feat(dash): group the 17 dashboard tabs into 5 question-based groups - #171
Merged
Conversation
added 3 commits
September 1, 2026 14:05
Seventeen co-equal top-level tabs was a flat list pretending to be a
hierarchy: every viewer scanned all seventeen labels to find one, and the
manager got no signal about which three mattered. The nav is now two levels
and five groups, chunked by the question the viewer arrived with — Overview,
Savings, Behaviour, Traffic, Admin — so a manager can stop after Savings and
an operator can carry on, on one nav rather than two products.
The URL carries both levels. The canonical hash is `#/<group>/<view>`, and the
rule is that the LAST path segment is the view: anything before it is a group
hint, ignored whenever the view is known. That makes the one-level `#usage`
this dashboard has always written and the new `#/savings/usage` the same link,
and it is decidable without a lookahead because no view name contains a slash.
Two of those one-level shapes are written by the server — dash/kvcache.go
builds `#requests?req=<id>` and `#sessions?diff=<escaped id>` for every row of
the KV-cache table, and the UI is forbidden from building them — so the
one-segment branch is not a shim to be tidied away later.
The whole contract is a table in dash/navhash.test.mjs, run against the real
resolver in app.js rather than against a Go re-implementation of it: seventeen
bare view names, all fourteen filter dimensions, from/to, sort/dir, the three
drawer keys, legacy `range=<ms>`, the two server-authored shapes, and every
rewrite case. `go test ./dash/ -run NavHash` runs it under node when node is
present and says loudly what went unverified when it is not;
TestTheNavHashContractIsPinned holds either way.
Three things fixed while rebuilding this markup:
- ONE TAB STOP PER LEVEL. Seventeen role=tab buttons were seventeen Tab stops,
so reaching the filter bar by keyboard took seventeen presses. Now it is a
roving tabindex with the arrows moving inside each tablist, and manual
activation — automatic would fire seven queries scrubbing across Admin.
Measured over 34 tab x theme combinations: 39 stops per page down to 23.
- REAL TABPANELS. There were none; all seventeen view sections now carry
role=tabpanel and aria-labelledby, and each tab its aria-controls.
- 44px TOUCH TARGETS. Nav tabs were 28px tall at every width and nothing about
them reflowed. Nav elements under 44x44 at 390px: 34 to 0.
Contrast failures and missing focus rings stay at 0 across all 68 audited
combinations, and no view gains horizontal body scroll.
The three self-mounting views now go through one mountTab({group, after, view,
label}) in app.js instead of each reaching into `.tabs` and insertBefore-ing a
named sibling. The tab exists before the view body is built, so lazy-loading
those bodies later is a change to the caller and not to the nav.
On a single-tenant local proxy the manager-gated tabs without data-local-ok are
now disabled with a one-line reason rather than hidden: only nine of the
seventeen tabs are otherwise reachable there, so hiding the rest made the
product look half its size and read as a broken build. data-account tabs stay
silently hidden — a signed-out viewer has no use for a tab they cannot enable
from here.
The chrome is one sticky box now. It was three separately-stuck elements each
needing the previous one's height as a magic `top` offset (.filters was
`top: 41px`), which a second nav level breaks and which nothing kept honest.
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Five groups wrapping to two 44px rows plus Admin's seven tabs on another two came to 176px of nav, which is more vertical chrome on a phone than the seventeen 28px tabs it replaced. Level 1 already carried `overflow-x: auto` below 900px and `flex-wrap: wrap` was quietly defeating it, so it is one scrolling row now: the group names are a fixed set of five a reader learns, which is what makes a scroller acceptable there and not on the second level, where a hidden tab is a tab nobody knows exists. Total chrome on the worst group (Admin) at 390px: 228px before, 217px after. syncNav scrolls the open group into view, since a selected tab half off the edge is the failure mode a scroller introduces. Re-audited at 390px across all 17 tabs x both themes: 0 contrast failures, 0 missing focus rings (a scrolling flex row can clip a box-shadow ring, and does not here), 0 nav elements under 44x44, and the four views that scrolled horizontally before still are exactly those four. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Six appendChild(tbl) sites had no scroll wrapper: loadMachines, loadTokens, loadAudit, loadTenants, loadVariants and loadArchive. None overflows any tab on today's corpus, so this is a latent-defect list rather than a bug list — overflow here depends on the data, and one long MCP tool name or one wide model id is the difference. The BRIEF's hard constraint is that the page body must never scroll sideways. .tblwrap and its tabindex="0" are the existing idiom (style.css:561, eight call sites already); the tabindex is not decoration — a scroll region a keyboard user cannot focus is axe scrollable-region-focusable, as app.js:3178 says at the one site that explains it. Three of the nine sites impl-perf enumerated are deliberately not here: toggleBenchTasks (4028), loadKAArmed (8533) and loadKABehaviour (8623) sit inside functions impl-tabs is rewriting, so they go with that work rather than conflicting with it. A static check that every appendChild(tbl) is wrapped is worth adding once all of them have landed; it would fail on this branch today. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seventeen co-equal top-level tabs was a flat list pretending to be a hierarchy: every viewer scanned all seventeen labels to find one, and the manager got no signal about which three mattered. This is two levels and five groups, chunked by the question the viewer arrived with.
usage·campaigns·benchmarkscomponents·tools(Inventory) ·keepalive·kvcachesessions·requestsconfig·strategies·tenants·setup·settings·archive·feedbackGroup order is the manager→operator gradient: a manager stops after Savings, an operator carries on, nobody is denied anything. Each group's first tab is the manager-legible one.
Accessibility — the headline
Measured over 34 tab×theme combinations per viewport,
origin/mainvs this branch, both sides fully populated:role="tabpanel"Seventeen
role="tab"buttons were seventeen Tab stops, so reaching the filter bar by keyboard took seventeen presses. Now it is a roving tabindex per level with arrows/Home/End, and manual activation — automatic is the pattern's default, but every tab fires a data fetch and scrubbing across Admin's seven would issue seven queries nobody asked for.† Deliberate, not an oversight. The 44px floor is applied only below 900px, so the desktop topbar stays one 41px row on a pointer-fine surface. The two-level nav adds buttons at 1440, all 28px tall, which is why that one number rises.
Contrast denominators are stated because
0/0/0is a scan that found nothing, not a pass.And the Admin group's zeros are thinner than they look. All seven Admin tabs audited at 23-75 visible text nodes, five of them under 50 —
tenantsat 27, which is its empty state. @impl-tabs's cookie-carrying harness renders 27 rows there. So0 contrast failuresand0 missing focus ringsare true of every page this harness can reach, but the Admin group was measured near-empty on both sides of the comparison: it is unaudited, not clean. Supporting that reading: the first two genuine contrast failures ever found on this product (feedback,strategies, in #173) surfaced the moment the harness could carry a session. The nav numbers above — tab stops, tabpanels, touch targets — are structural and unaffected; the colour and focus zeros for Admin need a re-run behind auth.The hash contract
Canonical form is
#/<group>/<view>, collapsed to#/overviewfor the one group that is also a view.The rule: the last path segment is the view; anything before it is a group hint, ignored whenever the view is known. That makes the one-level
#usagethis dashboard has always written and the new#/savings/usagethe same link, and it is decidable without a lookahead because no view name contains a slash.#/admin/campaigns(stale group) resolves and rewrites to#/savings/campaigns.Two of those one-level shapes are written by the server —
dash/kvcache.go:510-511builds#requests?req=<id>and#sessions?diff=<escaped id>for every KV-cache row, anddash/uikvcache_test.goforbids the UI from building them. So the one-segment branch is not a shim to tidy away later; it is half the traffic. No Go change was needed.dash/navhash.test.mjsruns the real resolver out ofapp.jsin a function scope over a ~12-line stub DOM — no second implementation, no proxy, no port, no HTTP, no 5s response cache, 0.2s. 10 tests: 17 bare views + canonical round-trip · all 14 filter dimensions individually and together ·from/torelative and absolute,toomitted whilenow, junk→0 not NaN ·range=<ms>over 6 durations including the documented86400000→now-1d·sort/dirwritten only on components ·req|diff|acctexclusivity · the two server-authored shapes includingsess%2Fwith%20space→sess/with space·#/group,#/group/typo, two-segment without a leading slash · 9 junk hashes →#/overview· nav/loader/section agreement. The tab table is parsed fromindex.htmlplus the threemountTab({...})call sites, so a tab moved between groups moves the test with it.go test ./dash/ -run NavHashruns it under node and skips loudly naming what went unverified when node is absent;TestTheNavHashContractIsPinnedholds either way.How much to trust the numbers above
I threw out a set of figures that favoured this branch. A re-run reported
bodyScrollsX 8 → 5andclipped 14 → 7. Both were fake:Same build, same viewport, different theme, opposite outcomes → a race, not a defect.
shots.mjshas no per-tab ready selector: it waits for[data-view](the nav, which exists immediately) at:227/:244and then a fixed 1400 ms, and these are the two heaviest queries on a 16,444-request corpus. Those views "stopped overflowing" by being empty. The rows quoted above are off real content on both sides — verified by text-node count, since a skeleton reads as ~15 nodes and a rendered table as 1156+.perf.mjsis unaffected; it has a real ready selector per tab. The table above is from the run where kvcache is 2038→2030 and tools 1164→1156 in all four combos.clippedis struck entirely.span.comp-name.truncis the only element that ever appeared in it across five runs, and that truncation is deliberate and non-lossy (style.css:598-602text-overflow: ellipsis,tools.js:957/:1069carrytitle: t.name). The metric carries no signal on this corpus. Thanks to @impl-perf for catching it before anyone "fixed" working code.The eight combos still scrolling at 390px are unchanged by this branch
Identical set before and after —
keepalive,kvcache,strategies,tools, each in both themes. Not caused here and not fixed here: all four are addressed in #169 (perf/dash-load-0901, which reports keepalive +753px, strategies +183px, kvcache +16px → zero).toolsalso carries pre-existingmcp__*name truncation routed toimpl-inventory. The1440 → 0result above is a clean one and should not be read as covering the mobile situation.Also here
mountTab({ group, after, view, label })replaces three copies of "reach into.tabsandinsertBeforea named sibling" intools.js,kvcache.jsandcampaigns.js. One place knows the nav's DOM shape. The tab button exists before the view body is built, so lazy-loading those bodies stays a change to the caller and not to the nav. The three are deliberately absent fromGROUPSinapp.js— hardcoding them would restore the coupling self-mounting exists to avoid.data-local-okrenderaria-disabledwith one line under the tab row ("Strategies and Tenants need a manager sign-in.") instead of vanishing. Only nine of seventeen tabs were otherwise reachable there, so hiding the rest made the product look half its size and read as a broken build. Visible tabs go 9 → 12.data-accounttabs stay silently hidden: a signed-out viewer has no use for a tab they cannot enable from here. A group with nothing reachable hides its group button.top(.filterswastop: 41px), which a second nav level breaks and nothing kept honest..chromesticks instead. That makes it a containing block for absolutely-positioned descendants, so: enumerated all fiveposition: absoluterules, established only.filters details.more > .more-bodyis inside it, then measured the popover against its own<summary>on both builds — identical to the pixel at both widths. Below 620pxstyle.css:371-373makes itstatic(not a popover at all on a phone); at 1440details.more { position: relative }stops the chain. There is no width at which.chromeis its containing block..tblwrapidiom:loadMachines,loadTokens,loadAudit,loadTenants,loadVariants,loadArchive. A table with no scroll container is only visibly broken once its content is wide enough, so "none overflows today" is a claim about the data, not the code —toolsaudited clean at 14,876 requests and failed at 16,444. Thetabindex="0"is not decoration (axescrollable-region-focusable).## Navigationsection indocs/dashboard.mdwith the group table, the hash rule and the rewrite cases;docs/dashboard.md:895anddocs/dashboard-kvcache-page.md:22updated.Known gap, on purpose
A static "every
appendChild(tbl)is wrapped" check would fail on this branch today, which is why it is not here. Three of the nine sites —toggleBenchTasks(4028),loadKAArmed(8533),loadKABehaviour(8623) — sit inside functionsimpl-tabsis rewriting, so they go with that work rather than conflicting with it. The check becomes addable once those land.Benchmarks moves under Savings — and can now say why it is empty
Benchmarks moves under Savings as evidence for the Overview claim, which makes it more prominent. It could not previously say why it was empty:
dash/api.go's?refresh=1returned{ingested_runs, ingested_tasks}and the click handler discarded the body, so a wrong--dashboard-bench-dirsand a correct scan of an empty directory rendered identically. I was going to file that as a follow-up needing a new payload field.No longer needed — @impl-tabs took both halves on
fix/tab-verdicts-and-denominators-0901:?refresh=1now also returnsdirs, and the handler distinguishes all three outcomes (scanned N dirs / no flag configured / scanned but no run directory found). The empty state's instruction, which named the non-existent--dash-bench-dirs, is fixed there too. So this PR does not promote a tab that cannot explain itself — but that fix lands in their PR, not this one, and both branches are independent. Nothing here depends on it.Verification
Live in Chromium, 29 hash cases resolving with exactly one visible view each, 17 tabpanels, 1+1 tab stops. Both proxies verified by pid+exe before measuring (
/healthzreturns 200 from a sibling's proxy if your own bind failed), and--dashboard-retention 0 --dashboard-max-bytes 0on both withgrep -c pruned= 0 — corpus 16,444 requests / 53,627 tool_declarations, unchanged end to end.Tests updated, none deleted:
dash/uikvcache_test.goasserted the literals'data-view': 'kvcache'/id: 'view-kvcache', which now live in themountTabcall — rewritten to assertmountTab({,view: 'kvcache',group: 'behaviour'and that the file no longer contains$('.tabs')/insertBefore(tab, which is stronger than what it replaced.dash/api_test.gogained the four group testids andtab-note; all six originaltab-*literals kept, and one tab per line preserved for the gating check at:371.Module seams for the
app.jssplitapp.jscarries a comment naming them: shell+router (GROUPSthroughapplyURL, exporting onlygo/mountTab/syncNav, depending only onloaders/DIMS/state/DOM helpers), overview+usage+components, sessions+requests+drawer, and admin.