Skip to content
commission

The dashboard's design system

commission serve renders the observer view: the pages a human opens to see what their agents are doing and what needs them. This page says what its design is, what it borrows, and — more usefully — what it deliberately does not.

Everything visual here is generated from site/src/styles/tokens.css into src/server/tokens.ts by bun run generate:tokens, and tests/design.test.ts fails if the two have drifted. There is no second place to change a colour.

Three systems, not two

site/README.md opens by splitting the marketing site in two: / uses a product system, /technical and /design use a documentary one. The dashboard is a third case, and the split is the same kind of argument.

surfacesystemread
/ (the landing page)productonce, by someone deciding
/technical, /design, /blogdocumentaryonce, carefully, in order
commission serveoperationalmany times a day, by owner

A landing page has to stop somebody scrolling. A reference page has to be readable end to end. An operational page has to be instantly legible to somebody who has already seen it two hundred times — which is a different job from either, and it is the one the dashboard is designed for.

The consequences follow from that sentence — and the first version of this page drew them too far. It argued for no motion, no display type, no gradient, no stage, and a page title two steps down the scale. Each was defensible alone; together they produced a surface its owner described as “a terminal w/ css” and would not open by choice. A tool nobody wants to look at is not more usable for being quiet — it is a tool people avoid, and the work it reports goes unwatched.

So the rules below are the second draft. They keep the part that was right — this page is read by somebody who has seen it two hundred times, and it must not waste their attention — and drop the part that mistook austerity for restraint.

  • The front door is a judgement surface; the observatory is one click behind it. §6 of docs/positioning.md is the ruling: next serves actors of kind agent; inbox serves actors of kind human.” /p/:slug rendered next’s worldview — the ready frontier, the parallel batches, the status buckets — and that is why three passes over this surface came out a tracker. It now answers what needs you, what is proven, and what is running, in that order; the machinery lives at /p/:slug/graph. Control is still minute and mundane and the real control surface is still the CLI, but the question the page is organised around is the human’s, not the dispatcher’s.
  • Motion reports a change, never decorates. Four things move: the page arrives (one shot, staggered), a meter fills from empty to its value, work somebody is holding right now breathes, and a control answers within 120ms. Nothing else. tests/design.test.ts asserts every keyframe animates only transform or opacity, that exactly one animation loops, and that prefers-reduced-motion turns all of it off — including the loop.
  • The strip carries the most variable fact, not the page’s name. One block at the top, on the site’s stage, with the display face used once. What sits in it is situation()’s sentence — “Six finished tasks to accept, and three other things needing you. Nothing has moved in five days.” — and the project’s name drops to the eyebrow. It was the other way round for three passes: 42px of a constant that is identical on every visit forever, above everything that actually changes at 12px. That is what “everything is weighted the same” and “a lot of parsing to find what you’re looking at” were describing.
  • Whitespace is not automatically an upgrade. A reader here is scanning for the exception, and every row pushed below the fold is a row they have to scroll to find. Space separates kinds of thing and is reclaimed everywhere else. The strip is the one place allowed to be generous.
  • Colour is state, or it is the product. See below.
  • Prose is prose. A note keeps its paragraphs, is bounded by --measure wherever it is shown in full, and is quoted to its first sentence anywhere it is merely referenced. Agents write two thousand characters at a time; a feed that prints them whole is a wall with timestamps down one side.

What carries over from the landing page

All of it, now: the colour identity, both gradient stops, the aura, the stage, the type scale including one display step, the 4px spacing rhythm, the radii, the elevation, the entrance timing and its stagger. It is the same product and it should look like it.

What does not, and why

Named with reasons in CLASSIFICATION in scripts/generate-tokens.ts, which throws on a token it has not been told about — so a value added to the site cannot leak into an operational tool because nobody looked. The list is short now, and what is on it belongs to somebody who has not bought the product yet.

excludedwhy
--t-display-xl, --t-display-lg92px and 68px are the hero’s. The dashboard sets one display step, on the strip, once per page
--dur-loopa twelve-second animation that explains the product to a stranger
--space-section4.5rem between blocks is a scrolling page’s pace; the operational scale is --sp-*, on the same 4px step

The pages are Commission’s behaviors, not a tracker’s

The redesign above fixed how the surface LOOKED. It did not fix what it was: a board with status columns, a task page with stacked panels, a standup with four lists. That is Jira’s information architecture, and no stylesheet rescues it — its owner’s verdict was “I mean you see the pages they’re ugly… this isn’t a standard task management product, it’s its own thing with its own behaviors.”

So each page is now built from the behavior that makes this product different from a tracker:

