The house style for every commitwork surface: the admin panel and its /config page, the docsite,
generated HTML reports and the terminal. It covers the dark and light colour tokens, type, spacing,
shape, depth, motion, the component vocabulary and the marks. It also lists where current surfaces
depart from the style.
Every contrast figure was measured on 2026-09-27 from the token values in the stylesheets, using
WCAG 2.x relative luminance, with alpha colours composited over the ground they paint on. Unless a
row says otherwise, a figure is text on --bg.
Open this file with node bin/md-view.mjs docs/THEME.md and every Specimen cell below is set in the
values beside it, in the page's current theme. Read as plain Markdown, the tables still carry the
values. Sizes are shown at the 16px base; the panel and the docsite scale them (§4.2).
| Face | Weight | Style | Size | Specimen |
|---|---|---|---|---|
| IBM Plex Sans | 400 | normal | 1.1rem | The quick brown fox jumps over the lazy dog · 0123456789 |
| IBM Plex Sans | 400 | italic | 1.1rem | The quick brown fox jumps over the lazy dog · 0123456789 |
| IBM Plex Sans | 500 | normal | 1.1rem | The quick brown fox jumps over the lazy dog · 0123456789 |
| IBM Plex Sans | 600 | normal | 1.1rem | The quick brown fox jumps over the lazy dog · 0123456789 |
| IBM Plex Sans | 700 | normal | 1.1rem | The quick brown fox jumps over the lazy dog · 0123456789 |
| IBM Plex Mono | 400 | normal | 1rem | rollup.json · 4,852 findings · 0123456789 {}[] |
| IBM Plex Mono | 600 | normal | 1rem | rollup.json · 4,852 findings · 0123456789 {}[] |
| IBM Plex Mono | 700 | normal | 1rem | rollup.json · 4,852 findings · 0123456789 {}[] |
The logo is the seal, a glyph rather than type. The default mark is drawn for light grounds: a white disc, a black ring and a gold key. On a dark ground the seal takes a near-black disc and a gold ring. Both are one geometry, recoloured (§9).
| Part | Default mark | Dark-ground seal |
|---|---|---|
| Disc | #FFFFFF |
#14161A |
| Ring | #101011 |
#C9A227 |
| Key: two nodes and a connector | #C9A227 |
#C9A227 |
| Mark | Ground | Size | Specimen |
|---|---|---|---|
| Default mark, on white | #ffffff |
4rem | |
| Default mark, on paper | light | 4rem | |
| Dark-ground seal | dark | 4rem |
The wordmark is live text, never an image, so it takes the theme's ink.
| Placement | Specimen | Face | Size | Weight | Tracking | Case | Colour | Opacity |
|---|---|---|---|---|---|---|---|---|
| Panel bar | commitwork | IBM Plex Sans | .78rem | 700 | .16em | uppercase | --ink |
1 |
/config bar |
commitwork | IBM Plex Sans | .78rem | 700 | .16em | uppercase | --ink |
1 |
| Panel bar on phones | commitwork | IBM Plex Sans | .7rem | 700 | .13em | uppercase | --ink |
1 |
| Empty-lane panel | commitwork | IBM Plex Sans | .78rem | 700 | .16em | lowercase | --ink |
.55 |
| Docsite header | commitwork docs | --sans (system stack; the docsite ships no Plex) |
.9375rem | 600 | normal | none | --head |
1 |
The lockup sets the seal and the wordmark on one line, with the wordmark .31rem (5px at the base) from the seal. Below, at the panel's size and at twice that, drawn with the panel's own stylesheets:
<header class="bar"><div class="row"><div class="mark"><span class="seal"><cw-seal></cw-seal></span><span class="wordmark">commitwork</span></div></div></header>
<div class="lk-zoom"><div class="bar"><div class="row"><div class="mark"><span class="seal"><cw-seal></cw-seal></span><span class="wordmark">commitwork</span></div></div></div></div>
The docsite shell (lib/docsite-page.mjs) is the house document type. Where it sets no rule, the
row says so and shows the browser default inside the docsite's --sans.
| Element | Specimen | Face | Size | Weight | Line height | Tracking | Colour | Marker | Indent | Source |
|---|---|---|---|---|---|---|---|---|---|---|
h1 |
Heading one | --sans |
1.9rem | 700 | 1.2 | −.015em | --head |
Docsite rule; weight is the browser's bold | ||
h2 |
Heading two | --sans |
1.28rem | 700 | 1.62 | −.01em | --head |
Docsite rule | ||
h3 |
Heading three | --sans |
1.02rem | 700 | 1.62 | normal | --head |
Docsite rule | ||
h4 |
Heading four | --sans |
.9375rem | 700 | 1.62 | normal | --head |
Colour only; size is the browser's 1em | ||
h5 |
Heading five | --sans |
.78rem | 700 | 1.62 | normal | --head |
Colour only; size is the browser's .83em | ||
h6 |
Heading six | --sans |
.63rem | 700 | 1.62 | normal | --head |
Colour only; size is the browser's .67em | ||
p |
A paragraph sets the measure and the rhythm of the page. This one runs long enough to wrap, so its line height shows. | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
Body rule; measure capped at 84ch | ||
div |
Text in a plain division inherits the body face and size. | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
No rule; inherits the body | ||
div p |
A paragraph inside a division is set exactly as one outside it. | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
Same as p |
||
ul li |
A list item | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
disc | 1.4rem | ul,ol{padding-left:1.4rem} |
ul li li |
A nested item | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
circle | 1.4rem | Browser marker for a nested list |
ol li |
A numbered item | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
decimal | 1.4rem | ul,ol{padding-left:1.4rem} |
ol li li |
A nested numbered item | --sans |
.9375rem | 400 | 1.62 | normal | --ink |
decimal | 1.4rem | Browser marker for a nested list |
The panel is a Mono surface (§4.1). It styles headings only where a component uses one; the rest is the browser default inside the body face.
| Element | Specimen | Face | Size | Weight | Line height | Tracking | Colour | Marker | Indent | Source |
|---|---|---|---|---|---|---|---|---|---|---|
h1 |
commitwork admin | --sans |
1.625rem | 600 | 1.5 | −.01em | --ink |
Screen-reader only (.sr-only); no size rule, browser 2em; never painted |
||
h2 |
Security coverage | --sans |
.9375rem | 600 | 1.5 | −.01em | --ink |
.hd h2, the section heading |
||
h3 |
Scanner coverage | --sans |
.8125rem | 600 | 1.5 | normal | --ink |
.card h3, a card's title |
||
h4 |
Disposition history | --sans |
.719rem | 600 | 1.5 | normal | --mut |
.iss-sec>h4, a sub-heading in an issue panel |
||
h5 |
Heading five | --mono |
.675rem | 700 | 1.5 | normal | --ink |
No rule; browser .83em bold | ||
h6 |
Heading six | --mono |
.545rem | 700 | 1.5 | normal | --ink |
No rule; browser .67em bold | ||
p |
4,852 critical findings across 61 repositories; 12 are actively exploited. | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
Body rule | ||
div |
rollup 4d ago · 76 checks · 46 tools | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
Body rule | ||
div p |
A paragraph inside a card body. | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
Body rule | ||
ul li |
A list item | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
disc | 2.5rem | No general rule; browser 40px indent |
ul li li |
A nested item | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
circle | 2.5rem | No rule; browser marker and indent |
ol li |
A numbered item | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
decimal | 2.5rem | No general rule; browser 40px indent |
ol li li |
A nested numbered item | --mono |
.8125rem | 400 | 1.5 | normal | --ink |
decimal | 2.5rem | No rule; browser marker and indent |
Each lockup is the panel's own markup, drawn by node bin/md-view.mjs with the panel's stylesheets
(panel.css, theme.css, the workspace shell and panel-light.css), once on the dark ground and
once on the light. Read as plain Markdown, the markup shows instead. Tables are drawn narrower than
the panel's 40rem minimum so they fit a half-width column.
<header class="bar"><div class="row">
<div class="mark"><span class="seal"><cw-seal></cw-seal></span><span class="mark-text"><span class="wordmark">commitwork</span><span class="build">v0.6.56 · 388d0e86 · 11:42</span></span></div>
<span class="gen" data-state="fresh">rollup 12m ago</span>
<div class="ctl-wrap"><div class="ctl"><button type="button" class="pri">▶ Run checks</button><button type="button">↻ Refresh</button></div></div>
</div></header>
<aside id="workspace-rail"><div class="rail-label">All projects</div>
<nav><button type="button" class="rail-link active">Overview</button><button type="button" class="rail-link">Rollups</button><button type="button" class="rail-link">Decisions & reviews</button></nav></aside>
<div class="actrow">
<button type="button">↻ Refresh</button>
<button type="button" class="pri">Run checks</button>
<button type="button" class="ph-update">Update</button>
<button type="button" disabled>Disabled</button>
</div>
<div class="actrow">
<button type="button" class="rec">⏺</button>
<button type="button" class="rec on">⏺</button>
<button type="button" class="lnk" data-dir="asc">Severity</button>
<button type="button" class="closed-toggle">Closed findings <span class="caret">▸</span></button>
<button type="button" class="cq-play">Run remediation</button>
</div>
<nav id="groups">
<button type="button" class="gtab pri">Findings <span class="vn crit">12</span></button>
<button type="button" class="gtab">Work <span class="vn warn">3</span></button>
<button type="button" class="gtab">History <span class="vn"></span></button>
</nav>
<nav id="views">
<button type="button" class="vtab pri">Code security <span class="vn crit">4</span></button>
<button type="button" class="vtab">Dependencies <span class="vn warn">7</span></button>
<button type="button" class="vtab">Malicious packages <span class="vn triaged">0</span></button>
<button type="button" class="vtab lane-running">Secrets</button>
</nav>
<div class="actrow"><span class="lane-box on">✓</span><span class="lane-box off">·</span><span class="lane-box inherit">↳</span><span class="lane-box dash">?</span></div>
<div class="chiprow"><span class="pill live">live</span><span class="pill part">partial</span><span class="pill plan">planned</span><span class="pill unk">unknown</span><span class="pill na">n/a</span></div>
<div class="chiprow"><span class="pill crit">critical</span><span class="pill high">high</span><span class="pill med">medium</span><span class="pill low">low</span><span class="pill exploited">exploited</span></div>
<div class="chiprow"><span class="pill unrf">uncorroborated</span><span class="pill done">done</span><span class="pill attest">attested</span><span class="pill green-human">human-verified</span></div>
<div class="actrow"><span class="dot"></span><span class="gen" data-state="fresh">rollup 4m ago</span><span class="gen" data-state="stale">rollup 3h ago</span><span class="gen" data-state="old">rollup 4d ago</span><span class="gen" data-state="none">never scanned</span></div>
<div class="posture">
<span class="p-row"><span class="p-dot" data-s="ok"></span><span class="p-k">Sweep</span><span class="p-v">fresh · 12m</span></span>
<span class="p-row"><span class="p-dot" data-s="stale"></span><span class="p-k">Rollup</span><span class="p-v">stale · 2d</span></span>
<span class="p-row"><span class="p-dot" data-s="unknown"></span><span class="p-k">Runtime</span><span class="p-v" data-s="unknown">never scanned</span></span>
</div>
<div class="stack">
<div class="pk-state"><span class="pk-dot on"></span><span>passkey enrolled</span></div>
<div class="pk-state"><span class="pk-dot part"></span><span>enrolled, not yet confirmed</span></div>
<div class="pk-state"><span class="pk-dot none"></span><span>no passkey</span></div>
<div class="pk-state"><span class="pk-dot unk"></span><span>passkey state unknown</span></div>
</div>
<div class="hd"><h2>Security coverage</h2><span class="n">76 checks</span></div>
<div class="kpis">
<div class="kpi"><div class="k">Findings</div><div class="v">4,852</div><div class="s">across 61 repositories</div></div>
<div class="kpi good"><div class="k">Passing</div><div class="v">68</div><div class="s">checks this sweep</div></div>
<div class="kpi warn"><div class="k">Stale</div><div class="v">5</div><div class="s">older than a day</div></div>
<div class="kpi bad"><div class="k">Exploited</div><div class="v">12</div><div class="s">on CISA KEV</div></div>
</div>
<div class="tw"><table><thead><tr><th>Check</th><th>State</th><th>Severity</th><th>Where</th></tr></thead><tbody>
<tr data-s="live"><td class="d"><b>Semgrep</b><span class="c">sast</span></td><td><span class="pill live">live</span></td><td><span class="pill crit">critical</span></td><td class="mono t-loc">src/server.mjs:523</td></tr>
<tr data-s="part"><td class="d"><b>Trivy</b><span class="c">iac</span></td><td><span class="pill part">partial</span></td><td><span class="pill med">medium</span></td><td class="mono t-loc">Dockerfile:4</td></tr>
<tr data-s="plan"><td class="d"><b>nuclei</b><span class="c">dast</span></td><td><span class="pill plan">planned</span></td><td><span class="pill na">n/a</span></td><td class="mono t-loc">—</td></tr>
<tr class="ann-row"><td class="d"><b>gitleaks</b><span class="c">secrets</span></td><td><span class="pill live">live</span></td><td><span class="pill low">low</span></td><td class="mono t-loc">test/fixture.env:2</td></tr>
</tbody></table></div>
<div class="card"><h3>Scanner coverage</h3>
<div class="kv"><span class="mut">Last sweep</span><span>12m ago</span></div>
<div class="kv"><span class="mut">Checks</span><span>68 of 76</span></div>
<div class="find"><code>js/request-forgery</code><span class="at">src/server.mjs:523</span></div>
<div class="find"><code>js/path-injection</code><span class="at">src/files.mjs:88</span></div>
</div>
<div class="pf-form">
<div class="pf-field"><label>Display name</label><input class="pf-input" value="operator"></div>
<div class="pf-field"><label>Project</label><select id="proj"><option>example-app</option></select></div>
</div>
<div class="set-val"><input class="set-input" value="30"><span class="dur-units"><button type="button" class="dur-u on">min</button><button type="button" class="dur-u">h</button><button type="button" class="dur-u">d</button></span></div>
<label class="att-row"><input type="checkbox" class="att-box" checked><span class="att-lbl">Checked by hand</span></label>
<div class="pop">
<div class="pop-acct"><span class="avatar in">O</span><span class="acct-lines"><b>operator@example.com</b><span class="mut">signed in with a passkey</span></span></div>
<div class="pop-h">Preferences</div>
<div class="pop-fld"><span class="pop-lbl">Appearance</span><div class="theme-row"><button type="button" class="theme-opt" aria-checked="true" aria-label="Auto"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor"/></svg></button><button type="button" class="theme-opt" aria-checked="false" aria-label="Light"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 1.5v1.75M8 12.75v1.75M1.5 8h1.75M12.75 8h1.75M3.4 3.4l1.24 1.24M11.36 11.36l1.24 1.24M3.4 12.6l1.24-1.24M11.36 4.64l1.24-1.24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button><button type="button" class="theme-opt" aria-checked="false" aria-label="Dark"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.6A5.6 5.6 0 0 1 6.4 2.5a5.75 5.75 0 1 0 7.1 7.1z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></button></div></div>
<button type="button" class="menu-view">Account & security</button>
</div>
<div class="card"><div class="con-head"><span class="con-title">Sweep</span><span class="pill part">running</span></div>
<div class="prog"><div id="sw-prog"></div></div>
<pre class="con-log">[semgrep] 214 files scanned in 12s
[trivy] 3 misconfigurations
[nuclei] no target configured</pre></div>
<div class="lane-toasts">
<div class="lane-toast ok">Semgrep finished · 3 new findings</div>
<div class="lane-toast bad">Trivy failed · exit 2</div>
<div class="lane-toast grey">nuclei ended with no result</div>
</div>
<div class="learn-box"><b>Learning mode</b>A lane is one kind of check, such as secrets or dependency vulnerabilities.</div>
<div class="banner-box">68 of 76 checks ran in the last sweep; 8 have no result yet.</div>
<div class="pf-once"><b>Write these down now.</b> Recovery codes are shown once.</div>
<div class="cq-remed"><span>3 findings can be remediated by the dual-agent prompt.</span><span class="cq-remed-act"><button type="button" class="cq-play">Run remediation</button></span></div>
<div class="pop"><div class="pk-err">NotAllowedError: the passkey prompt was dismissed.</div></div>
<div class="cw-surface">
<div class="actrow">
<button type="button" class="cw-btn">No thanks <span class="cw-keys"><span class="cw-kbd">esc</span></span></button>
<button type="button" class="cw-btn cw-btn--fill cw-btn--ok">Run 2 selected <span class="cw-keys"><span class="cw-kbd">⏎</span></span></button>
<button type="button" class="cw-btn cw-btn--fill cw-btn--neutral">Approve one <span class="cw-keys"><span class="cw-kbd">⌥</span><span class="cw-kbd">⏎</span></span></button>
<button type="button" class="cw-btn cw-btn--fill cw-btn--caution">Reach live <span class="cw-keys"><span class="cw-kbd">⌘</span><span class="cw-kbd">⏎</span></span></button>
<button type="button" class="cw-btn cw-btn--fill cw-btn--stop">Grant all <span class="cw-keys"><span class="cw-kbd">⌘</span><span class="cw-kbd">⇧</span><span class="cw-kbd">⏎</span></span></button>
<button type="button" class="cw-btn cw-btn--obsidian">Obsidian</button>
</div>
<div class="actrow"><button type="button" class="cw-pick">Select this</button><button type="button" class="cw-pick" aria-pressed="true">Selected</button></div>
<div class="cw-dialog">
<h3 class="cw-dialog__title">Approve semgrep?</h3>
<p class="cw-dialog__body">The scan runs <code>semgrep</code> from this machine against every repository in scope.</p>
<div class="cw-facts"><span>/opt/homebrew/bin/semgrep</span><span>61 repositories</span><span>network: none</span></div>
<p class="cw-note">It reads every file in scope.</p>
<div class="cw-actions"><button type="button" class="cw-btn">Decline</button><button type="button" class="cw-btn cw-btn--fill cw-btn--neutral">Approve</button></div>
</div>
<div class="cw-grid">
<div class="cw-row cw-row--group"><input type="checkbox" class="cw-tick" checked><span class="cw-row__name">sast</span><span class="cw-row__needs">semgrep, codeql</span></div>
<div class="cw-row cw-row--lane"><input type="checkbox" class="cw-tick" checked><span class="cw-row__name">semgrep</span><span class="cw-row__needs">local binary<span class="cw-badge cw-badge--binary">binary</span></span></div>
<div class="cw-row cw-row--lane"><input type="checkbox" class="cw-tick"><span class="cw-row__name">codeql</span><span class="cw-row__needs">GitHub token<span class="cw-badge cw-badge--perm">permission</span></span></div>
</div>
</div>
<div class="cw-linear"><div class="cw-stage"><div class="cw-dlg">
<div class="cw-dlg__t">Scanning</div>
<div class="cw-dlg__b">Approve the tools this machine runs for a sweep. Approval is kept per binary.</div>
<div class="cw-meta"><span>46 tools</span><span>76 checks</span><span>local only</span></div>
<div class="cw-act"><button type="button" class="cw-ghost">Cancel</button><button type="button" class="cw-sev cw-sev--one">Approve one</button><button type="button" class="cw-sev cw-sev--go">Run</button><button type="button" class="cw-sev cw-sev--hold">Hold</button><button type="button" class="cw-sev cw-sev--all">Approve all</button></div>
</div></div></div>
- Take colour from tokens, never from literals. A page the panel serves links
/static/panel.css, pluspanel-light.cssbehind the media switch in §2.1. A generated page inlineshouseCss()fromlib/house-css.mjs; the docsite inlinesPAPER_CSSfromlib/brand-tokens.mjs. A new block of hex values is a second palette, and it will drift away from the first. - Derive fills from their token. Write
color-mix(in srgb, var(--crit) 12%, transparent), not anrgba()copied from today's value. A frozen literal keeps the old hue after the token moves. - Give every themed token a light value. A token that
panel-light.cssdoes not override keeps its bright dark-theme value on the light ground, where it fails. - Size in
rem. Use px only for hairlines, accent rules, focus outlines and shadow offsets. Size dots, markers and the rings around them inem, so they grow with the label they sit beside. The root font scales with the window (§4.2). - Never use colour as the only channel (WCAG 1.4.1). A state also carries a word, a glyph, a dot shape or a position.
- Give the accent one meaning per treatment. A solid accent fill means "this control acts". An accent rule under a tab means "you are here".
- Draw an unknown value as an absence: a hollow dot, a dashed border, italic dim text, or no count badge at all. Never draw it as a zero or with a state colour.
- Honour
prefers-reduced-motion. Stop the animation and keep the marker it animates. - Keep pages self-contained. Fonts come from
/static/fonts/, marks are inline SVG or data URIs, and nothing loads from a CDN.
| File | Holds |
|---|---|
admin/static/panel.css |
@font-face rules, the dark tokens on :root, and every panel component |
admin/static/panel-light.css |
Light overrides of those tokens, plus three light-only rules |
admin/static/panel-cvd.css, panel-cvd-light.css |
Colour-vision palettes keyed on :root[data-cvd]. Generated by bin/cvd-palette.mjs |
admin/static/theme.css |
The --cw-* component vocabulary: decision buttons, keyboard hints, the approval dialog, the selection grid and the Linear livery (§8). theme-demo.html renders all of it except the Linear livery |
admin/static/config.css |
Rules used only by /config. That page takes its tokens from panel.css |
admin/static/theme-switch.js, admin/lib/theme-head.mjs |
The Appearance choice on admin pages outside the panel shell, and the links those pages carry (§2.1) |
lib/house-css.mjs, bin/house-css.mjs, admin/static/house.css |
The house sheet for every other page, the tool that writes and checks its copies, and the copy the panel serves (§11) |
admin/menus/styles.html |
The workspace shell (top bar, side rail, larger controls). admin/lib/panel-document.mjs splices it into admin/panel.html when the panel is served; bin/build-admin-panel.mjs writes the same document to admin/index.html |
lib/brand-tokens.mjs |
The palette transcribed for generators (LIGHT, DARK, LIGHT_SEMANTIC, DARK_SEMANTIC, ROOT_CSS, PAPER_CSS, MONO, SANS) and the marks: the default mark (also every tab icon), the dark-ground seal and the key (the cw:key specimen only). bin/test/brand-tokens-parity.test.mjs holds LIGHT and DARK equal to the two panel stylesheets; lib/test/house-css.test.mjs holds the semantic sets |
lib/theme-follower.mjs |
html[data-mode] for pages the panel embeds or generates |
bin/lib/theme.mjs |
The terminal palette |
-
Dark is the default.
panel.cssdefines the dark tokens on an unguarded:root. -
Light is an overlay.
<link id="theme-light" href="/static/panel-light.css">carries amediaattribute, and that attribute is the switch.CW_THEME_MEDIAmaps the three choices:Choice mediavalueResult auto(prefers-color-scheme: light)Follows the OS lightallAlways light darknot allAlways dark The panel has no
data-themeattribute. A rule keyed on one never matches. -
Stored choice.
localStorage['cw-theme']holdslightordark; an absent key means auto. A head script applies it before first paint, so the page never flashes the other theme. -
Colour-vision link.
<link id="cvd-light">is switched in lockstep withtheme-light. Otherwise a manual light choice would keep the dark colour-vision palette. -
Appearance control. The account menu offers Auto, Light and Dark as a segmented radio group of icons (half-filled circle, sun, moon), each named by its
aria-labelandtitle. -
Other admin pages.
/config, the sign-in page and the placeholder pagesserve.mjsanswers with carry the same sheets (admin/config.htmllinks them itself, the rest throughadmin/lib/theme-head.mjs) and load/static/theme-switch.js.- The script applies the stored theme and vision choices before first paint. It switches
theme-light,cvd-lightand any element markeddata-light, and setshtml[data-mode]. - It turns a
[data-theme-switch]element into an AUTO / LIGHT / DARK text control with the same three choices: a tint with an accent rule under the selected segment. - An embedded page hides the control, because there the panel's menu owns the choice.
- The sign-in page loads it before a session exists, so it is a public asset with its own route.
- The script applies the stored theme and vision choices before first paint. It switches
-
/configin both themes. Its own rules take every colour from the tokens, so §3 holds for it. The pieces that are its own measure:Element Dark Light Filter chip, --muton--panel25.00 5.37 Selected chip and theme segment, --inkon--washover--bg11.71 13.34 Tab, --inkon--panel211.53 13.77 editabletag,--dimon--bg4.95 5.10 Path, --accon--panel6.81 5.66 Action button, --on-accon--acc7.64 5.66
FOLLOWER_JS(lib/theme-follower.mjs) sets<html data-mode="light|dark">andcolor-schemebefore first paint.- It reads
localStorage['cw-theme']first and falls back to(prefers-color-scheme: light). - It re-applies on the
storageevent, so an embedded page follows the panel's menu without a reload. ?mode=light|darkpins a page, for a screenshot or a link. Loading a page never writes the key.- Pages key their palettes on
html[data-mode].
- It reads
- The
#themetoggle (TOGGLE_JS) appears only on standalone pages. When a page is embedded, the panel's menu owns the choice. ROOT_CSSis light-first with aprefers-color-scheme: darkoverride. No page inlines it; a page that follows the OS takeshouseCss()(§11).
The docsite is a documents site laid out as bond paper. PAPER_CSS paints the light palette only,
with no dark override. A reader in dark mode still gets paper, because a paper livery that turned
black would stop being paper. Print swaps in #fff grounds and darker rules (lib/docsite-page.mjs).
The editor (docsite/editor/editor.css) is an operator tool, not a document. It has a dark theme,
driven by its own data-theme toggle.
Two stylesheets hold these palettes:
admin/static/panel-cvd.cssfor the dark theme;admin/static/panel-cvd-light.cssfor the light theme.
bin/cvd-palette.mjs generates both files. Do not edit them by hand.
How a palette is chosen. The Vision menu stores its choice in localStorage['cw-cvd'] and
applies it as :root[data-cvd] before first paint. When no palette is selected the attribute is
absent, and the default palette applies. A palette re-grades only the semantic tokens: --sev,
--crit, --high, --med, --low, --live, --part, --plan, --machine and --attest.
How the colours were found. Candidates are searched, not picked by eye.
- Keep only candidates that reach 4.5:1 or more on that theme's ground.
- From those, choose the set that maximises the minimum pairwise CIELAB ΔE76 under that condition's simulation. The simulation is Machado, Oliveira and Fernandes (2009) at severity 1.0.
Which colour axis each palette uses.
| Palette | Axis used |
|---|---|
| Protanopia and deuteranopia | Move onto the blue–yellow axis, which these readers keep |
| Tritanopia | Keeps critical red; the lower severity steps and the exploited band move |
| Achromatopsia | Separates colours within a group, but not across groups. Position and labels carry the rest |
Limits. Colour carries identity, and the order of rows carries rank. The generator grades
against the panel's own grounds (DARK.bg and LIGHT.bg from lib/brand-tokens.mjs), and every
palette clears 4.5:1 on --bg, --panel and --panel2, the worst at 4.60:1 on --panel2 (5.08:1
on --bg). As pills the dark palettes do not all clear it: the worst measures 4.27:1 (deuteranopia,
the exploited pill).
The default palette is not safe by default. The dark severity ramp (red, red-orange, orange, amber) cannot be ordered by a reader with protanopia or deuteranopia, and the selector does not fix that for readers who never open it.
Each contrast cell reads ground by ground: --bg · --panel · --panel2.
| Token | Dark | Light | Role | Contrast, dark | Contrast, light |
|---|---|---|---|---|---|
--bg |
#17181a |
#f4f3ef |
Page ground: warm dark grey, or bond paper | — | — |
--panel |
#1e1f22 |
#ffffff |
Cards, tables, popouts, the side rail | — | — |
--panel2 |
#26272a |
#eae8e2 |
Raised surfaces: table headers, the active tab, tab and menu hover, inputs | — | — |
--line |
#333438 |
#d8d5cc |
Structural hairline | — | — |
--line2 |
#44454a |
#b9b6aa |
Control borders; the hairline under table headers | — | — |
--head |
#f4f3ef |
#101011 |
Headings, table headers, dialog titles | 16.00 · 14.84 · 13.45 | 17.13 · 19.02 · 15.52 |
--ink |
#e4e2dc |
#1c1d1f |
Body text | 13.71 · 12.72 · 11.53 | 15.19 · 16.87 · 13.77 |
--mut |
#98958e |
#5f5d57 |
Secondary text and labels | 5.94 · 5.51 · 5.00 | 5.93 · 6.58 · 5.37 |
--dim |
#8a877e |
#69675f |
Tertiary text: stamps, captions, placeholders | 4.95 · 4.59 · 4.16 | 5.10 · 5.67 · 4.62 |
--dim is set on --bg and --panel only. In light it passes on every ground, but dark --dim
measures 4.16:1 on --panel2, so tertiary text on a raised surface uses --mut. A dark --dim
that passed there would be #908d84, only ΔE 3 from --mut, which would erase the tertiary level.
The dark ramp holds its hue near neutral, so the gold accent is the only chromatic thing on the surface.
| Token | Dark | Light | Role | Contrast, dark | Contrast, light |
|---|---|---|---|---|---|
--acc |
#c9a227 |
#885f25 |
Gold, and paper gold. Primary fill, selection rule, focus ring, links, progress fill | 7.34 · 6.81 · 6.17 | 5.10 · 5.66 · 4.62 |
--acc2 |
#9a7b1d |
#6e5220 |
Borders on hover and selection; the left rule on notes | 4.42 · 4.10 · 3.72 | 6.54 · 7.26 · 5.93 |
--wash |
rgba(201,162,39,.10) |
rgba(136,95,37,.10) |
Tinted selected state: segmented control, learning box, rail item | — | — |
--on-acc |
#1a120e |
#ffffff |
Ink on a solid --acc fill: the skip link, /config's action buttons and the docsite editor's buttons |
7.64 on --acc |
5.66 on --acc |
Dark --acc2 is under 4.5:1 on every dark ground (4.42 · 4.10 · 3.72), so it is a border colour,
never text; as a border it clears the 3:1 that WCAG 1.4.11 asks of a control's edge.
- Hover moves the edge, not the text. A hover changes a control's border to
--accor--acc2. - Filled gold takes dark ink in dark.
#1a120emeasures 7.64:1 on#c9a227. - Filled paper gold takes light ink in light. White measures 5.66:1 and
--bg5.10:1 on#885f25;#1a120ewould measure 3.26:1, so--on-accis white in light.
Pill contrast is the token on its own pill fill over --panel, dark · light.
| Token | Dark | Light | Meaning | Contrast, dark | Contrast, light | Pill contrast |
|---|---|---|---|---|---|---|
--live |
#5fd08a |
#047734 |
Running or passing now: a measured pass, a live lane, an enrolled passkey | 9.20 · 8.53 · 7.74 | 5.12 · 5.69 · 4.64 | 6.72 · 4.78 |
--part |
#d69a52 |
#955906 |
Partial, stale or pending; a warning that does not block | 7.28 · 6.75 · 6.12 | 5.10 · 5.66 · 4.62 | 5.50 · 4.79 |
--plan |
#8a92a5 |
#5b6675 |
Not evaluated, planned, or produced no result (noscan) |
5.70 · 5.29 · 4.79 | 5.25 · 5.83 · 4.76 | 4.43 · 4.96 |
| Token | Dark | Light | Meaning | Contrast, dark | Contrast, light | Pill contrast |
|---|---|---|---|---|---|---|
--crit |
#fe5b66 |
#c32b25 |
Critical | 5.84 · 5.42 · 4.91 | 5.12 · 5.69 · 4.64 | 4.64 · 4.71 |
--high |
#fe5d30 |
#9c4221 |
High | 5.77 · 5.35 · 4.85 | 5.88 · 6.53 · 5.33 | 4.61 · 5.45 |
--med |
#f18b2e |
#a14a02 |
Medium | 7.17 · 6.65 · 6.03 | 5.41 · 6.01 · 4.90 | 5.47 · 5.04 |
--low |
#f9bd30 |
#765e02 |
Low | 10.45 · 9.69 · 8.78 | 5.61 · 6.23 · 5.08 | 7.48 · 5.25 |
--sev |
#b47cff |
#7b2ff7 |
Actively exploited (on CISA KEV). Reserved for this one signal | 6.15 · 5.70 · 5.17 | 5.27 · 5.85 · 4.77 | 4.80 · 4.85 |
--sev-fill |
rgba(74,35,130,.55) |
rgba(123,47,247,.12) |
Fill for the exploited pill | — | — | — |
Hue carries the severity ramp. It runs red, red-orange, orange, amber, and its lightness is not
monotonic: forcing it to be would suppress the hue separation the ramp depends on. Adjacent dark
steps are ΔE 28, 28 and 28. Dark critical is a light rose-red: a pure red cannot clear AA on
--panel2 or as a pill on the dark ground, and lightening red along its own hue lands on high.
In light every severity colour has to be dark to pass, so the steps sit closer: ΔE 24, 14 and 31
from critical to low, and medium and low sit ΔE 14 and 18 from --part. Every pill also carries its
word, so no state rests on hue alone.
Exploitation is a kind of fact, not a higher severity. It therefore leaves the red ramp entirely and takes violet. Violet sits on the blue–yellow axis, which survives protanopia and deuteranopia.
Rank comes from rankOf. Table order carries rank, not colour. To change where exploited
findings rank, change rankOf; never repaint the palette.
The exploited fill is inverted between themes.
| Theme | Fill | Ink | Why |
|---|---|---|---|
| Dark | Heavy (.55) | Light | The row must survive a glance down a long table. Over --panel the fill composites to #362157, the ink on it measures 4.80:1, and it stays above 4.5:1 up to a fill of .70 |
| Light | .12 tint | Dark | Over --panel the ink measures 4.85:1 at .12 and 4.40:1 at .18. At .55 the tint composites to #b68dfb and the ink falls to 2.28:1 |
CRA reporting clocks escalate through --cra-50, --cra-75, --cra-90 and --cra-over, at 50,
75 and 90% of a deadline and past it. They are aliases of --low, --med, --high and --crit
(panel.css :root). An escalation ramp is a heat scale, so it takes the severity hues, and the
light theme and every colour-vision palette re-grade the clocks with the ramp. On the case box's
--panel2 ground they measure 8.78 · 6.03 · 4.85 · 4.91 in dark and 5.08 · 4.90 · 5.33 · 4.64 in
light. The lowest under a colour-vision palette is 4.60 (light, protanopia). The later bands also
step up in weight, 600 then 700, so the escalation does not rest on hue.
Dials take the same ramp. The scanner taxonomy (bin/scanner-taxonomy-render.mjs) draws its gain
dial up the escalation steps, --low, --med, --high, --crit, and its closure dial down them,
ending on --live for a class solved and pinned. Both take the values of the theme rendered. The
arc's length and the dial's label also carry the level, so hue is never the only channel.
These are separate axes from severity, and they never borrow its tokens.
| Token | Dark | Light | Meaning | Contrast, dark | Contrast, light | Pill contrast |
|---|---|---|---|---|---|---|
--done, --done-fill |
the --sev values |
the --sev values |
A completed plan or task. It has its own name so a finished plan never renders through the severity vocabulary | 6.15 · 5.70 · 5.17 | 5.27 · 5.85 · 4.77 | 4.61 · 4.70 |
--machine |
#8a9a5b |
#5c6b33 |
Olive. Written by an agent, or supplied by an environment override. Reads as "handled" without claiming a human decision | 5.80 · 5.38 · 4.87 | 5.25 · 5.82 · 4.75 | — |
--attest |
#6fb3d2 |
#1b6a8c |
Blue. Verified and signed for by a person. It is never --live: a measurement and a signature are different evidence |
7.66 · 7.11 · 6.44 | 5.42 · 6.02 · 4.92 | 5.73 · 5.07 |
The lifecycle triple is done (violet), active (.pill.live) and pending (.pill.part). The three
hues sit on two colour-vision axes, so no pair of states is separated by weight alone.
| Use | Recipe |
|---|---|
| Pill fill / border | token at 12% / 28% |
| Count badge | Default: --mut on a --panel2 fill inside a 1px --line2 ring. Graded: no fill, a 1px ring of the token at 45%, and the token as ink |
| Glow ring around a status dot | 0 0 0 .23em–.29em (3px at the base, in em so it grows with the dot), token at 14–16% |
| Wash (selected tint) | accent at 10% |
Error box (.pk-err) |
--crit at 8% fill, 35% border |
| Page glow | radial-gradient(120% 55% at 50% -8%, <accent> 6%, transparent 60%) |
| Sticky bar | color-mix(in srgb, var(--bg) 90%, transparent) with backdrop-filter: blur(8px). An opaque var(--bg) is declared first as the fallback |
| Literal | Where | Why it is not a token |
|---|---|---|
#1a120e |
Ink on every gold fill, and on every semantic fill in dark | In dark, dark ink passes AA on every fill and white fails; in light the semantic fills darken and take white |
#FFFFFF, #101011, #14161A, #C9A227 |
The marks | The logo's own colours: the default mark and the dark-ground seal (§9) |
#fff ground, #16171a ink, #e3e1db rules |
/perf JSON editor modal (.pc-modal) |
White in both themes, at the operator's request |
#fff |
.cw-pick, the #svcframe ground |
"Select this" sits outside the semantic family; the frame hosts foreign pages |
#000 |
Sign-in field ink, light theme only (admin/lib/login-page.mjs) |
The operator's rule of 2026-09-02: pure black field text. On the dark ground it would vanish, so dark keeps --ink |
#fff |
The sign-in QR frame (.qr) |
A scanner reads dark modules on a light quiet zone, in either theme |
#4285F4, #34A853, #FBBC05, #EA4335 |
The Google mark on the sign-in SSO button | Google's own logo colours, which are not recoloured |
#cfccc4, #fff |
The checkerboard under lib/md-view.mjs swatches (--checker) |
It shows a colour's alpha, so it stays the same in both themes |
38;2;R;G;B inline spans |
ansiHtml() in the sweep console |
The colour is data, read from a running scanner's output |
| Face | Weights shipped | Stack |
|---|---|---|
| IBM Plex Sans | 400, 400 italic, 500, 600, 700 | "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif |
| IBM Plex Mono | 400, 600, 700 | "IBM Plex Mono",ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace |
How the faces are loaded. Both are self-hosted Latin-1 subsets in admin/static/fonts/, as
woff2 files with font-display:swap and a unicode-range. Their licence is LICENSE-IBM-Plex.txt.
Generators outside the panel use MONO and SANS from lib/brand-tokens.mjs; these are the system
fallbacks without the Plex names (SANS adds Helvetica and Arial).
Mono is the panel's body face. It suits a surface made mostly of figures, identifiers, paths and hashes. Use Sans for:
- headings;
- tabs, buttons and menu labels;
- names inside tables (
.name, which sets Sans at 600); - the wordmark.
Keep Mono wherever text is columnar. /config and the docsite are prose, so their body text is Sans.
Only static weights ship. theme.css asks for 520 and 560 (and its Linear livery for 510 and
590), and config.css for 650; CSS font matching resolves each one upward: 510, 520, 560 and 590 render as 600, and 650 renders as 700. The Linear
livery names "Inter", which is not shipped, so it falls back to the system face.
Every size is in rem, and the root scales with the window:
html{font-size:clamp(16px, min(calc(16px * 100vh / 1080), calc(16px * 100vw / 1600)), 24px)}A head script uses the same rule with screen height × DPR. It holds 16px below 900px of width and re-runs on resize.
- The rule takes the smaller ratio. A narrow window on a 4K display gets small text, because a big monitor is not the same as a big window.
- The ceiling is 24px (150%). A 32px root would push the 40rem tables and the tab strip past the window.
- The docsite doubles its base with
--doc-scale:2.
Pixel values are at the 16px base.
| Step | rem | px | Use |
|---|---|---|---|
.t-micro |
.625 | 10 | WCAG 2.2 marker inside a level cell, set as a pill at .04em tracking |
.t-tag |
.625 | 10 | Table headers; a pill worn as a tag |
.t-meta |
.656 | 10.5 | Annotations, KPI keys, pills, count badges, freshness stamps |
.t-loc |
.6875 | 11 | Located things: file:line, a commit, a batch id, an age |
.t-note |
.719 | 11.5 | Explanatory prose, the page foot, section counts |
.t-body |
.75 | 12 | Table body, card rows |
| body | .8125 | 13 | Page body (Mono) |
.t-title |
.8125 | 13 | Card titles, section tabs, row names |
.t-lead |
.875 | 14 | Lead line of a fleet card; controls in the workspace shell |
.hd h2 |
.9375 | 15 | Section heading |
.t-mark |
1 | 16 | An emoji standing in for a vendor mark; the empty-lane heading |
.kpi .v |
1.25 | 20 | KPI figure |
/config sets its prose at .71875rem with 1.55 line height and −.006em tracking. It uses a
1.1875rem h1 and a 1.4375rem KPI figure. The docsite body is .9375rem at 1.62 line height, with a
1.9rem h1.
Small text. The floor is .625rem, 10px at the base.
- Nothing smaller. No text is set below .625rem.
- .625rem is for tags. It is used only for short uppercase labels, badges and tags, at weight 600 or more and .04em tracking or more.
- The meta step, .656rem, holds a short annotation, key, pill, badge or stamp of one line.
- Sentences and values start at .6875rem, and so does any note that wraps.
- The exception is a glyph. A marker that is only a glyph and is hidden from assistive technology
(
aria-hidden, or CSScontentwith empty alternative text) may go smaller. Two do: the▸disclosure marker on/config's repository rows, and the.mark-lmvendor badge.
| Treatment | Value |
|---|---|
| Body | 400 |
| View tabs, account name | 500 |
| Headings, card titles, pills, active tab, section tabs, names | 600 |
| Wordmark, sub-section headers in the scanner table | 700 |
h1, h2 tracking |
−.01em |
| KPI figure tracking | −.02em |
| Button tracking | .02em |
| Uppercase labels | th .08em · KPI key .1em · field label .1em · scope and menu headings .11em · primary button .12em · rail label .14em · wordmark .16em |
Uppercase is for short labels only: table headers, KPI keys, field labels, section labels, the primary button and the wordmark. Never set sentences in uppercase.
-
Figures that line up take tabular numerals. Use
.tnum, which setsfont-variant-numeric:tabular-nums. Counts and timers also use it. -
Line height depends on the text:
Text Line height Body 1.5 Prose blocks 1.55–1.65 (the remediation plan is 1.65) Menus and badges 1.3–1.45 Tabs 1.3 -
Cap the line length (measure) at the job the text does:
Text Measure Empty-lane text 52ch Setting notes 62ch Model reasoning 70ch Learning notes 78ch Docsite paragraphs 84ch Verdict reasoning 90ch Comment-review columns 80ch
Spacing uses sixteenths of a rem (1px at the base). These are the working steps:
.125 · .1875 · .25 · .3125 · .375 · .4375 · .5 · .5625 · .625 · .6875 · .75 · .8125 · .875 · 1 · 1.25 · 1.625 · 2 · 3 · 5 rem
Gaps inside a control are .25–.5rem. Gaps between controls are .5–.75rem. Page-level gaps are 1.25rem and up.
The named rhythm classes are:
| Class | Value |
|---|---|
.gap-t |
.5rem top |
.gap-banner |
.75rem below a coverage banner |
.gap-strip, .gap-top |
.875rem |
.hd-sub |
1rem, for a second section within one tab |
| Element | Value |
|---|---|
| Content column | --colw: 75rem (1200px). .wrap, the bar row and the three consoles all read it |
.wrap |
max-width:var(--colw); padding:0 1.25rem 5rem |
| Section | margin:1.625rem 0 |
Section heading .hd |
baseline flex, gap .6875rem; padding-bottom:.5rem, margin-bottom:.8125rem, 1px --line rule below |
| Bar (base panel) | sticky; row padding .8125rem 1.25rem .4375rem; margin-bottom:1.375rem |
| Workspace shell bar | min-height:4.75rem; padding .85rem 1.5rem; gap 1rem |
| Side rail | --rail-width:13rem; padding 1.5rem .875rem; --panel ground with a --line rule on the right; fixed below the bar |
| Content context | padding 1.35rem 1.5rem .25rem |
| Page foot | margin-top:2rem; padding-top:.875rem; --line rule above; --dim text at .719rem |
/config column |
96rem |
| Docsite column | 55rem, with 1.5rem side padding |
| Component | Padding | Other |
|---|---|---|
| Button (panel) | .375rem .75rem | Workspace shell: .5rem .85rem, min-height:2.5rem; phones: min-height:2.25rem |
Section tab .gtab |
.375rem .875rem | Gap .4375rem |
View tab #views .vtab |
.25rem .5625rem | max-width:11rem; strip gap .25rem |
| Rail link | .7rem .75rem | min-height:2.75rem |
| Card | .9375rem 1rem | .card-tight is .75rem .875rem |
| KPI tile | .8125rem .875rem | Grid minmax(9.875rem,1fr), gap .6875rem |
Table th / td |
.625rem .8125rem / .5625rem .8125rem | Phones: .5rem .625rem |
| Pill | .125rem .5rem | Dot .57em (6px at the base); gap .3125rem |
| Count badge | .0625rem .3125rem | — |
| Input | .1875rem .375rem | .pf-input: .375rem .5625rem |
| Console log, pre block | .5625rem .6875rem | — |
| Popout menu | .3125rem | Workspace shell: .75rem |
Dialog (theme.css) |
1.1rem 1.2rem 1.2rem | Width min(34rem, 100vw − 2rem) |
| Width | Change |
|---|---|
| ≤ 1400px | The last table column (the one the operator acts in) becomes sticky on the right |
| ≤ 1100px | The workspace bar hides the freshness stamp and tightens |
| ≤ 800px | The rail becomes a drawer behind ☰ Navigate over a .45 scrim; the bar wraps |
| ≤ 720px | Two-column grids stack |
| ≤ 640px | Phone layout: the tab strip becomes one scrolling row; tables drop to min-width:34rem and keep scrolling, with no hidden columns; padding tightens |
| ≤ 45em | /config definition lists and grids stack |
| Radius | Use |
|---|---|
| 0 | Segments inside a segmented control; workspace-shell inputs and the check picker |
| 2px | Small selects in menus, legend keys |
3px (--cw-r-sm) |
Buttons: panel, workspace shell and decision buttons |
| .25rem | Lane boxes, the segmented-control group |
| .3125rem | Count badges, chips |
| .375rem | Inputs (profile fields and the project select take .5rem), notes, credential figures |
| .5rem | Section tabs, popouts, banners, consoles, pre blocks |
.625rem / 10px (--cw-r-lg) |
Cards, table wrappers, KPI tiles, posture strip, dialog, workspace sections, workspace-shell popouts |
| 999px | Pills and pill-shaped badges |
| 50% | Dots, avatars, the seal |
A radius belongs to the group, never to a segment inside it.
| Border | Meaning |
|---|---|
1px --line |
Structure |
1px --line2 |
Controls |
| 3px left rule | State: KPI tiles, the descriptor cell in scanner tables, toasts, remediation bars, CRA cases, notes, the learning box |
inset 0 -2px 0 0 var(--acc) |
Selected: section tab, view tab, workspace segment |
inset 2px 0 0 var(--acc2) on the first cell |
A hovered table row. It is never a fill: a fill darkens the ground under every pill in the row |
2px --acc bottom border |
The primary button |
2px --acc left rule over --wash |
The active rail item |
| Dashed | Unknown or provisional: .pill.unk, the unknown passkey dot, the one-shot enrolment block, model reasoning, a partially configured lane |
| Dotted | In progress: the running-lane ring |
Hairline dashed --line |
Separates rows inside a card (.kv, .find) |
| Layer | Shadow or scrim |
|---|---|
| Popout menu | 0 8px 24px rgba(0,0,0,.35) |
| Toast | 0 2px 10px rgba(0,0,0,.35) |
| Dialog | 0 14px 36px rgba(0,0,0,.55); scrim rgba(10,10,12,.72) |
/perf editor modal |
0 1rem 3rem rgba(0,0,0,.45); scrim rgba(0,0,0,.55) |
| Rail drawer | 12px 0 35px rgba(0,0,0,.25); scrim rgba(0,0,0,.45) |
| Command palette | scrim --bg at 65% |
| Sticky last column | -0.5rem 0 .5rem -0.5rem rgba(0,0,0,.45) marks the seam |
Stacking order (z-index):
| Layer | z-index |
|---|---|
| Sticky table cell | 1 |
Sticky group row (.cw-row--group) |
2 |
| Bar | 5 |
| Popout menu | 10 |
| Rail drawer scrim | 14 |
| Rail | 15 |
| Workspace bar | 20 |
| Skip link | 20 |
| Dialog scrim / dialog | 40 / 41 |
Toasts, /perf modal |
60 |
| Command palette | 1000 |
| Motion | Timing |
|---|---|
| Control transitions | .14s |
| Glow transitions | .16s |
| Progress fill width | .3s |
| Spinner | .7s linear |
| Running-lane ring | 1.6s linear, dotted, with the right edge open |
| First-run pulse | 1.6s ease-in-out, opacity 1 → .35 |
| Record pulse | 1.1s |
Under prefers-reduced-motion:reduce, the lane ring, the first-run pulse and the spinner stop, the
workspace-shell transitions are dropped, and every marker stays visible. The record pulse does not
stop (§12).
- Focus ring. Every interactive element shows
outline: 2px solid var(--acc)with a 2px offset. Lane boxes and notes use a 1px offset; the workspace shell uses 3px. - Skip link. It is the first focusable element. It stays off-screen until focused, then pins at .75rem, set in Sans 600 over the accent.
- The page title is screen-reader only. It sits in
.sr-only, because the wordmark already states the name visually. - Touch targets. The attestation checkbox is 1.5rem, which meets the WCAG 2.2 24×24 minimum (2.5.8). Workspace-shell controls are 2.5–2.75rem tall.
Buttons
- Base. Sans .78125rem with .02em tracking,
--inkon--panel, a 1px--line2border and a 3px radius. On hover the border becomes--acc. A disabled button is at .55 opacity. .pri. A gradient from--panel2to--panel,--headink, a 2px--accbottom rule, 600 weight, and uppercase at .12em tracking..ph-update. Swaps the bottom rule to--part.- The run control. While a sweep runs, its bottom rule turns
--crit. .rec. Stays quiet until hovered.
Tabs
- Two levels. Sections (
.gtab) sit above views (.vtab). - Resting.
--muton the bar's--bg. - Hover.
--inkon--panel2. - Active section.
--panel2surface,--acc2border and the inset accent rule. - Active view. A transparent ground with the inset rule only, so the two levels never both look current.
- Solid accent fill never marks a tab.
- Workspace shell. Sections (
#groups .gtab) lose their box:--mutat rest,--inkwith the inset accent rule when current. Rail links are--muton--panel,--inkon--panel2on hover, and--inkon--washover--panelwith a 2px--accleft rule when current.
| State | Dark | Light |
|---|---|---|
Resting tab, --mut on --bg |
5.94 | 5.93 |
Hovered tab, --ink on --panel2 |
11.53 | 13.77 |
Current shell section, --ink on --bg |
13.71 | 15.19 |
| Rail link at rest · hovered · current | 5.51 · 11.53 · 10.76 | 6.58 · 13.77 · 14.73 |
Count badges (.vn)
- Default. Mono .656rem,
--muton a--panel2fill inside a 1px--line2ring. The ring keeps the badge's edge on a hovered or current tab, whose ground is also--panel2. - Graded states.
.warnis--part;.critis--critat 600;.triagedis--machine. A graded badge has no fill: a 1px ring of its token at 45% and the token as ink. - Unknown count. Render no badge at all (
:emptyhides it). Never render a 0 for a lane that was not read. - Zero. A measured zero keeps the quiet default.
- Workspace shell. Badges become outlined 999px pills:
--mutink with a--line2border;.warnin--inkwith an--accborder;.critas a solid--accfill with--bgink.
Each badge is measured on its tab's ground: the bar's --bg at rest, --panel2 when the tab is
hovered or current. The shell's rail puts its badges on --panel.
| Badge | Dark | Light |
|---|---|---|
Default, --mut on --panel2 |
5.00 | 5.37 |
.warn, --part · --bg · --panel2 |
7.28 · 6.12 | 5.10 · 4.62 |
.crit, --crit · --bg · --panel2 |
5.84 · 4.91 | 5.12 · 4.64 |
.triaged, --machine · --bg · --panel2 |
5.80 · 4.87 | 5.25 · 4.75 |
Shell, outlined --mut · --bg · --panel · --panel2 |
5.94 · 5.51 · 5.00 | 5.93 · 6.58 · 5.37 |
Shell .warn, --ink · --bg · --panel · --panel2 |
13.71 · 12.72 · 11.53 | 15.19 · 16.87 · 13.77 |
Shell .crit, --bg on --acc |
7.34 | 5.10 |
The tinted badges these replaced failed: the default was 3.20 dark and 3.24 light on its --line2
fill, and the 16–18% tints fell as low as 3.55 (light .crit on --panel2). Every colour-vision
palette clears 4.5 on the badges as they are now.
Pills
- Status.
live,part,plan. - Severity.
crit,high,med,low, each filled from its token at 12%. - Exploited.
--sevink on--sev-fill, at 600. unrf. An uncorroborated zero:--sevink at a 12% fill and 40% border.done. The lifecycle state: 14% fill, 38% border.- Human verification.
attestandgreen-human, both in--attestblue. unk.--mutink, transparent, with a dashed border.na.--planink with a hollow dot, at .72 opacity. It is quieter thanunkbecause it is a settled answer.
Dots
Every dot and its halo is sized in em against the label it marks, so it grows with that label.
The px figure in brackets is each size at the 16px base. Hollow outlines stay 1–1.5px, as hairlines.
| Dot | Size and meaning |
|---|---|
.dot |
.69em (9px), with a .23em halo |
.gen::before |
.57em (6px) freshness dot, .29em halo: fresh (--live), stale (--part), old (--crit), none (hollow --plan ring) |
.p-dot |
.7em (8px) posture dot, .26em halo: ok, stale, unknown (hollow) |
.pk-dot |
.67em (8px beside its .75rem label): unknown (dashed hollow), none (solid --dim), on (--live), unconfirmed (--part) |
.pill::before |
.57em (6px), in the pill's own colour |
.vtab.lane-running::after |
.73em (8px) dotted ring after a tab label while its lane runs |
Other panel components
- KPI tile.
--panelground and a 3px left bar in--acc;good,warnandbadturn the bar--live,--partand--crit. The key is an uppercase .656rem label; the figure is Sans 600 at 1.25rem. - Posture strip. A Mono row of state dots. An unknown value renders as italic
--dim, never as a number. - Tables. Wrapped in
.tw, which scrolls horizontally. Tables havemin-width:40rem. Headers are Mono uppercase--headon--panel2; a hovered row takes a 2px--acc2rule on its first cell, never a fill. The first cell carries a 3px state rule. A sortable header is a real<button class="lnk">with ▲/▼ in--acc. - Forms.
- Inputs are Mono,
--inkon--panel2(or on--bgfor profile fields), with a 1px--line2border. - On hover the border becomes
--acc2; focus shows the accent ring. - Checkboxes and range inputs use
accent-color:var(--acc). - Labels are uppercase .656rem
--mut. - A result line under a control group is
--liveor--crit, and its sentence also says the outcome.
- Inputs are Mono,
- Menus. A
.poppopout. Its segmented control is tinted (--wash), not filled. Field rows put the label on the left and the control on the right. - Consoles. A 6px progress track on
--panel2with an--accfill; a log in Mono .719rem--muton--bg, capped at 13.75rem tall. - Toasts. Fixed at the bottom right.
--panel2ground with a 3px left rule:okis--live,badis--crit, andgreyis--planfor a lane that finished without a verdict.
Row states. Each state has its own opacity, and every row also names its state in words.
| Row state | Opacity |
|---|---|
| Carried forward | .72 |
| Superseded | .65 |
| Standby | .6 |
| Absent, accepted | .55 |
| Retired | .5 |
| Annotated | .55, struck through in --part at 60%. The row stays; suppression never deletes it |
theme.css holds the approval and selection vocabulary that came out of the 2026-09-03 design
review. Its tokens use a --cw- prefix. Their ground and ink alias the panel's tokens, with dark
fallbacks, and they add semantic fills:
| Fill | Value | Contrast with #1a120e ink |
Hover (-lift) |
Hover contrast |
|---|---|---|---|---|
--cw-acc |
#c9a227 |
7.64 | #dcb43a |
9.35 |
--cw-ok |
#9bb069 |
7.75 | #aec27d |
9.51 |
--cw-caution |
#d69a52 |
7.57 | #e4ac68 |
9.15 |
--cw-stop |
#e5484d |
4.72 | #ef5f64 |
5.71 |
--cw-obsidian |
#0b0b0c |
Takes white ink at 19.67:1 | — | — |
/config links this file. The panel serves it at /static/theme.css.
Every filled button takes dark ink. Against #ffffff and #1a120e:
| Fill | White | Dark ink |
|---|---|---|
Gold, --cw-acc |
2.42 | 7.64 |
Amber, --cw-caution |
2.44 | 7.57 |
Olive, --cw-ok |
2.38 | 7.75 |
Red, --cw-stop |
3.91 | 4.72 |
White fails AA on every fill, so there is no light-on-colour variant to reach for. The only
exception is white on --cw-obsidian, at 19.67:1.
Hover lifts the fill and adds a glow. On a dark ground a lighter fill raises the dark ink's
contrast, so each -lift hover stays above its resting ratio (on a light ground it does not, §12); the glow says how much the action grants. A white-text
fill cannot do the same, because lightening it lowers the text's contrast (the Linear livery, below).
Gold has a dead zone. On a light surface, #96770f fails both ways: 4.25 against white text and
4.35 against dark ink. So on .cw-light, gold alone keeps its bright fill and dark ink (7.64:1)
while every other fill inverts to a dark fill with white text: --cw-ok #1f7a4d at 5.32:1,
--cw-caution #8a6d14 at 4.91:1 and --cw-stop #c0343a at 5.54:1. On a light ground the glow
becomes a 3px focus-style ring, because a bloom on light reads as a smudge.
Hue alone would rank the buttons by pigment brightness rather than consequence: gold is the brightest fill, and would read loudest while it means "caution". So the halo radius is the variable, and hue comes second.
| Class | What it grants | Halo |
|---|---|---|
--ok |
Nothing new; already-approved work | Rim only |
--neutral |
One binary, reversible | 10px |
--caution |
Reaches a live service | 18px + 34px |
--stop |
Everything; irreversible | 26px + 58px |
The keyboard shortcut escalates with the halo. The action that grants nothing takes a bare ⏎.
Each step up adds a modifier, ending at ⌘⇧⏎ for grant-all. Severity is encoded twice, visually and
in the chord, so a reader who does not notice the glow still gets it.
- Markup.
.cw-keyscarriesdata-macanddata-win. - Platform. It is resolved at runtime from
navigator.userAgentData.platform, falling back tonavigator.platform, and never from a UA string. A⌘hard-coded in markup is wrong for every Windows and Linux reader. - Size. Hints are Mono .82rem on a filled chip with real padding, so they can be read rather than merely noticed.
- Why it exists. Every check in
manifests/security-baseline.jsondeclares a required binary, so the dialog is on the critical path for every lane. - Approval is keyed on the binary, not the lane. Keying on the lane would ask about
semgreponce per lane that uses it. Bulk approval uses the inline selection grid (.cw-grid,.cw-row), not a queue of dialogs. - Button layout. The decision pair is centred, with decline on the left and the affirmative on the right, 24px apart.
- Button radius. The dialog's buttons use a 3px radius.
- Facts chips. The facts the operator consents to (what runs, from where, over how much) are
Mono chips in
.cw-facts, never prose.
Variant E of the same review uses the .cw-linear class. It left-aligns the dialog, puts the
actions at the bottom right, and sets them on a near-black ground (#08090a) with an indigo accent
(#5e6ad2, which carries white at 4.70:1).
Nothing uses it: the Scanning section on /config uses the standard decision controls above. The
livery keeps the rules above:
- Hover heat lives in the glow. The indigo fill holds still on hover: its hover edge
#7b86e4would carry white at only 3.30:1. - Severity rides the glow radius.
.cw-sev--allblooms at 22px, and.cw-sev--oneat 9px. The severity fills carry dark ink at 5.73:1 (--one,#7c87ea), 6.94:1 (--go,#3fb27f), 6.29:1 (--all,#ff6166) and 9.49:1 (--hold,#e3b341).
- Which button carries the default. An inverted variant, where decline is the filled button and
⏎refuses, was reviewed and not chosen. It remains the safer default for a dialog that grants execution. .cw-light's ground. Its fills also apply underhtml[data-mode=light], which is how/configtakes them in the light theme. Its ground is#fbfbfa, not the panel's#f4f3ef.- Two tokens have no fill.
--sevand--attesthave no settled meaning as a fill, sotheme.cssdoes not define one.
The logo is the seal. lib/brand-tokens.mjs holds its geometry once, as SEAL_SVG:
- a 32×32 viewBox;
- a disc, r16;
- a ring, r14.5, stroke 1;
- a filled node, r2.4, at (12,20);
- a hollow node, r2.4, at (21,11), stroke 1.3;
- a connector from 13.7,18.3 to 19.3,12.7, stroke 1.3.
The default mark is for light grounds. MARK_SVG recolours that geometry: a #FFFFFF disc, a
#101011 ring and a #C9A227 key. On white the disc meets the page and the black ring is its edge.
MARK_IMG and MARK_URI carry it as an image.
The dark-ground seal is SEAL_SVG itself: a #14161A disc, with the ring and key in #C9A227.
Inline copies match SEAL_SVG byte for byte. Six sources carry one:
admin/menus/top-bar.html, spliced into the panel page (and so intoadmin/index.html);admin/static/panel-core.js, the empty-lane lock-up;admin/config.html;admin/lib/login-page.mjs;docsite/editor/edit.html;sitemap/mainline.html.
bin/test/brand-tokens-parity.test.mjs holds the panel page and the editor to it, and
sitemap/test/light-dark.test.mjs holds mainline.html; the /config, sign-in and empty-lane
copies match today, and no test holds them. On a light ground the stylesheet recolours the copy to
the default mark: the disc is its first circle and the ring its second. panel-light.css does this
for the panel, /config and the sign-in page, the editor's light chrome does it for the editor, and
sitemap/mainline.html carries its own rule.
Use it as an image on user-editable pages. Where the mark reaches a page rendered from
user-editable source, use MARK_IMG (or SEAL_IMG on a dark ground), a data-URI <img>, and never
a live <svg>. A standalone SVG without xmlns does not render, so the wrappers add it.
- Size. The seal is a 1.5rem square, with a .31rem gap (5px at the base) before the wordmark.
- Wordmark. Live text, "commitwork", in Sans .78rem 700, uppercase at .16em tracking, in
--ink. - Phones. 1.25rem and .7rem, with .13em tracking.
/config. The seal is 1.375rem.- Empty-lane panel. The lock-up appears at .55 opacity, with the wordmark in lowercase.
- Tab icons are the default mark. The panel and the sitemap each serve
cw-favicon.svg,cw-favicon-32.pngand afavicon.icoholding 16, 32 and 48px PNGs (sitemap/mainline.htmllinks only the SVG). The docsite shell, the public origin's pages (we/public/in the commitwork-web repository) and the build-box landing page (admin/public/index.html) carryMARK_ICON, the mark as a data URI, and so does every page a generator writes for opening from disk or Pages (rollup, timelines, maps, reports, md-view).lib/test/tab-icons.test.mjsfails on a producer that writes a page without one, apart from two exempted as pending (bin/lib/brief.mjs,bin/remediation-web.mjs), which still write pages without one; pages served only from the panel origin take its/favicon.ico. - The key.
KEY_SVGis the seal with a#101011disc and#c9a227gold. No surface wears it; it remains only as thecw:keyspecimen.
bin/lib/theme.mjs uses truecolor SGR.
When colour is used. In order of precedence:
NO_COLORorCW_NO_COLORturns colour off.FORCE_COLOR, when set to anything but0, forces colour on, even when piped.- Otherwise colour is used only on a TTY.
stripAnsi removes the codes wherever printed width is measured.
Every token except blocked is the dark panel token of the same name. blocked has no panel
token; it is 5.06:1 on the dark --bg and ΔE 30 from crit.
| Token | Hex | Use | Glyph |
|---|---|---|---|
acc |
#c9a227 |
Headings, the [tag] phase banner |
— |
acc2 |
#9a7b1d |
Rules, underlines | — |
ink / mut / dim |
#e4e2dc / #98958e / #8a877e |
Body / labels / echoed commands and paths | — |
live |
#5fd08a |
Pass | ✓ pass |
crit |
#fe5b66 |
Fail | ✗ fail |
high |
#fe5d30 |
noscan: ran, produced nothing trustworthy |
▚ noscan |
blocked |
#f2448c |
A void a person can clear (a missing credential or tool) | ■ BLOCKED |
part |
#d69a52 |
Skipped on purpose | ⊘ skipped |
plan |
#8a92a5 |
Not applicable | · n/a |
Keep the [tag] phase banner verbatim; operators grep logs for it.
Pages the panel serves take panel.css and panel-light.css (§2.1). Every other page takes the same
palette from lib/house-css.mjs, so no surface carries a palette of its own.
houseCss({ fonts, weights }) returns the IBM Plex faces, the tokens and the base elements. Its parts
are also exported: houseFonts(), houseTokens(), houseBase() and houseSwitchCss(), plus
houseTokenValues() for tests that measure contrast.
- Tokens. Light values on
:root, dark ones onhtml[data-mode=dark], and aprefers-color-schemefallback for a page without scripts. They are the panel's names and values, read fromlib/brand-tokens.mjs, including--on-accand the--cra-*clock aliases. - Fonts.
servedpoints at/static/fonts/;inlineembeds a chosen subset as data URIs, for a page opened from disk;noneleaves the system stacks. - Base elements. Headings, prose, lists, links, code, tables, blockquotes, pills and the theme
switch control, all written against the tokens. The pills include
.pill.unkand.pill.na, drawn as the panel draws them (§7): an unknown or not-applicable value takes these, never a style of its own (Rule 7).
A page takes the sheet in one of four ways:
- A generated page inlines it, with
followerScript()fromlib/theme-follower.mjsso it follows the panel's Appearance choice. A page that must stay script-free follows the OS instead. - The panel serves it at
/static/house.css, the copybin/house-css.mjswrites toadmin/static/house.css. - A static page carries a
<style id="house-css">block when it must stay one file or its CSP admits inline style only.bin/house-css.mjsregisters each such page as a copy and fills the block;--checkfails when a block is stale. - A page the panel serves outside its shell links the panel's own sheets through
admin/lib/theme-head.mjs(§2.1).
A page keeps only its own layout rules, and any name of its own is an alias onto a token. Charts keep their categorical series colours, which are data; their ground, ink, lines and severity come from the tokens.
bin/test/house-palette-guard.test.mjs fails any tracked producer that declares its own --bg,
--ink or --acc. It names the tests that must declare one to test anything, the producers that
interpolate lib/brand-tokens.mjs or carry PAPER_CSS verbatim, and any surface still pending (§12). It reads past a
house-css block and requires every page carrying one to be a registered copy. A generator's
"Generated by" header exempts only the files that generator writes, so an imported docsite source
that names bin/docsite-build.mjs is checked as a producer, and any colour it restates outside
PAPER_CSS must equal its token.
| Surface | How it takes the style | Light and dark |
|---|---|---|
The panel, /config, the sign-in page, the serve.mjs placeholders, the off-box page |
panel.css and panel-light.css: the panel and /config link them in their own head, the rest through admin/lib/theme-head.mjs |
Stored choice; AUTO / LIGHT / DARK control |
admin/menus/styles.html (workspace shell) |
Panel tokens | Panel switch |
Memory view (admin/lib/memory-view.mjs) |
House tokens and the switch script, inlined | Stored choice; the OS when opened from disk |
Memory view's 503 page (renderUnavailable) |
houseCss(), no font |
Follower |
Docsite shell (lib/docsite-page.mjs), the pages it builds, and bin/taxonomy-web.mjs |
PAPER_CSS |
Light only, by design (§2.3) |
docsite/imported/commitwork-coverage-survey.html, a recovered page the build copies |
The docsite shell's stylesheet, header and footer, held equal to lib/docsite-page.mjs by admin/test/theme-conformity.test.mjs; its own names alias the tokens, and --warn restates light --part under the palette guard |
Light only |
| Docsite editor | PAPER_CSS, and the dark values restated under a parity test |
Own data-theme toggle |
bin/taxonomy-render.mjs, bin/scanner-taxonomy-render.mjs |
LIGHT / DARK interpolated |
Chosen at build time with --dark |
bin/taxonomy-structure.mjs |
LIGHT interpolated |
Light only |
lib/md-view.mjs |
houseTokens() and houseBase(), faces inlined |
Follower and toggle |
Monitor reports (rollup, runtime-report, timeline, timeline2) |
houseCss(), faces served |
Follower and toggle, embedded or standalone |
map/generate.mjs |
houseTokens(), faces served; its own names are aliases |
Follower |
lib/launchlist-render.mjs, the map/render.mjs no-data placeholder |
houseCss(), faces served |
Follower |
bin/projectstatus.mjs, cra/dashboard.mjs, chunk-diff/generate.mjs, bin/lib/lattice-html.mjs, bin/lib/brief.mjs, bin/vuln-profile.mjs |
houseCss(), a Plex subset inlined |
Follower |
bin/top100-html.mjs, bin/render-report.mjs, bin/verdict-flow.mjs, bin/remediation-web.mjs, bin/cvd-palette.mjs report |
houseCss(), a Plex subset inlined; no script |
The OS |
we/public/ (commitwork-web repository) |
A house-css block with Plex Sans 400/600/700 and Mono 400/600 inlined (its CSP admits inline style, data: images and data: fonts); the panel's lockup as MARK_URI and SEAL_FAVICON images |
The OS |
admin/public/index.html |
A house-css block, no fonts; MARK_ICON as its tab icon. No route in this tree serves it and it declares no CSP, so a host that adds one must admit data: images |
The OS |
sitemap/mainline.html, sitemap/demo.html |
A house-css block, no fonts; scene tokens shared with the 3D view stay per mode |
Follower; mainline rebuilds its scene on a change |
A surface found departing from this document is recorded here, and a producer that declares its own
--bg, --ink or --acc meanwhile goes in the palette guard's pending list with the item number
(bin/test/house-palette-guard.test.mjs). Items 1 and 2 remain from the conformity audit of
2026-10-07; items 3 to 5 were found on 2026-10-08.
bin/vuln-profile.mjsseverity palette. It declares--s-crit,--s-high,--s-medand--s-lowwith values of its own, and--aff,--clean,--unprovenand--blockedbeside them, instead of aliasing the §3.3 and §3.4 tokens.map/generate.mjskeeps its sticky bar when embedded. Underhtml[data-embed]it hides the logo and title (.embed-hide), but the bar keeps its background and 3px accent border. The other embedded reports make theirs static and transparent.- The record pulse ignores reduced motion.
.rec.on(panel.css) animatesrecpulsewith noprefers-reduced-motionoverride. - Light lifts lower white-ink contrast. On a light ground
theme.css's hover lifts take white ink below their resting ratio:--cw-ok-lift4.33:1,--cw-caution-lift4.25:1 and--cw-stop-lift4.72:1, the first two under AA. - .625rem text outside the tag rule (§4.3).
.exp-tagis weight 400, lowercase and untracked;.t-tagpills carry no tracking, and one.t-tagline carries a row title at weight 400; the phone.scopeis weight 400. The Linear livery's.cw-lane__tis set at .62rem.