The declared design-system artifact for Cockpit (src/cockpit/web/**). Closes the gap
docs/brand-system.md names explicitly: that doc covers color, typography, and motion for
any Minsky surface, but "not components, spacing, or interaction states" — and cockpit
shipped with no type scale at all (stock Tailwind sizes only) while the marketing site has
shipped a concrete one since mt#1934. This doc is that missing layer for cockpit.
Parent: mt#2914 (design-system umbrella). This doc is mt#2915's deliverable — token definitions only. Applying the scale across widgets is mt#2917's register-unification pass; structural lint enforcement is a separate umbrella child.
docs/brand-system.md is the color / typography / motion source of truth
for every Minsky surface (site, cockpit, docs, future channels). This doc does not restate
those tokens — it adds the layer brand-system.md explicitly defers:
| Layer | Owned by |
|---|---|
| Color palette (hex + OKLCH), WCAG floors, font families, motion budget | brand-system.md §1–3 |
| Vocabulary, bridge-as-affect, anti-patterns | brand-system.md §4–6, .claude/skills/minsky-brand/SKILL.md |
| Cockpit token mapping (which CSS var carries which brand token) | brand-system.md §7 |
| Cockpit type scale | This doc, §2 |
| Spacing scale decision | This doc, §3 |
| Component inventory + interaction states | This doc, §4 |
| Status/severity color semantics + red-scarcity boundary | This doc, §5 |
| Icon decision | This doc, §6 |
Minsky-domain entity conventions (task/session/changeset/PR/ask/agent display, mission-control
density, command-palette UX, drill-down nav) live in .claude/skills/cockpit-design/SKILL.md —
that skill is the IA/interaction layer; this doc is the token/component layer underneath it.
Cockpit shipped with zero named type tokens — every heading and label reaches for stock
Tailwind (text-xs … text-2xl). An audit of current usage across src/cockpit/web/
(grep -rhoE "text-(xs|sm|base|lg|xl|2xl)\b") found:
| Stock class | Count | Role it's actually playing |
|---|---|---|
text-xs (12px) |
357 | captions, metadata, table cells, badges |
text-sm (14px) |
174 | primary readable content |
text-base (16px) |
8 | sub-section headings |
text-lg (18px) |
6 | section headings |
text-2xl (24px) |
1 | page title (CardTitle) |
Cockpit is a mission-control dashboard, not editorial copy (src/cockpit/CLAUDE.md "Density as
a feature") — its real body-text size is ~14px, not the marketing site's 16px. Rather than
import the site's literal pixel values, this scale names the roles cockpit has already
organically converged on, mirroring the site's technique (named tokens as
line-height/letter-spacing pairs, services/site/src/styles/global.css) at cockpit's
measured density. display is new — reserved for a big-stat callout (a Grafana/Sentry-style
single-number panel) cockpit doesn't use yet but the mission-control aesthetic anchor implies.
| Token | Size | Line-height | Letter-spacing | Maps onto (today) | Use |
|---|---|---|---|---|---|
display |
2rem (32px) | 1.1 | -0.02em | (new, unused) | Reserved: single big-number stat panel. |
h1 |
1.5rem (24px) | 1.2 | -0.01em | text-2xl |
Page titles (matches CardTitle today). |
h2 |
1.125rem (18px) | 1.3 | — | text-lg |
Section headings. |
h3 |
1rem (16px) | 1.4 | — | text-base |
Sub-section headings. |
body |
0.875rem (14px) | 1.5 | — | text-sm |
Primary readable content. |
small |
0.75rem (12px) | 1.4 | — | text-xs |
Captions, metadata, table cells. |
eyebrow |
0.6875rem (11px) | 1.3 | +6% | (new) | Caps structural labels, per brand-system §1's 11–13px eyebrow row. |
code |
0.8125rem (13px) | 1.5 | — | (new) | Inline + block code. |
Shipped as CSS custom properties in src/cockpit/web/index.css (:root, theme-independent —
typography doesn't change between light/dark) and exposed as Tailwind fontSize theme entries
in tailwind.config.ts, giving text-display / text-h1 / text-h2 / text-h3 / text-body
/ text-small / text-eyebrow / text-code utility classes alongside the stock scale
(additive, not a replacement — nothing currently using text-xs/text-sm/etc. breaks).
Explicitly out of scope here: migrating existing widgets onto these classes. That's mt#2917's register-unification pass — this task only ships the token definitions so that pass has something to adopt.
Decision: bless Tailwind's stock 4px-based spacing scale. No cockpit-specific spacing tokens.
The cockpit-design skill's density model already codifies the only two spacing conventions cockpit actually needs, built entirely from the stock scale:
- Compact row:
py-1.5 text-sm - Comfortable row:
py-3 text-base
Rationale against inventing a custom scale: (1) no widget currently exhibits a spacing need the
4px scale can't express — the density audit for §2 above turned up no arbitrary-value spacing
(p-[13px] etc.) hits; (2) the plant board's instrument layout (tanks, gauges, valve nodes) is
pixel-precise SVG/react-flow node geometry, not a Tailwind-spacing-utility consumer, so it sits
outside this decision's scope entirely; (3) a custom spacing scale is real ongoing maintenance
cost (a second scale to keep in sync, migration surface) for zero identified gap today. If a
future widget needs a spacing value the 4px scale can't hit cleanly, revisit as a scoped
addition — don't pre-build one speculatively.
Existing src/cockpit/web/components/ui/*.tsx primitives (shadcn/ui + Radix), what interaction
states they already carry, and where a "component" cockpit visibly needs doesn't exist yet.
| Component | Exists? | Hover | Focus | Active / selected | Disabled | Loading |
|---|---|---|---|---|---|---|
| Button | ui/button.tsx |
hover:bg-*/90 per variant |
focus-visible:ring-2 ring-ring ring-offset-2 |
n/a | disabled:opacity-50 disabled:pointer-events-none |
Gap — no loading variant. Convention: disable the button and prepend a lucide-react Loader2 with animate-spin. |
| Card | ui/card.tsx |
none (static container) | n/a | n/a | n/a | n/a — wrap contents in the shared LoadingState/ErrorState (components/) instead of a card-level loading prop. |
| Badge / chip | No shared component. Every status pill is hand-rolled per call site (status-colors.ts's statusStyle() returns inline style={} triples; KindBadge/NeedsMeBadge in Agents.tsx are bespoke local components). |
— | — | — | — | — |
| Checkbox | ui/checkbox.tsx (Radix, mt#3348) |
inherits trigger surface; no separate hover fill | focus-visible:ring-2 ring-ring ring-offset-2 (matches Button/Select) |
data-[state=checked]:bg-primary + Check indicator; data-[state=indeterminate] renders a Minus |
disabled:opacity-50 disabled:cursor-not-allowed |
n/a — reflects local UI state, not a fetch. Separate non-interactive variant: markdown task-list checkboxes (- [ ] x) are rendered by Prose.tsx's input override as a styled role="checkbox" span with aria-disabled, deliberately NOT this primitive — markdown task state is not editable from that surface. |
Command (Cmd+K) |
ui/command.tsx (cmdk) |
n/a — keyboard-driven | aria-selected:bg-accent aria-selected:text-accent-foreground |
cmdk conflates selected+focused (same class) | data-[disabled]:opacity-50 data-[disabled]:pointer-events-none |
n/a — palette sources resolve before the palette opens. |
| Dialog | ui/dialog.tsx (Radix) |
n/a | close button: hover:opacity-100 focus:ring-2 ring-ring ring-offset-2 |
data-[state=open/closed] drives enter/exit animation |
close button: disabled:pointer-events-none |
Gap — no documented in-dialog loading convention. Recommend: swap DialogContent's body for <LoadingState/> while the mutation/query is pending. |
| Select | ui/select.tsx (Radix, mt#3347) |
trigger: hover:bg-muted/70 |
trigger: focus-visible:ring-2 ring-ring ring-offset-2 (matches Button/Tabs); item: focus:bg-accent focus:text-accent-foreground |
item: data-[state=checked] renders a Check indicator; trigger shows the selected label via SelectValue |
trigger: disabled:opacity-50 disabled:cursor-not-allowed; item: data-[disabled]:opacity-50 data-[disabled]:pointer-events-none |
n/a — options are resolved before the panel opens. Disable the trigger while a dependent mutation is pending (see TaskDetail.tsx's model picker). Naming: where a visible label span sits beside the trigger (control bars in Agents/TaskList/Workstreams), tie it via aria-labelledby + useId rather than duplicating the text in aria-label. |
| Tabs | ui/tabs.tsx (Radix) |
n/a | focus-visible:ring-2 ring-ring ring-offset-2 |
data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm |
disabled:opacity-50 disabled:pointer-events-none |
n/a |
| Disclosure control | DisclosureChevron + HOVER_ROW + BURST_CHILDREN (components/ConversationElementRenderers.tsx, mt#4348). Covers the conversation view's expanders: tool row, injected span, thinking <summary>, command, action-burst fold. |
hover:bg-muted/50 (HOVER_ROW, mt#4251) on the control's actual hit-area. For the four row-shaped controls that is the full-width row rather than the inner toggle — a toggle is flex-1 and stops short of trailing siblings, so highlighting it lights part of a row meant to read as one object. CommandInvocation is the deliberate exception: its row is CONTENT and only its trailing chevron toggles anything, so the treatment covers the chevron's own box (rounded px-1). Widening what is clickable there would be a behaviour change, not an affordance fix. |
FOCUS_RING — focus-visible:ring-2 ring-ring ring-inset |
Chevron is LEADING, never ml-auto. ▸ collapsed / ▾ expanded, text-muted-foreground/60. A <summary> host must suppress the user agent's own marker (list-none + [&::-webkit-details-marker]:hidden) — otherwise the browser draws a second, unmatched triangle. |
n/a | n/a — expansion is local UI state, not a fetch. Nested groups: an expanded group wraps its children in BURST_CHILDREN (border-l border-border/40 pl-3), one step lighter than RunView's border-l-2 + accent, so a nested rail cannot compete with the run rail containing it. The leading chevron is what makes that indent read as depth: a trailing marker leaves every level's glyph in the same column and no indent recovers the hierarchy. |
| Table rows | No shared <Table> component. Every list widget hand-rolls its row markup. |
Recommended convention: hover:bg-muted/50 |
n/a — rows aren't focusable by default | n/a | n/a | Loading state goes above the row list via LoadingState, not per-row. |
| Pane divider | components/PaneDivider.tsx (mt#3701) — draggable vertical separator; first host is the film's ribbon/stage split |
seam + grip go bg-border → bg-ring |
group-focus-visible:bg-ring; role="separator" with live aria-valuenow/min/max, arrow keys step, Home resets |
data-[dragging=true] holds the bg-ring state for the whole drag |
n/a | n/a — reflects a layout preference, not a fetch |
| Scrollbar | index.css @layer base (mt#3701, rescoped mt#4355) — color-scheme makes the PLATFORM's bar match the theme everywhere; .scrollbar-readout opts a long scroller into the token-styled bar; .scrollbar-none suppresses chrome entirely |
thumb --border → --muted-foreground |
n/a — not focusable | thumb --muted-foreground while dragging |
n/a | n/a |
| Status dot | lib/liveness-colors.ts (livenessDotClass) + animate-status-dot keyframe (index.css) |
n/a | n/a | n/a — reflects a data value, not a UI toggle | n/a | The pulse animation IS the "live" signal (per brand-system §3); a static (non-pulsing) dot means "not live," not "loading." |
Two real gaps this inventory surfaces (not fixed here — token-definition scope only):
- No shared Badge/chip primitive. Four widgets independently render status pills through
status-colors.ts's color triples via inlinestyle={}; there is no<Badge>component wrapping that into a consistent DOM/className shape. A future task should introduce one (shadcn's standardBadgecomponent, parameterized by the sameTaskStatusunionstatus-colors.tsalready exports) so hover/focus states can be added in one place instead of per-callsite. - No shared
<Table>primitive. Row markup, hover state, and density (compact/comfortable) are each hand-rolled per widget. A shared table primitive would let the compact/comfortable toggle (§3) live in one component instead of being re-implemented per list.
Scrollbar decision (mt#3701, rescoped mt#4355). Two mechanisms, and keeping them apart is the whole of this section — mt#3701 shipped only the second and the first was what actually mattered.
1. color-scheme decides what the PLATFORM draws. :root declares light, .dark declares
dark. This is what tells the engine which scheme the document is painted in, so every control
the platform renders itself — scrollbars, <select> popups, date pickers, form controls,
spellcheck underlines, the canvas before first paint — comes back matching. <html class="dark">
drives Tailwind's dark: variant and says nothing to the engine, which is why its absence was
invisible for as long as it was: the cockpit computed color-scheme: normal, so the platform
assumed a light page. In Chromium that was masked by mechanism 2. In the tray's WebKit window,
which honors neither of mechanism 2's property sets, it was not — the macOS light-appearance
scrollbar (track ~#f8f8f8, thumb ~#c1c1c1) rendered inside a near-black surface for as long
as mt#3701 was shipped. This mechanism reaches every engine; prefer it.
2. .scrollbar-readout REPLACES the platform's bar with ours. Thin, --border at rest,
--muted-foreground on hover, transparent track, no stepper buttons, always visible. Two
properties of it worth knowing before applying it:
- The thumb stays visible instead of fading in on hover. On a long virtualized surface a scrollbar is a position-and-extent readout, not only a drag target, and a readout that appears after you have reached for it is not one. Density (§Design vocabulary) argues the same way.
- Styling a scrollbar opts out of macOS overlay scrollbars, so the gutter takes layout width (~10px) on every container it is applied to. On a long scroller that is the right trade — it also means content stops reflowing when a scrollbar appears. Everywhere else it is a permanent bar on something that overflows by a line.
It is opt-IN, and that is the mt#4355 change. mt#3701 applied it through a bare * selector,
so all 31 scroll containers across 21 files carried a permanent gutter — every markdown code
block, every table wrapper, every small pane. The principal's report was "a ton of these ugly
scrollbars"; the reach was the defect, not the treatment. Apply .scrollbar-readout to a surface
long enough that the bar reads as a position indicator. Currently two: the app's main content
scroller (components/Layout.tsx) and the virtualized film ribbon
(components/session-film/SessionFilmRibbon.tsx). Everything else gets the platform's bar, which
mechanism 1 now renders dark.
.scrollbar-none (TabBar) still suppresses chrome entirely and is unaffected — it lives in the
utilities layer, which outranks the base layer .scrollbar-readout sits in. All four states are
asserted in scripts/verify-session-film-panes.ts: color-scheme is dark, .scrollbar-readout
resolves to the token, a BARE container resolves to auto, and .scrollbar-none suppresses.
The bare-container assertion is the one that keeps the global reach from creeping back.
A caveat on that verification. It drives Chromium, so it can confirm the color-scheme
declaration SHIPPED — not that the tray renders correctly. There is no headless WKWebView path
(mt#2226), and mt#3475 is precedent for how this class gets settled: the principal looks at the
tray window and reports. Do not read a green verify run as evidence about WebKit.
Root-scoping the base selector (.cockpit *) was considered and skipped when the rule was still
global: the cockpit bundle styles only its own document, and no external or third-party content is
hosted inside it (PR #2632 R1). Opt-in scoping makes the question moot.
warn.red / bg-warn-red / text-warn-red / bg-destructive is reserved for hard-alarm
states that call for action now:
BLOCKEDtask status- Hook-denial flash (a blocked/denied action)
- A hard service failure (a check gone red, a service down)
- An escalation ask requiring immediate principal action
It is not for informational classification or routine attention-debt signaling, even when that information feels "important":
- Priority badges (P0/P1/P2 labels) are classification, not an active alarm — use amber or neutral, never red, regardless of priority tier.
- Standing / open asks that are not yet past their window are attention debt, not an alarm — amber (or neutral, if not yet due) is correct; red is reserved for an ask that has actually escalated (deadline missed, bot intervention triggered).
- A "stale" liveness dot on an agent that's simply been idle a while is a different signal than a genuinely dead/errored session — reserve red liveness for real failure states; amber for stale/idle.
The distinction: red means "act now," amber means "attention debt exists." Conflating them (the mt#2914 audit found red at volume — a P2 badge on every ask row, a red dot on every stale agent row) erodes the signal red is supposed to carry. Fixing already-shipped over-use of red is out of this task's scope (that's widget-level work — mt#2917's register pass); this section records the boundary those fixes should converge on.
src/cockpit/CLAUDE.md already documents one deliberate exception to "semantic tokens only":
per-status healthy/warning indicators (Changesets, McpServerStatus, MemoriesHealth,
EmbeddingsPage, and similar widgets) use raw Tailwind palette classes —
bg-emerald-500/text-emerald-500 or bg-green-400/text-green-400 for healthy,
bg-amber-500/text-amber-500 or bg-amber-400/text-amber-400 for warning — rather than a
dedicated success/warning design token. This is blessed, not a violation, because the brand
palette (brand-system.md §2) deliberately carries no success token ("one accent, two warning
tiers, one pastel — that's the budget"), and this repetition is narrow and already consistent.
The boundary does not extend further:
- Error states always use the semantic
destructive/warn-redtoken — never a raw color liketext-red-400. SeeErrorState(src/cockpit/web/components/ErrorState.tsx). - Never raw hex or arbitrary values (
bg-[#10b981]) — only Tailwind's named palette, and only for the healthy/warning pair specifically. - Any widget needing a third distinct status beyond healthy/warning is a signal to revisit
as a real
success/warningtoken pair, not to extend the raw-palette exception further.
Lint enforcement scope (mt#2916): cockpit only, not the marketing site. The
custom/no-raw-colors-in-cockpit ESLint rule enforces this boundary structurally, but only for
src/cockpit/web/** — services/site/** (the marketing site) is Astro, runs through a separate
build/lint pipeline, and is explicitly out of this rule's scope. Extending equivalent enforcement
to the site is future work, not folded into mt#2916.
The mt#2909 visual-verification pass found --muted, --secondary, and --accent resolving to
the identical OKLCH triplet (0.200 0 0) in .dark — so the TODO/PLANNING/IN-PROGRESS
badges (which map to muted/secondary/accent respectively, per the cockpit-design skill's status
mapping) render as the same dark gray, and TaskGraph DAG node fills using the same tokens are
correspondingly low-contrast against the near-black canvas.
Fix (this task): differentiate the three tokens by lightness only — chroma and hue stay at
0 0 (still neutral gray; no new hue is introduced, staying inside brand-system §2's "one
accent, two warning tiers, neutrals" budget). The spacing principle: prominence tracks
distance from the base canvas lightness (--background: 0.078 0 0 in dark, 1 0 0 in light)
— the same principle the existing card(0.155) → popover(0.200) → border(0.270) ladder
already uses, just applied to the three collided tokens:
| Token | Dark (was → now) | Distance from --background (0.078) |
Light (was → now, placeholder) |
|---|---|---|---|
--muted (TODO/CLOSED) |
0.200 → 0.180 |
0.102 | 0.945 → 0.960 |
--secondary (PLANNING) |
0.200 → 0.240 |
0.162 | 0.945 → 0.900 |
--accent (IN-PROGRESS) |
0.200 → 0.300 |
0.222 | 0.945 → 0.830 |
This resolves badge distinguishability directly (three visibly distinct grays instead of one),
and improves — without fully solving — DAG node-fill contrast, since the same three tokens
back statusStyle()'s node fills. A full node-fill fix (a dedicated lighter/higher-chroma fill
octave for large-area instrument surfaces, distinct from the badge-oriented subtle-surface
tokens) is a further register-pass decision for mt#2917 if screenshots after this change still
read as low-contrast — not pre-built here speculatively.
Applied in src/cockpit/web/index.css; docs/brand-system.md §7 records the current values.
**APPROVED 2026-07-31 (ask#6551): Lucide is blessed for Cockpit product UI, and stays banned on
marketing surfaces (services/site/**).**
Evidence this is already the de facto choice, not a new pick: lucide-react is a declared
package.json dependency, already imported in 15 src/cockpit/web/** files, and already
declared as "iconLibrary": "lucide" in src/cockpit/web/components.json (the shadcn/ui
scaffold config) — blessing it formalizes existing practice rather than introducing a new one.
This does not contradict brand-system.md §6's "Inter + Roboto + Lucide icons trifecta"
anti-pattern. That anti-pattern targets the generic AI-SaaS marketing-site default — a landing
page reaching for the same three defaults everyone else's AI product reaches for, signaling "not
designed." Cockpit is not a marketing surface: it's an internal, single-operator mission-control
dashboard in the Data-Dense-Pro family (Sentry, Linear, Grafana, PostHog) — tools whose UI is
substantially Lucide-based today, where the icon set is a professional-tool convention, not an
AI-slop tell. The anti-pattern's boundary is the surface, not the icon set in isolation.
The veto window is closed. mt#2914's spec flagged this decision
for principal veto ("Icon decision (child 2) carries a recommendation for principal veto"). It
was routed as ask#6551 on 2026-07-30 and answered on 2026-07-31: "Bless Lucide for the cockpit
only." No code change was required — lucide-react was already the declared dependency and
components.json already set "iconLibrary": "lucide". Revisiting this is a new decision, not
an open question.
docs/brand-system.md §2 is the single source of truth for brand color. It already said so
— "OKLCH is canonical; hex is derived", "commit any refinement back to this table so all surfaces
stay in sync" — but until mt#3411 nothing read it, so the obligation was prose and prose does not
fail a build.
The same seven values live in three copies, in three syntaxes, because the surfaces consume them differently:
| Copy | Syntax | Why it differs |
|---|---|---|
src/cockpit/web/index.css |
bare components — --signal-cyan: 0.745 0.124 215; |
Tailwind/shadcn wrap them in oklch() at the use site |
services/site/src/styles/global.css |
--color-signal: oklch(0.745 0.124 215); |
consumed directly |
the same file's @supports not (color: oklch(...)) block |
--color-signal: #00bfd8; |
sRGB fallback for engines below Chrome 111 / Safari 16.4 / Firefox 113 |
The contract. Change a brand color in brand-system.md §2 first, then re-derive every
surface from it. src/design-system/brand-token-drift.ts enforces this: it parses the §2 table
and asserts all three copies match, failing with the token name, the surface, and both values.
Comparison is numeric, not textual — the surfaces legitimately write 0.56 and 0.560 for the
same value, and a string compare would report drift constantly and be switched off within a day.
Adding a token. Add the row to §2 AND an entry to TOKEN_MAP in that module. The map is
declared rather than inferred on purpose: distinct tokens can share a value (cockpit's light-mode
--primary is 0.078 0 0, the same triple as the site's --color-bg-base), so matching on
value would pair unrelated tokens and then "verify" that pairing forever. A canonical name with
no map entry fails the check rather than being skipped.
Why a check and not a generated pipeline. The canonical answer to one-source-many-surfaces is a token-transform pipeline (Style Dictionary and similar) generating per-platform output. That means a new dependency wired into two build systems, with three output transforms, to keep seven values in sync — heavier than the problem. Revisit when the shared set exceeds ~15 tokens or a third surface appears; at that point the manual path is the one the tooling literature warns about, and the check should be replaced by generation rather than extended.
docs/brand-system.md— color/typography/motion source of truth; §7 carries the current cockpit token mapping this doc's §5.3 fix updates..claude/skills/cockpit-design/SKILL.md— Minsky-domain entity/IA layer above this doc's token/component layer..claude/skills/minsky-brand/SKILL.md— brand foundation (myth, cultural code, bridge-as-affect) this doc's tokens instantiate.src/cockpit/CLAUDE.md— engineering/IA conventions; §Design vocabulary is the source for the healthy/warning raw-palette exception cited in §5.2.src/cockpit/web/lib/status-colors.ts,src/cockpit/web/lib/liveness-colors.ts— the shared token-consuming modules §4/§5 reference (mt#2909).- mt#2914 — umbrella. mt#2909 — prior
sibling (status-color consolidation; surfaced the §5.3 finding). mt#2917
— register-unification pass that adopts this doc's tokens across widgets. mt#2916
— structural lint enforcement (
custom/no-raw-colors-in-cockpit,eslint-rules/no-raw-colors-in-cockpit.js+eslint.config.js) of this section's §5.2 boundary — the umbrella child §1 forward-references above.