behaviorwhere it shows
done is a predicate, not a column — criteria, evidence, approvals, children must all be truethe task page IS the gate resolving: what has to be true, what is, and the exact command for everything that is not
claims are leases — work is held, and the clock runs outevery piece of executing work carries its time remaining, and “lapsed” is a state with a colour, not a silence
the frontier is parallel — N agents can start now without collidingReady is CAPACITY: core’s collision-free batches, rendered as the columns of work they are — on /graph, because dispatch is the agent’s question
blocked is derived — from the graph, never seton /graph, a stuck item names what it waits on and whether anybody is on that, which is what decides whether it moves. There are three ways to be un-workable, not two: a parent whose dependencies are satisfied and whose children are not is neither ready nor blocked, and it used to render in no section at all — which hid six of GulfWeather’s EPICs from their own board
work happens while you are awaythe activity is a stream, notes are quoted to their opening sentence, and “since you last looked” is a watermark rather than a timestamp
humans are asked only for judgementthe judgement queue IS the project page. Each item is grouped by kind, the reason a kind needs a person is stated once at its heading, and each card carries what differs about its own item
completion is verified, not assertedProven: the tally first — “121 closed here in all · 54 with a test attached · 2 forced”, a sentence no tracker can print — then closed work with the evidence rows it closed on, and a forced close marked as one

The one rule that keeps this honest: the page may not compute a fact core already derives. doneGate() in src/core/transitions.ts returns the predicate as data, and commission done’s refusal is composed from the same function — so the task page and the CLI cannot disagree about what is missing. When a page needs a fact that only exists inside a refusal, the fix is to make core return it, not to reimplement it in a view.

Colour means a state in the task graph

The site’s rule, and it binds harder here because a person is triaging. Commission has five statuses; the site names four semantic colours. The mapping, and the one extension:

colourmeans
--c-accentlinks, in_progress
--c-okdone, criteria met, evidence present
--c-warnblocked (derived), a gate not yet satisfied
--c-refusalcancelled, a refusal, a divergence
--c-brand-violeta person is neededin_review, and the two judgement controls
--c-brandthe wordmark, and nothing else

Violet still means a person is needed: in_review, the person-holder chip, and the judgement controls. What changed is that the brand is no longer confined to the wordmark. The indigo→violet spine paints four things and only four: a progress fill, the underline on the tab you are on (there are two: Project and Graph), the top edge of a card or a strip, and the one control that writes. Each is the product identifying itself or reporting progress toward something — none of them is a state in the task graph, so none competes with the semantic four.

tests/web-inbox.test.ts enforces the half that can be checked mechanically: every rule painting with --done, --warn, --cancel, --review or --prog must sit in a selector that names a state. A state colour on ordinary chrome fails the build.

Colour is never the only signal. A dot always travels with its word. Where a row’s status chip is suppressed, it is because the section heading above it is that word for every row underneath — IN REVIEW over three rows beats three chips saying “in review”, and the dot is aria-hidden so assistive technology gets the heading instead.

Constraints that are features

These are not incidental and must survive any future work here.

  1. Server-rendered. The client JavaScript is a budget, not a ban. Two inline scripts, no request, no framework, nothing that renders: the theme (before first paint, so there is no flash) and the entrance (an IntersectionObserver that only ever adds a class). Every row is still server-rendered HTML, so JavaScript failing costs an animation and a preference, never content. tests/web-inbox.test.ts asserts the count, the size, that nothing is fetched, and that no handler attribute exists.
  2. The stylesheet is inline and self-contained. No stylesheet link, no font file, no CDN — asserted by tests/design.test.ts. The fonts are named (IBM Plex Sans / Mono, falling back to the system UI face), not shipped: a single-file binary serving three webfonts would be a heavier page over exactly the connection this view exists for. The type scale is what carries over; the face is a bonus where it is installed.
  3. Every route is a GET except the write mounts. The judgement routes under /p/:slug/inbox are the only mutations, they live in their own sub-app, and the route table is asserted.
  4. commission serve never reads site/. The tokens are compiled in.

Themes

Two designed themes. Light is the default, the system setting applies until somebody chooses on this surface, and their choice then wins on every visit — stored in localStorage, applied by the head script before first paint.

That is why the theme is not a media query alone: somebody whose system is dark and who wants this page light cannot say so with one, and a class applied after paint is a white flash in a dark room.

The strip is the exception. It is painted on the stage in both themes, like the marketing hero, because it is the product’s face — and a face that turns off-white in the daytime has no atmosphere to give.

Verifying a change

bun run generate:tokens          # after any change to the site's tokens
bun test tests/design.test.ts    # drift, colour literals, spectacle, scale
bun run scripts/shoot-dashboard.ts .screenshots/dashboard

