Dashboard visual pass (2026-08-28) โ
- Issue:
#991: the shared Scaffold component carries the placeholder half of this work. - Branch:
feat/admin-and-merchant-portals - The verdict this starts from, hers, this morning: "I like the direction we are headed but the design is horrendous."
What is this for? โ
- The information architecture from yesterday's Shape 3 rebuild is right and stays. This is a visual pass on top of it, not another shape decision.
- The rebuild was verified accessible and never once looked at. This document starts by fixing that.
What did the before capture actually show? โ
There was no visual record of the rebuilt page, and the two captures that existed were stale. โ
screens/dashboard-redesign-1440.pngandscreens/dashboard-redesign-1100-stacked.pngare timestamped 2026-08-27 13:31.DashboardHome.jsx,StatusRail.jsxanduseDashboardRail.jswere all edited between 15:04 and 15:17 the same day, after those captures.- So the only pictures of the page predate its last three edits. The fresh ones are
screens/dash-before-*.png, captured 2026-08-28 against her running stack athttp://localhost:3001/admin/dashboard.
The reading column is 3108px tall and the rail is 392px. โ
- Measured, not estimated:
harness/dash-before.jsoncarries the geometry. - The rail is 12.6% of the column's height and sticky at the top, so roughly seven eighths of the right hand side of a very long page is empty background.
The release stream takes between 25 and 45 seconds to arrive. โ
- Timed with
harness/dash-capture.mjsand a stepped probe: 0 release days at t=10s, 0 at t=25s, 6 at t=45s. - The cause is the fetch path, not the rendering.
releaseFeed.jsreads through the docs API,VITE_DOCS_API_URLis unset, sodocsApi.jsfalls back to the DEPLOYED Cloud Run service in us-central1 and pulls the listing plus each of six changelog documents as separate round trips. Dev StrictMode double-invokes the effect, so the set goes out twice: 16 requests observed. - For that whole window the page renders a headline, a stage row, four links, and the words "Reading the changelog." over an otherwise empty screen. That is what she opens the Dashboard to.
The full page does not fit a fullPage screenshot, and that is the same fact behind axe's incomplete nodes. โ
- The admin shell scrolls in an inner container, so Playwright's
fullPageis capped at the viewport and silently crops the column. The capture script grows the viewport to the column's own height instead. - Yesterday's run chased 60 incomplete contrast nodes to a page-height artifact and was right to. The number today is 62 with the page fully loaded, and axe violations are 0.
What is the accessibility baseline this pass must not regress? โ
| Measure | Before (2026-08-28) | Method |
|---|---|---|
axe violations on .admin-page | 0 | axe.run('.admin-page'), axe-core from node_modules |
| axe incomplete contrast nodes | 62 | same run, page fully loaded |
| Console errors | 403s from a third party plus two React shorthand-style warnings, none from the Dashboard | captured in harness/dash-before.json |
Where does the evidence live? โ
harness/dash-capture.mjs: the capture and measurement script. Reads only, never restarts one of her services, and reuses a persistent browser profile so a run costs zero sign-ins against the 10-per-15-minutes bucket she shares.harness/dash-before.json: geometry, computed styles, rail contents, axe results.screens/dash-before-1440-full.png,-1440-viewport.png,-1100-full.png.
What did she say was wrong, and what happened to each? โ
Shown the before capture, she picked three of four offered complaints. The one she did NOT pick is recorded too, because a rejected complaint is as useful as an accepted one: it says the release list is not the problem.
| Her complaint | What was actually wrong | What changed |
|---|---|---|
| The rail and the dead space | A 240 x 392px card sticky beside a 3108px reading column, so roughly seven eighths of the right hand side was empty | The rail takes the reading panels' own padding (--space-4) and stacks each row as a label above a 22px number, taking it to 584px. Its heading gained a rule. The reading column now has a defined right edge, so the space beside it reads as margin |
| No surface, so it reads raw | The reading column was bare text on the page's pure black (.admin-main is #000) while the rail beside it was a bordered --surface card | Every reading section is a panel on the .dashboard-chart-section idiom the analytics pages already use: --surface, --border, --radius-lg, --space-4. Launch stage cards move up to --surface-2, since a card on --surface would now have no edge at all |
| Type scale and section headings | A section heading was 13px uppercase letter-spaced --text-secondary, which is a LABEL. It had no more presence than the entry titles three levels below it | Section headings are 1.0625rem at weight 700 in --text, sentence case, tracking -0.01em. The release day heading drops to a date band (0.75rem uppercase --text-secondary), so the stream reads section, then entry titles, then days as quiet dividers |
| The release list is a flat wall | Not selected | Left alone. Entry structure, sizes and colours are unchanged apart from the kind column, which now sizes to max-content instead of a guessed 4.5rem, so SECURITY stops overflowing a gutter sized for ADDED |
Two changes she did not ask for, because the measurement turned them up. โ
- A per day summary: "4 changes: 3 fixed, 1 security" on the date band. The band carried a date and a version and nothing else, so a heavy day and a light one looked identical until the entries were counted by eye. Built from the groups the file already parsed into, so it cannot disagree with what renders below it.
- A loading skeleton in place of one grey sentence over an empty column. See the load time section below.
Why the 30 second blank page was a wrong diagnosis, and what it really is โ
The before capture showed "Reading the changelog." over an empty column, and the first reading of that was that the Dashboard is slow to open. It is not, for her.
| Cache state | Time to render | Network |
|---|---|---|
| Warm IndexedDB (every visit after the first) | 2.0s | zero requests |
| Cold IndexedDB, dev | 31.6s | 10 requests |
| Cold IndexedDB, production | roughly 18s | the same minus the StrictMode repeat |
docsApi.js keeps a stale-while-revalidate IndexedDB cache, so a warm profile never goes to the network at all. Her browser is warm. She almost certainly never saw the blank page; a new admin would, once.
Inside the cold path, one call is the whole cost. GET /api/documents?dir=docs/changelogs/dev took 17.5 seconds. The six changelog documents that follow it landed together between 17.9s and 18.2s, so Promise.all is already doing its job and streaming them would buy nothing. The second listing at 31.6s is React StrictMode double-invoking the effect in dev only.
So the fix that was available here is the honest one: hold the stream's shape while it loads, so a cold first visit reads as loading rather than broken. Making the listing itself fast is server side, in the docs API, and outside this block's scope.
What does the announcements placeholder show? โ
- A panel headed Announcements, sitting between "What we are" and "Releases" in the reading column.
- Inside it a
Scaffoldreading "Writing an announcement is not built yet", with a note naming exactly what is missing: nowhere to compose, no publish, no targeting, no read state. - An inert sketch of one announcement, so the shape can be judged. It is wrapped in
inertplusaria-hidden, so it takes no click, cannot be tabbed into, and is never offered to a screen reader. - The age line: "Scaffolded 2026-08-28, wiring tracked in #992."
- Wiring issue: #992: an authoring surface for announcements someone WRITES, behind the Dashboard reading column.
What is still open on the rail? โ
The rail went from 392px to 584px, and the reading column from 3108px to 3624px, so the ratio moved from 12.6% to 16.1%. The right hand column is still mostly empty below the rail, and that is inherent: a sticky rail beside a long reading page cannot fill it.
Two structural options that WOULD close it, neither taken, because both trade something and the choice is hers:
| Option | What it does | What it costs |
|---|---|---|
| Cap the Releases panel with an internal scroll | The page becomes roughly viewport height, so the rail is never marooned | Turns a reading page into an app page, which is against the premise of the shape |
| Carry 3 release days instead of 6 | Halves the column, and "All changelogs" already carries the rest | Less on the page, when the ask this morning was for more |
Accessibility: before and after โ
| Measure | Before | After |
|---|---|---|
axe violations on .admin-page | 0 | 0 |
| axe incomplete contrast nodes | 62 | 0 |
The incomplete count going to zero is the real result. Those 62 nodes were text whose background chain was transparent all the way up to the page's black, which axe cannot resolve, so it reported them unmeasured and the gate passed without measuring anything. Giving the panels a real --surface background is what let axe finally measure them.
It caught two genuine failures on the way, both in new code, both fixed before the commit landed:
.dash-announce__bodyat 3.05:1 and.dash-announce__metaat 2.63:1. Cause: theScaffoldsketch dimmed toopacity: 0.55, and opacity composites text toward the surface behind it. 0.85 is the measured ceiling where--mutedstill holds 5.51:1; the meta line also moved from--text-secondary(4.52:1, no headroom) to--muted.link-in-text-blockon the issue link in the age line. Amber--accent-500against surrounding--mutedprose is 1.15:1, so colour alone cannot distinguish it. It is underlined always, not just on hover.
Measured contrast for every token in play, on both grounds:
| Token | On black #000 | On --surface #18181b |
|---|---|---|
--text #e6eef8 | 17.95:1 | 15.14:1 |
--muted #9aa6b2 | 8.47:1 | 7.15:1 |
--text-secondary #8a94a0 | 6.82:1 | 5.76:1 |
--accent-500 #f59e0b | 9.78:1 | 8.25:1 |
What is proposed for the timeline, and why โ
"Timeline" meant the launch stage row, not the release stream. โ
- Her words when the release options were put to her: "When I was mentioning timeline, I meant the 'Prototype, Alpha, etc'. The release design was fine as it was, it didn't really need any change."
- So the four mocked release variants in
screens/timeline-fluff-options.pngare dead. They stay in the folder as a record of the wrong turn and of one finding worth keeping, below. - Everything this pass had changed in the release stream was reverted: the per day summary line, the date band treatment, and the entry gutter. The stream renders exactly as it did yesterday.
- The one thing that could not simply be reverted is the section heading. It had been raised to 17px while the day heading was demoted to a band; with the band reverted to its original 16px, 17px sat too close to it. The section heading went to 1.25rem (20px) instead, so the gap is closed by raising the heading she asked about rather than by touching the stream she did not.
One finding from the wrong turn, kept because it is still true. โ
ReleaseStream.jsx drops every GitHub triage label, on the reasoning that they are routing metadata. Counted across every changelog file there are 497 labels, and the reasoning is right for 325 of them (enhancement 233 and bug 92 only restate the kind chip beside them) and wrong for the other 172 (security 77, devops 50, design 23, documentation 8, plus 14 others), which name an AREA the chip cannot express. Not acted on, because she has since said the stream is fine. Worth knowing if it is ever revisited.
What was built for the stage timeline โ
She picked Option A, added "I also want it pretty", and then added a constraint mid-build: "Note it needs to be scrollable as we continue to have timeline builds."
What the track does โ
- A connecting line with a marker per stage. Solid amber behind stages already passed, a gradient fading out through the one we are in, plain
--borderahead of it, so the LINE reads as progress rather than four boxes in a row. - The current marker glows on a slow breathing loop, which is the visual idea of this product. It stops under
prefers-reduced-motion. - Each stage carries its one line goal, read from the launch plan's own "What each stage means" table via a new
parseStageMeanings. That table has always been in the file and the parser read straight past it, so the row could name four stages without ever saying what any of them were for. The copy is the plan's, not an agent's summary. - It scrolls horizontally, with no visible scrollbar (the design rule: an internal scroll region shows no gutter and no thumb, so
.no-scrollbarrides on the track). It is keyboard reachable, which axe requires of a scrollable region holding no focusable children, and the current stage is scrolled into view on mount so it cannot sit off the right edge once there are more stages than fit.
Two numbers that were measured rather than picked โ
--dash-stage-width: 9.5rem. The reading column is 762px and the panel takes--space-4each side, leaving 714px. Four stages at 9.5rem plus three--space-3gaps is 656px, so today's four fit with room to stretch and a fifth is what first makes it scroll. Verified live:scrollWidth712 againstclientWidth712, so it does not scroll today. 12.5rem was tried first and cut 1.0 in half on the real page.- The last line segment ends at
calc(100% - 6px). The marker sits at each stage's LEFT edge, so the final segment has to stop half a marker in from that edge. The first attempt ran the line to the panel edge, implying stages that are not there.
Future stages are dimmed with a token, never with opacity โ
--text-secondary at full strength is 5.76:1 on --surface. The same text at opacity: .72, which is what the mockup used, composites to roughly 3.4:1 and fails AA. This is the third time today that trap appeared, and it is now written into the CSS comment beside the rule.
A test caught a real bug in the new parser โ
The section regex ended (?=^#{1,2}\s|\Z). JS has no \Z anchor, so that alternative matched a literal "Z". It passed against the real launch plan purely because that file happens to have a later heading, and would have returned nothing the day "What each stage means" became the last section. The fixture in releaseFeed.test.js ends on that table, which is what surfaced it. The parser now takes everything after the heading and cuts at the next one.
| Story | Stages | Scrolls | axe |
|---|---|---|---|
Default (today's plan) | 4 | no | 0 violations |
PartwayThrough (current is Beta) | 4 | no | 0 violations |
MoreStagesThanFit | 7 | yes, and scrolled to centre the current one | 0 violations |
WithoutTheMeaningsTable | 4, no goal lines | no | 0 violations |
Captures: screens/sb-track-default.png, sb-track-partway.png, sb-track-scrolling.png, sb-track-no-meanings.png. The mocked options that produced the pick: screens/stage-timeline-options.png.
Final numbers โ
| Measure | Before | After |
|---|---|---|
axe violations on .admin-page | 0 | 0 |
| axe incomplete contrast nodes | 62 | 0 |
| Reading column height | 3108px | 3731px |
| Status rail height | 392px | 584px |
| Dashboard tests | 30 passing | 30 passing (4 new, on the stage meanings parser) |