What the dashboard still needs before it leaves beta
relay-80, relay-81 and relay-82 were the design lift the owner asked for: the web view now shares one design language with the product, applies it at operational density, and the inbox is built for triage rather than for reading. This page is the audit afterwards, written by going through the thing as somebody who did not build it.
It is not a victory lap. The recommendation at the bottom is that the beta label stays on, and the reasons are here.
Everything below was reproduced, not guessed. Where a number appears it was measured; where something is deferred it says why and roughly what it would cost.
1. Found and fixed during this pass
Recorded because they show the class of thing that was wrong, and because two of them were invisible to the test suite and only showed up in a screenshot.
| what | how it was found |
|---|---|
?days=abc on the standup returned a 500 error page — a hand-typed query string was a server fault | probing query strings |
?days=1e9 was a 500 by a second route: finite and positive, and still outside the range core accepts. The window is now clamped as well as validated | writing the test for the first fix |
| The active project in the nav measured 4.36:1 in dark, below AA at 14px/600 | measuring every pair |
| Heading order on the home page was h1 → h3 → h2 | reading the markup |
No prefers-reduced-motion block, though the site calls that rule non-negotiable | checking against site/README.md |
Every progress bar rendered on its own line (.meter was an inline span wrapping a block child) | reading a screenshot |
At 390px, “waiting on checkout-2” was clipped by the panel’s overflow:hidden | reading a screenshot |
Each of those is now a test rather than a paragraph — tests/design.test.ts
computes every contrast pair from the tokens, walks the heading levels on every
page, and requests the malformed query strings. An audit finding that is only
written down is a finding somebody has to remember.
2. Not ready, with the reason and the size of the fix
2.1 A cancelled task is invisible — medium, needs core
board() returns in-progress, in-review, ready, blocked, stale and
recently-done. A cancelled task is in none of them, so on a board with one
cancelled task the only trace is a line in the activity feed. Somebody
wondering where a task went cannot find it from the board at all.
Reproduced: a project with one cancelled task renders its title exactly once on
/p/:slug, inside the feed.
The fix belongs in src/core/board.ts (a cancelled bucket, or an explicit
“this board omits cancelled work” contract) rather than in the view, because a
view that queried tasks directly would be a second source of truth. Left for
the core owner — noted on relay-83.
2.2 No way to find a task except by scrolling — medium
There is no search, no filter and no jump-to-id. commission search exists on the
CLI; the web view has no equivalent, and no URL you can type to reach a task
you know the id of except /t/<id>, which is not written down anywhere on the
page. On a project with three hundred tasks that is the difference between a
tool and a wall chart.
Not a design problem and not small: a search box is a form, and a form on a
read-only page is a GET with a query string — which is fine and does not
breach the write boundary, but it needs a result page, an empty result state,
and a decision about what is searched (titles, bodies, notes). Half a day.
2.3 The board does not scale — medium
Measured: a project with 302 tasks renders a 95KB board with no pagination, truncation or virtualisation, and the page auto-refreshes every 60 seconds. Every task in the project is in the HTML on every load.
There is no design here yet at all. It needs a decision (cap each section with a “and 240 more” disclosure? paginate? collapse sections past N?) before it needs code. Until that decision exists, a big project gets a slow page.
2.4 Every page ships 23KB of uncached CSS — small, needs the server
The stylesheet is inlined into every response: 23,290 bytes, 6,447 gzipped,
re-sent on every page view and every 60-second auto-refresh. Inlining is right
— constraint 2 in dashboard.md — but nothing compresses the response, so the
bad SSH connection this view exists for pays 23KB per page.
Two independent fixes, neither in the view: gzip/brotli on the server (src/server/
mounts the app), or serve the stylesheet from one cacheable GET route with a
far-future Cache-Control and a content hash. The second breaks the “no
external request” property the CSP-free single file has today, so compression
is the one to do first. Left for the server owner — noted on relay-83.
2.5 The auto-refresh is a blunt instrument — small
/, /p/:slug and /p/:slug/standup carry <meta http-equiv="refresh" content="60">.
On a slow database that is a full re-render every minute per open tab, and the
home page recomputes board() for every project on every render, plus a
GROUP BY over the whole events table for the navigation on every request of
every page. Nobody has looked at what that costs on a large database, and there
is no caching layer of any kind.
It also silently discards a reader’s scroll position, and it will interrupt
someone mid-<details>. The inbox already opts out of it for exactly this
reason (“the one page with a half-typed sentence in it”); the reasoning applies
more widely than it is applied.
2.6 Commission’s vocabulary is unexplained — small, but a judgement call
A stranger reads: gate: tier1, unblocks 2, stale claims, routine events,
in review, blocked (derived), scoped to dana, supervised, human_only.
Every one of those is defined in docs/concepts/, and none of them is linked
from the page that uses it. site/README.md argues at length that this
vocabulary is the reference’s, not the buyer’s — the same argument applies to a
person opening the dashboard for the first time.
Cheap version: a title on the six worst offenders. Honest version: link each
section heading to its concept page. Neither is done.
2.7 No theme toggle — deferred, with the reason
The view follows prefers-color-scheme and cannot be overridden. A toggle
without client JS means a route that writes a cookie, and the read-only
boundary — four write routes, structurally enforced, perturbation-tested — is
worth more than a toggle. Deliberately deferred, not an oversight, and
recorded here so nobody re-litigates it by accident.
2.8 Undesigned states that are now handled
Enumerated so the list is a list rather than a shrug. Each was reproduced:
| state | now |
|---|---|
| no projects at all | designed empty state naming commission init |
| a project with no tasks | designed empty state naming commission apply |
| an empty inbox | designed as the goal (relay-82), with the in-flight work under it |
| an inbox with one item | captured and checked; no layout that needs a second item |
| nothing moving on the standup | designed empty state, pointing at ?days= and the board |
| no activity at all | “nothing has happened here yet” |
| only routine activity | pre-existing disclosure, kept |
| a very long title | wraps; rows reflow rather than clip, at 390px and 1440px |
| a failed sync / a divergence | pre-existing; edged in --c-refusal, links the resolving command |
| a stale claim | pre-existing board section, keeps its status chip |
| an unknown project or task | 404 through the same layout, with a way back |
| a refusal from a judgement route | comes back as a prompt on the page, not an error page |
?days= / ?limit= / ?actor= nonsense | ignored rather than fatal (?days=abc was a 500) |
2.9 Undesigned states still outstanding
| state | why it is deferred |
|---|---|
| hundreds of tasks on one board | §2.3 — needs a decision before it needs code |
| hundreds of projects in the navigation | overflow menu is a flat <details> list with no search; fine at ten, unusable at two hundred |
| a database that is locked or slow | no timeout, no degraded render, no message — the page just takes as long as it takes |
| a project whose vision is 5,000 words | the disclosure holds it, but nothing caps it |
| an actor name long enough to break a chip | chips white-space:nowrap; a 60-character actor name will push a row wide |
| two people acting on the same item at once | second one gets core’s refusal, which is correct, but the page does not say “somebody else just did this” |
3. Accessibility pass
Done by hand against the rendered markup, plus computed contrast for every pair this redesign introduced.
Contrast — measured, WCAG 2.1, against the surface each colour actually sits on. Every text pair clears AA in both themes; the two tightest are named.
| pair | light | dark |
|---|---|---|
chip in_progress (accent on 12% tint) | 5.49:1 | 7.12:1 |
chip in_review (violet on 14% tint) | 4.61:1 | 5.44:1 |
chip done (ok on 12% tint) | 5.45:1 | 7.10:1 |
chip cancelled (refusal on 12% tint) | 5.40:1 | 5.61:1 |
chip gate (warn on 14% tint) | 4.86:1 | 7.27:1 |
unblocks N (warn on surface) | 5.93:1 | 9.42:1 |
| the judgement button (on-brand on violet) | 5.70:1 | 7.15:1 |
| active project in the nav (ink on brand-soft) | 15.32:1 | 13.80:1 |
| the item’s context line (muted on surface) | 6.57:1 | 7.32:1 |
| the rank (faint on surface) | 5.17:1 | 4.75:1 |
| section labels and footer (faint on canvas) | 4.99:1 | 5.04:1 |
The in_review chip at 4.61:1 in light is the tightest pair on the surface.
It passes, and it is 12px, which is where AA is least forgiving. If the violet
is ever retuned, re-measure this one first.
Keyboard and structure — what holds:
- Skip link to
#main,lang="en",header/nav/main/footerlandmarks, the project nav labelled,aria-current="page"on the active project. - One
<h1>per page and no skipped levels (fixed this pass). - A visible
:focus-visiblering, 2px, never removed, on every interactive element including the disclosures. - Everything interactive is a link, a
<button>, a<summary>or an<input>— there is nodivwith a click handler anywhere, because there is no JavaScript at all. Focus order is source order. - Status dots are
aria-hidden; the word always travels with the colour, and where a row’s chip is suppressed the section heading is the word. - Every input carries an
aria-label: the judgement fields name the task and the question, the unlock field names what it wants. No control is labelled by its placeholder alone. - The three questions each inbox item answers are still announced (
.vhlabels) even though they are no longer drawn.
Not good enough yet:
| gap | size |
|---|---|
Several metadata elements carry information only in a title attribute (the exact timestamp behind “3d”, the touches overlap warning behind a ⚠, the binding’s observed-at). title is not reliably announced and is unreachable by touch | medium |
The ⚠ glyph on an overlapping ready task has no accessible name at all | small |
| The inbox context line is 12px. It clears contrast, but 12px as the resting size for a sentence is small, and it is the third thing a reader with low vision will hit | medium — it is a density trade-off that deserves a second opinion |
| Nothing has been tested with an actual screen reader, and no automated a11y suite runs in CI. Everything above is a hand audit | medium |
| Zoom to 200% and 400% has not been checked | small |
| The 60s auto-refresh will interrupt a screen-reader user mid-page, with no way to turn it off | medium — see §2.5 |
4. On a phone
Captured at 390×844 in both themes, every page, with no horizontal overflow —
the capture rig fails the run if scrollWidth > clientWidth, so this is checked
rather than eyeballed.
What is genuinely fine: rows reflow instead of clipping, the inbox item stacks in a sensible order, the judgement form is reachable and usable, the nav wraps.
What is not designed for a phone so much as tolerated: the project nav is the same row of pills and will wrap to three lines with six projects; the activity feed’s three-column row becomes very narrow; and nothing anywhere is a touch target of the recommended 44px — the tab strip, the disclosure triangles and the nav pills are all around 28–32px high. That last one is a real finding and it is the density decision working against touch.
5. Recommendation: the beta label stays on
The design lift was real and it is done: one design language with a test that fails on drift, an operational system that is its own thing rather than a squashed landing page, a measurably denser board, and an inbox a person can triage. If the question were “does this look like a product?”, the answer is now yes.
But the beta label is not a claim about how something looks. It is a promise about what a stranger can rely on, and three things below the line are load bearing:
- A cancelled task disappears (§2.1). Somebody will lose a task and not believe the tool again. That is a correctness problem wearing a design problem’s clothes.
- Nothing here has been tried on a large project (§2.3, §2.5). 95KB and no pagination at 300 tasks is not a scaling curve anybody has looked at, and “we never tested it past our own repository” is exactly what a beta label is for.
- You cannot find anything (§2.2). A dashboard without search is a dashboard you can only read top to bottom.
None of the three is hard. All three are unfinished, and a label that comes off while they are unfinished costs more than a label that stays on another month — because the first person who loses a cancelled task will not file a bug, they will stop opening the page.
Suggested exit criteria, so the next person is not re-arguing this:
- cancelled work is visible somewhere on the board, or the board says in words that it omits it;
- a project of 500 tasks renders in under 100KB and somebody has looked at the page;
- a task is findable by id or by title from the web view;
- responses are compressed;
- one pass with a real screen reader, and the
title-only metadata given a visible or announced home.
Everything else on this page is worth doing and none of it should hold the label.
6. Reproducing this audit
bun run scripts/shoot-dashboard.ts .screenshots/dashboard
bun test tests/design.test.ts tests/web-inbox.test.tsThe capture rig reports page height and horizontal overflow for every page ×
viewport × theme and exits non-zero if anything scrolls sideways. The numbers in
§2.3 and §2.4 came from rendering a 302-task project through webRoutes in
process and measuring the response; the contrast table came from computing WCAG
2.1 relative luminance for each pair against the surface it sits on, the same
method site/README.md uses.
This page is docs/design/dashboard-beta.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.