# and then, before believing any of it:
cp ~/.commission/commission.db /tmp/shot.db
bun run scripts/shoot-dashboard.ts .screenshots/real --db /tmp/shot.db --project <slug>

# and the states a real project is not in today:
bun run scripts/fixtures.ts --list
bun run scripts/shoot-dashboard.ts .screenshots/empty --fixture empty

The first form seeds a scratch database with real commands, serves it, and photographs every page at 390px and 1440px in both themes, reporting page height and failing on horizontal overflow. Read the heights: on this surface a page that got taller for the same content is a regression.

The second form is the one that matters, and its absence is why three redesigns were shipped and rejected. That fixture is nine tasks, none of them closed, one parent, two dependencies. It cannot show a forty-row frontier, a project with twenty-eight parents, what a hundred and twenty closed tasks look like, or a queue that has gone cold — so every design decision recorded in this document was made looking at a project shaped like nothing anybody has, and the defects that survived were exactly the ones the fixture could not contain. Pass a COPY: the rig runs no command against a real database, but a browser driver crashing mid-capture should never be able to cost somebody their task graph.

The third form covers what a real database happens not to show on the day it was copied — an empty project, a month of silence, a lapsed lease, a dead Jira, and the rest of §43’s eleven. The procedure, and the rule that no decision here is signed off against the demo fixture alone, live in fixtures.md.

Who holds this (added after the first pass)

The first pass optimised for density and won it — the board went from 1562px to 1182px on desktop — but it removed signal along with decoration. The owner’s verdict was that it looked like a directory listing, and looking at the captures rather than the rationale, that was fair. The specific failure: the board never said who was working on anything. The claimant appeared exactly once per page, inside the activity feed, while the CLI printed it on every in-progress line.

On a board shared by a team and a fleet of agents that is the first question anybody asks, and the one thing that stops two of them starting the same work.

The rules

meaning
rounded pill, ● marka person holds this
square tag, ■ markan agent holds this
amber holderthe claim’s lease has expired — nobody is really on it
no holderunclaimed, and claimable

Shape carries the person/agent distinction as well as colour, so it survives greyscale, a projector, and colour blindness. Hue alone would have made the most important distinction on the page the least reliable one.

Section headings now carry weight rather than being uniform: live for work in flight, stuck for work that needs somebody. Everything else stays quiet. A board where every section looks identical gives the eye nowhere to land.

The activity feed is folded. It was half the page and is the least decision-relevant thing on it — a person opening this page wants to know what is in flight, what is stuck, and who has it, not a transcript. It stays one click away because when you do want it, you want all of it.

Density was not given back: desktop board 911px against the first pass’s 1182px, measured by the same capture script, on a scenario that now contains more state (a human-held task was added precisely so the person/agent distinction is exercised rather than asserted).

Acting, not just reading

The board was read-only for its whole life. On a board a team shares, that means everyone looking at it is about to duplicate something — you can see that an agent has the refund path, and the only way to say “leave it, I’m doing that” is to open a terminal and learn a CLI built for agents.

The task page now carries the five things a person actually does: claim, release, note, declare a block, set priority.

Rules

  • Identity before affordance. The chips (status, priority, gate) come first and the controls sit under them. Placed above, the actions read as the most important thing on the page, and what a task is has to precede what you can do to it.
  • Offer only what will succeed. “Claim” is not shown on work somebody else holds, and nothing is offered on a closed task. A surface that shows every button and refuses half of them teaches people to ignore the buttons. Core still refuses independently — hiding is courtesy, not enforcement.
  • Quiet. These are bordered, not filled; only “Claim this” carries the accent. Most visits to this page are still reads, and a row of loud buttons would compete with the work for attention.
  • Sentence-length fields get room. The note and block-reason inputs are flex: 1 1 22rem, so they wrap to their own line rather than being crushed into a button row — the first cut truncated the placeholder to “a breadcrumb for whoev”, which was visible in the capture and in nothing else.

Two write surfaces, deliberately separate

/p/:slug/inbox/* is judgement: a person accepting somebody else’s work. /p/:slug/act/* is work: a person doing some. They are different sub-apps because the route-table test says a non-GET route may only live where a human exercises judgement, and mounting claim beside approve would have made that sentence quietly untrue while the test still passed.

Matching the marketing site (the second redesign)

The owner’s verdict on the first two passes: “one of the ugliest dashboards I’ve seen in about 10 years.” Looking at the six screenshots rather than at the rationale, that was fair. Three decisions caused nearly all of it, and two of them were mine, made deliberately, for reasons that were individually sound.

1. The mono micro-label was every heading

The site’s stylesheet says of that label, in its own comment: “It is never a heading.” Here it was h2 — uppercase, letterspaced, faint, mono — on every section of every page. That single choice is most of why the whole thing read as a terminal dump rather than a product.

Sections are headings now: sans, 600, ink, sentence case, with the count as a small pill that takes the section’s colour.

2. Panels were flat grey rectangles

The old rule argued that “a shadow under every list on the page is noise; elevation should mean something.” The restraint was right and the result was wrong: combined with a 4px radius and the recessed --panel fill, every container on every page became the same flat grey box on a warm canvas.

A panel is a surface now, the way the site means one: the lit --surface fill, an 8px radius, and --shadow-1. Sections that mean something — in flight, in review, blocked — also carry the site’s two-pixel gradient edge, beside which the site’s comment reads: “Cheap, and it is what stops a white rectangle reading as a document.” It is applied only to those, because an edge on every panel is a stripe pattern rather than a signal.

Project cards get the full treatment — brand edge, and a lift on hover — because they are the one thing on this surface that really is a separate object.

3. Prose ran to 140 characters

The worst of it, and the most embarrassing: --measure: 68ch has been in the tokens since the first pass and was used by no prose on this surface. The activity feed’s text column was flex: 1 with nothing capping it, so a note written as a paragraph — and Commission’s notes are written as paragraphs, by design — rendered as one enormous line across the full width of the page.

Now: capped at --measure, clamped to three lines with a fade so the truncation is visible rather than a sentence that merely stops. There is no expand control because this surface ships no client JavaScript; the task id on the row is the link to the whole thing, which is where a note that long belongs.

The same bug had a second form. The standup script was measure-capped on the TEXT while its panel stayed full-bleed, so a 1440px viewport showed a readable paragraph floating in half a metre of empty white — which reads as a rendering fault, not as typography. The card is the width of what it holds.

What did not change

Density. The board is the same height it was after the first pass. None of this was bought with whitespace — it was bought by using the design system that was already generated, shared, and sitting unused in the same file.

Two follow-ups

The action row is a row of verbs

The first cut put every field inline: three sentence-length text inputs and a number spinner on one line, all different widths, with no visible relationship between a field and the button that submitted it. It read as a form somebody had left open rather than as a set of things you can do.

Now the default state is verbs only. Actions that need nothing typed (Claim, Release, I’m unblocked) are plain buttons. Actions that need something typed hide their field behind a <details> disclosure that opens attached to its own label — the tab joins the panel, so the field visibly belongs to the verb above it. No client JavaScript, which this surface does not have.

Worth noting how that was checked: the closed state is what every screenshot shows, so the open state was CSS nobody had ever looked at. Forcing one open for a capture found that it works — and would have found it if it did not.

A project card answers “is anything happening”

The card was a title, a meter and three counts, which on a quiet project is a title, an empty bar and three zeros. The question somebody opens this page to answer is not how many — the board is one click away for that — it is is anything happening, and does any of it want me.

So the card now carries, in order: how complete it is, who is on it right now (the same person/agent holders the board uses, or “nothing in flight”), and then only the things that need somebody — awaiting review, waiting on you, stale claims — each linking to the page that resolves it, with “ready” kept quiet because it needs nobody. The navigation links sit below a rule, because they are navigation rather than content.

Both rows hold their height when empty, so a grid of cards does not ripple as projects get busy.

What changed since you last looked

The one criterion of the first redesign that was not met, and it was blocked for a real reason rather than a scheduling one: the server had a single identity, so “since you last looked” had no subject. Sessions gave it one.

It is a property of the person

Every cheap version of this feature is keyed on a browser — a cookie, a localStorage timestamp, a since= parameter — and every one of them silently means “since this browser last looked”. That is wrong the moment somebody opens the board on their phone, and wrong in the other direction when two people share a screen. Being caught up is a fact about a person, so it is stored next to the person: one watermark per principal per project.

The hard part is not losing a change

Knowing what changed is easy. The obvious implementation — record now() on every page view — throws work away constantly and silently: you glance at the board, refresh, and the twelve things that changed overnight are gone unread with no way to get them back.

Two decisions prevent it:

  1. Being caught up is explicit. Nothing advances the watermark except a person pressing the button. A page view is not evidence that anybody read the page.
  2. The watermark is the newest event the page actually rendered, not the moment of the click. Anything that lands between the render and the click stays unseen, because it was never on the page they were looking at.

A first visit records the watermark and shows nothing, because “everything that ever happened” is not a useful answer to “what changed”.

On the page

A strip above the board: the true count (not the page’s capped one — somebody away for a fortnight is told the real number), how many rows below are marked, and the one control that clears it. Changed rows carry a left edge in the in-progress blue and a small new after the id.

The edge is a box-shadow, not a background: the row already uses background for hover, and two meanings on one property is how a signal stops being one.

This page is docs/design/dashboard.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.