Skip to content

fix(dashboard): pin the view-mode switcher and add a v shortcut to cycle Timeline / Grid / List (#2536) - #2547

Merged
vybe merged 6 commits into
devfrom
vybe/issue-2536
Sep 7, 2026
Merged

fix(dashboard): pin the view-mode switcher and add a v shortcut to cycle Timeline / Grid / List (#2536)#2547
vybe merged 6 commits into
devfrom
vybe/issue-2536

Conversation

@trinity-ability

@trinity-ability trinity-ability commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The Timeline / Grid / List switcher sat mid-way through the header's right-anchored controls cluster with two conditional siblings to its right — the grid-only Tidy up / Reset pair and the history-loading spinner — so entering Grid moved the control the operator had just clicked, and every refresh / time-range change twitched it while the spinner showed.
  • The switcher is now the cluster's last child. In a right-aligned, non-shrinking flex row a child's x depends only on the siblings to its right; with none, x = cluster.right − switcher.width, both invariant. This is a reorder, not a reserve: the cluster's total width per (mode × loading) state is byte-identical to dev, so the bug: Dashboard stats bar — right controls overlap host telemetry below ~1170px effective width #1830 stats-overflow sweep sees the same inputs it sees today.
  • v cycles Timeline → Grid → List → Timeline from inside the existing / hotkey handler's guard ladder (repeat / chords / IME / editable targets / open modals shared; one document listener; armed above the mount await), persisting through setViewMode exactly like a click.
  • The mode list moves to the zero-import leaf utils/viewModes.js — the store copy was ordered grid, timeline, list and a cycle built on it would have run against the buttons.
  • Frontend only: zero backend, zero MCP, zero migrations.

Closes

Fixes #2536

What changed (by commit)

  • 1c12ba40 docs(requirements) — new §9.14 in docs/memory/requirements/core-agent.md (Rule Fix: Add missing Docker labels to system agent container #1, first commit): activation + guard order, cycle order = visual order = default, persistence, discoverability, layout invariant, known gaps (guard-5 pane-internal popovers, WCAG 2.1.4), follow-ups.
  • a3e8ca48 refactor(dashboard)src/frontend/src/utils/viewModes.js (zero imports: VIEW_MODES, DEFAULT_VIEW_MODE, nextViewMode); stores/network.js drops its local list and uses the shared one; Dashboard.vue's v-for reads VIEW_MODES; e2e/dashboard-stats-overflow.spec.js imports the list (same order, sweep unchanged); new tests/unit/viewModes.spec.js (4 tests) and tests/unit/viewModeStructure.spec.js one-home / zero-import / one-listener guards.
  • 704ac54f fix(dashboard) — switcher moved verbatim to the last child of the controls cluster; Tidy up / Reset moved verbatim to immediately before it; data-testid="view-mode-switcher" / history-loading / refresh-all added; the site comment records why (and the [BUG] Dashboard navigation and timeline controls overlap at narrow desktop widths #1754 collapse-from-the-left hand-off); viewModeStructure.spec.js gains the SFC-AST last-child + adjacency guard with a mutation control; new e2e/dashboard-mode-switcher.spec.js bounding-box test (3 modes × spinner held / absent).
  • 132033f6 feat(dashboard) — guard 1 of handleDashboardKeydown becomes a dispatch: / → open filter (Shift NOT excluded — de-DE / is Shift+7), v / V without Shift → cycleViewMode; guards 2–5 stay one code path; title="Switch view (press v to cycle)" on the wrapper (no aria-label on the mode buttons — their accessible names are contract for five e2e specs); e2e cycle-and-persist + guards tests.
  • 3d9ab3f0 docs(dashboard)architecture/frontend.md clause; dashboard-grid-view.md / dashboard-list-view.md / dashboard-timeline-view.md; feature-flows.md Recent Updates row; user docs (2 lines); §9.14 → ✅ Implemented.

Proof

  • verify-local PASS on this branch: unit 13899 passed, 32 skipped; integration 70 passed, 13 skipped, 2 deselected; image build + import-smoke OK; boot + health OK; venv Python 3.13.5.
  • vitest 86 files / 1847 passed; post-rebase neighbourhood (viewModes, viewModeStructure, mountListenerOrdering, loadingGateRatchet): 4 files / 24 passed.
  • Mutation controls (each reverted before commit): Tidy/Reset moved after the switcher → viewModeStructure 2 failed / 6 passed; a second document keydown listener → 1 failed; nextViewMode off-by-one → 2 failed.
  • Ratchets vs HEAD: views/Dashboard.vue raw-colour 23 / 126 / 2 == origin/dev (the committed baseline 18/77/2 is stale and deliberately NOT regenerated in a bug fix); loading gates Dashboard.vue: 1 (unchanged); check:tokens OK.
  • Playwright e2e/dashboard-mode-switcher.spec.js — 4 passed (3 tests + the auth setup project), run against a worktree Vite: the dev stack on :80 bind-mounts a different checkout, so CI's advisory @smoke job is the first :80-equivalent run.
  • /review clean, 0 fixes; /validate-pr APPROVE (no Critical, no Warnings; the only note is the three dashboard flow docs' pre-existing bespoke section shape, left alone per Rule Feature/gemini runtime support #2).

Decisions for the reviewer

  1. PRODUCT DECISION (defaulted): the cycle key is v. Mnemonic for the product's own "view" vocabulary (trinity-dashboard-view, ?view=); mirrors the existing / binding's guard ladder (a plain layout-produced key via e.key); zero collisions at HEAD (the only printable-key document handler in src/frontend/src is /); Shift+V deliberately left free (reserved for a possible reverse cycle). Rejected in the plan: 1 / 2 / 3 direct jumps (optional per the AC, a ≈3-line opt-in on the dispatch map — note AZERTY's top-row digits are Shift-produced), g / t / l (three keys, and g is a vim prefix), m, and Tab chords (design-system principle 23). Override here if you want a different key.
  2. Tidy up / Reset now sit immediately LEFT of the switcher as one control group (the grid tools beside the Grid button that summons them) — defaulted. Reserving their width instead was rejected: it adds width in every mode and changes the bug: Dashboard stats bar — right controls overlap host telemetry below ~1170px effective width #1830 sweep's inputs.
  3. NOT done, deliberately (opt-in if wanted): folding the history spinner into the Refresh button (a visible change beyond the ask; refactor: replace bespoke spinners with skeleton placeholders on non-chart surfaces (scanline stays on charts only — design-system p12 as amended 2026-09-06) #1921's remit — the spinner stays a v-if, loading-gate count unchanged); any AT attributes (role="group", aria-label="View mode", aria-keyshortcuts="v" on the wrapper, aria-pressed on the buttons) — they change what assistive tech perceives, so they are listed for Design system v2: shared UI primitives, raw-color ratchet, and written style guide #1430 rather than defaulted.

Not in this PR / follow-ups (not filed)

Pre-existing reds reconfirmed on base

A/B-proven identical on origin/dev with the same tag-rich dev stack: dashboard-stats-overflow.spec.js grid@640 (9 px overlap) and grid-org-overlay.spec.js:134 .gv-zone strict-mode violation with 2 departments.

🤖 Generated with Claude Code

CI note — CodeQL

The CodeQL summary check flagged one new alert, js/incomplete-multi-character-sanitization, at src/frontend/tests/unit/viewModeStructure.spec.js (stripComments). It is a false positive: the regex blanks <!-- --> comments from a Vue SFC template string so the structure guard can regex-scan source text; the result is fed only to RegExp.test / expect().toMatch, never rendered or inserted into a DOM (no innerHTML/v-html sink in the file; vitest runs with environment: 'node'). Dismissed as used in tests with this rationale. Both Analyze (python) and Analyze (javascript-typescript) jobs are green.

vybe and others added 5 commits September 6, 2026 18:30
Rule #1: the requirement lands before any code. §9.14 specifies the two
halves of #2536 — the Timeline / Grid / List switcher pinned as the LAST
child of the right-anchored header controls cluster (x-position then
depends on nothing to its right), and `v` cycling the modes in the
switcher's visual order through the `/` hotkey's shared guard ladder.
Records the known gaps (guard 5 does not cover pane-internal popovers;
WCAG 2.1.4 single-key shortcuts) and the follow-ups (1/2/3 jumps,
Shift+V reverse, e.code matching, AT attributes, #1921, #1754).

Status 🚧 In Progress; flipped to ✅ in the docs commit that closes the
change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s.js (#2536)

The mode list lived twice, in DIFFERENT orders: `['grid','timeline','list']`
in stores/network.js (whitelist only — order never mattered, not exported)
and `['timeline','grid','list']` in the Dashboard's switcher v-for (the
visual order). A `v` cycle built on the store copy would have run against
the buttons. Consolidate into one zero-import leaf, `utils/viewModes.js`
(the #2199 gridStorageKeys shape — Playwright resolves neither the Vite `@`
alias nor a paths map, so e2e specs import it by relative path), exporting
VIEW_MODES (frozen, visual order), DEFAULT_VIEW_MODE (index 0, the degrade
target) and nextViewMode() (unknown wraps to the default).

Consumers: the store imports the whitelist and uses DEFAULT_VIEW_MODE at
its two degrade sites (init + setViewMode; the identity checks stay
literal); the view iterates the import in the v-for; the #1830
stats-overflow sweep imports the same list (identical order → identical
sweep). Behaviour-neutral.

Guards: tests/unit/viewModes.spec.js (pure module — order, default,
frozen, cycle, unknown wrap) and tests/unit/viewModeStructure.spec.js
(the literal appears in exactly ONE file under src/ + e2e/ on
comment-stripped source; the leaf has zero imports; store + view import
it; Dashboard.vue registers exactly one document keydown listener). The
last-child assertion lands with the move in the next commit.

npm run test:unit: 86 files / 1845 tests (HEAD: 84 / 1835).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#2536)

The header's right-hand controls cluster is a right-anchored, non-shrinking
flex row (`justify-between` + `flex-shrink-0`), and the Timeline / Grid /
List switcher sat in the MIDDLE of it with two conditional siblings to its
right — the grid-only Tidy up / Reset pair and the history-loading spinner.
In an LTR flex row a child's x is `cluster.right − Σ(width+gap of every
sibling to its RIGHT)`, so entering Grid moved the control just clicked
(~124px) and every history fetch twitched it (~24px).

Move the switcher to be the cluster's LAST element child and the Tidy up /
Reset `<template v-if>` to immediately before it (tail: Refresh ▸ [Tidy up
▸ Reset]* ▸ Switcher). Both blocks move VERBATIM — a reorder, not a
reservation: no colour class added, removed or rewritten, no new width in
any (mode, loading) state, so the #1830 stats-overflow sweep sees the same
inputs it always did. Added only: `data-testid="view-mode-switcher"` +
`title="Switch view (press v to cycle)"` on the wrapper (a `title`, never an
`aria-label` — five e2e specs select the buttons by exact accessible name),
`data-testid="history-loading"` on the spinner (its `v-if` untouched;
loading-gate count stays 1), `data-testid="refresh-all"` on Refresh. DOM
order moves with the visual order (no CSS `order:`), so tab order still
matches (principle 23).

Guards:
- tests/unit/viewModeStructure.spec.js: parses the SFC with
  vue/compiler-sfc and asserts the switcher is the last ELEMENT child of
  its parent (a `<template v-if>` counts, so a conditional sibling fails
  too) and that the grid-controls template is the element immediately
  before it. A mutation control splices a `<span v-if>` after the switcher
  in the real source and proves the probe reports it — the guard is not
  vacuous. This is the REQUIRED CI gate (frontend-build.yml runs test:unit;
  the e2e suite is advisory).
- e2e/dashboard-mode-switcher.spec.js (@smoke): holds
  `/api/activities/timeline` at the network boundary so the spinner is
  deterministic, then asserts the switcher's boundingBox is identical
  (≤1px) with the spinner, without it, in grid (Tidy up / Reset visible
  while measuring), list, timeline, and again while Refresh re-holds the
  spinner; plus `nextElementSibling === null`. Negative control run
  locally: with the grid template moved after the switcher the spec fails
  in grid mode with x 1115.67 → 989.61.

Ratchets unchanged: views/Dashboard.vue raw colours 23/126/2 (== HEAD; the
committed baseline is stale, not regenerated), loading gates 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…'s guard ladder (#2536)

`v` (and Caps-Lock `V` — `shiftKey` false) advances the view mode in the
switcher's visual order, Timeline → Grid → List → Timeline. It is dispatched
from INSIDE handleDashboardKeydown: guard 1 (`e.key !== '/'`) becomes a
lookup that picks openFilterPill for `/` and cycleViewMode for `v`, so
guards 2–5 — Ctrl/Meta/Alt chords, IME composition, editable targets
(INPUT / TEXTAREA / SELECT / contentEditable), open modals — stay ONE code
path, and guard 0's `e.repeat` means a held key cycles once. `/` still does
NOT exclude shiftKey (de-DE `/` is Shift+7); `v` DOES — the letter never
needs Shift on any layout, so Shift+V stays reserved (a future reverse
cycle) instead of silently cycling forward. Same document listener, same
registration slot above the mount await (#2200; mountListenerOrdering.spec
unchanged and green; viewModeStructure.spec pins exactly one keydown
listener).

cycleViewMode calls networkStore.setViewMode(nextViewMode(viewMode)) —
the exact path a switcher click takes — so localStorage and the active
button cannot disagree with the pane; the `?view=` deep-link's
`persist: false` path is untouched. Discoverability is the wrapper's
`title="Switch view (press v to cycle)"` (added with the pin in the
previous commit), mirroring the filter button's "(press /)".

e2e (both @smoke, run locally against a worktree Vite server on :5180
because the :80 stack bind-mounts a different checkout):
- `v` cycles all three modes; localStorage follows each press (read in the
  browser context); a reload after a further `v` lands in the persisted
  mode. The saved key is cleared once in-page — never via addInitScript,
  which re-runs on reload().
- Guards: inert with focus in the time-range <select> (targeted by its
  `24h` option — the first <select> is the Owner filter on multi-owner
  stacks), under Control/Alt/Meta/Shift chords, inside the filter pill
  (the typed `v` becomes a live query that legitimately filters
  trinity-system out, so the pane is asserted, not the row), and with
  focus on the Create Agent modal's Cancel button (a BUTTON, so guard 5 is
  what suppresses it). Positive control: a bare `v` then switches. The
  wrapper's title names the key; no mode button carries an aria-label.

npm run test:unit: 86 files / 1847 tests. e2e/dashboard-mode-switcher:
3 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Implemented (#2536)

- requirements/core-agent.md §9.14: 🚧 In Progress → ✅ Implemented.
- architecture/frontend.md (Dashboard Grid view paragraph): VIEW_MODES now
  lives in the zero-import leaf utils/viewModes.js — one home for the store
  whitelist, the switcher v-for, the `v` cycle order and the e2e specs; the
  switcher is deliberately the LAST child of the header controls cluster;
  `v` cycles through handleDashboardKeydown's shared guard ladder.
- feature-flows/dashboard-{grid,timeline,list}-view.md: the toggle is the
  last header control (pinned across modes and the history spinner), `v`
  cycles the modes, and the "second home of VIEW_MODES" wording is retired.
- feature-flows.md: new top row for 2026-09-06 / #2536.
- user-docs (operations/dashboard.md, guides/using-trinity.md): the pages
  already advertise `/`; staying silent on `v` would be a doc/product
  mismatch — one bullet and one clause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@trinity-ability
trinity-ability marked this pull request as ready for review September 6, 2026 17:36
Comment thread src/frontend/tests/unit/viewModeStructure.spec.js Dismissed
# Conflicts:
#	docs/memory/feature-flows.md

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: Fixes #2536 (P2 type-bug), targets dev, 15 files, frontend only. Mode list moved to a zero-import one-home module with a vitest structure guard (last-child + adjacency, mutation control) and a named e2e spec; v rides the existing / keydown guard ladder with one document listener. Requirements §9.14, architecture/frontend.md, three dashboard flows and user docs updated. Security greps clean. Merged current dev to resolve the feature-flows.md index conflict; CI fully green on the merged head.

@vybe
vybe merged commit c9074cd into dev Sep 7, 2026
28 checks passed
@vybe
vybe deleted the vybe/issue-2536 branch September 7, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants