The design system
The six binding laws from the Design Constitution, the token vocabulary in css/tokens.css, and the design-lint ratchet that fails a build when any debt count rises.
Last updated 2026-08-06
Summary#
docs/DESIGN-CONSTITUTION.md is binding law, not a style guide. It exists because an audit in July 2026 found the app rendering like roughly fifty-five separate products: about thirty-nine of fifty-five tools shipped a bespoke renderer that hand-coded layout and color inline, tokens.css was being overridden by a forked :root in legacy-mvx.css, and the .mvr-* unification was only about 65 percent done.
The important part is that the same rules already existed as comments and were ignored. So the constitution is backed by a machine: tests/design-lint.mjs records a per-file, per-rule baseline and fails when any count goes up. Debt may hold steady or shrink. It may never grow.
Purpose#
The prime directive is one sentence, and everything else derives from it:
The user is using one application, not 55. Only the content of a result changes between tools, never the layout, spacing, type, color, or components. No user should ever be able to tell which engineer built which tool.
Every change must leave the codebase more consistent and with less debt than it found it. Adding a screen at the cost of the system is forbidden.
A ratchet rather than a gate, because the alternative does not work. The codebase carries thousands of pre-existing violations; a guard that failed on all of them would be red forever, and a permanently red guard gets disabled. A ratchet is red only when you made it worse, which is a signal a developer will act on.
Architecture#
Single sources of truth#
| Concern | The one source | Never |
|---|---|---|
| Tokens: color, space, radius, shadow, type | css/tokens.css | A second :root anywhere else |
| Result layout: hero, KPIs, sections | The .mvr-* Result Shell (css/mvr.css, css/mvr-premium.css) | A bespoke per-tool layout |
| App chrome and canvas | css/app.css | Inline layout on a view |
| Legacy, frozen | css/legacy-mvx.css | Growing it, ever |
legacy-mvx.css is in hospice. It is 1,050,377 bytes and 12,046 lines, lifted byte for byte out of the inline <style id="mv-legacy-css"> block so that CSS parsing and cascade order stayed identical. It may only get smaller, and the ratchet enforces that by tracking its counts alongside everything else.
The six laws#
Each law maps to one ratchet rule. These are reproduced verbatim from DESIGN-CONSTITUTION.md section 5.
| # | Law | Ratchet rule |
|---|---|---|
| 1 | No hardcoded color. Use a token. | hardcoded-hex |
| 2 | No inline style=. Use a component or utility class. | inline-style |
| 3 | No emoji in UI. Inline SVG only. | emoji-in-ui |
| 4 | No off-scale radius. var(--radius-*) only. | offscale-radius |
| 5 | No left-accent border stripes. Use an icon chip plus a tier badge. | left-accent-border |
| 6 | No forked tokens. Definitions live only in tokens.css. | forked-token-def |
Six further rules are equally non-negotiable but are not counted, because no regex expresses them. They are reviewed by humans: one page canvas (a centered .content), one result container, one FAB system, one modal and alert system, no decorative gradients, and no rainbow difficulty scales (use the single mvKeywordDifficultyColor).
Two consequences that catch people out:
- Per-tool accent colors are abolished. The audit found the same brand hue expressed as thirteen purples, eight pinks and nine greens. A tool does not get its own color; state gets a color.
- Renderers do not build frames. No renderer may emit
style=, a raw hex, an emoji, or a<div class="glass-card">. See Building a result renderer.
The token vocabulary#
css/tokens.css is 4,051 bytes, loaded first in <head>, and light-first: the :root block is the light theme and dark is opt-in through [data-theme="dark"]. Only the variables that actually differ are redeclared under dark, so radius, spacing and type are shared between themes.
| Group | Tokens |
|---|---|
| Surfaces | --bg, --bg-2, --panel |
| Text | --text, --text-2, --text-3, --muted, --muted-soft |
| Semantic accents | --purple, --purple-bright, --magenta, --magenta-bright, --cyan, --cyan-bright, --gold, --green, --green-bright, --amber, --red |
| Lines and surfaces | --border, --border-strong, --hairline, --card |
| Brand | --brand #7a5af7, --brand-2 #d946a8, --gradient |
| Radius | --radius-xs 6, --radius-sm 9, --radius 12, --radius-lg 16, --radius-xl 20, --radius-pill 999 |
| Elevation | --shadow-sm, --shadow-md, --shadow-lg, mapped to flat, raised, floating and overlay |
| Spacing | --space-1 4px through --space-10 40px, on a 4px grid. There is no --space-7 or --space-9 |
| Type | --font (body), --font-heading (display), --font-mono (code) |
Two tokens are deliberately not redeclared per theme, and the file says why:
--on-brandis#ffffffin both themes. The brand surface stays purple in both, so its text must stay light; using--textthere turns the label near-black on purple in light mode. That pairing is why--brandis#7a5af7and not the older#7c5cfc: white on the old value measured 4.38:1, just under AA, on every primary button.--mv-scrimstays dark in both themes because its job is to dim the page, but it is lighter in light mode.
--purple carries the same value as --brand and is a fill, not an ink. On a dark panel it is too dim to read as text, so purple TEXT uses --purple-bright, which is defined per theme (deeper in light, lighter in dark). Reaching for --purple to colour a label is the most common way a new contrast failure gets in, and tests/contrast-audit.mjs measures both themes, so it is also the most common way a deploy gets blocked.
The scale numbers behind those tokens are the audit's findings: 344 distinct shadows, 40 font sizes, more than 40 font stacks including three different serifs, and 766 off-scale radii. Spacing is a 4px grid only, so 11px, 13px, 15px, 22px and 26px are all wrong.
Note: the constitution's color table quotes the dark theme values for green, amber, red and cyan. The light :root values differ, chosen for contrast on a light ground. Both are correct; the table simply quotes one theme. tokens.css is the authority.
tokens.css also carries a transitional caveat in its own header: the same color tokens are still defined inline in the dashboard's first style block, and because that inline :root loads after this file it currently wins. The values are identical, so there is no visual difference, and the duplicates are to be removed in a later, separately verified step.
Components#
The .mvr-* layer#
The Result Shell owns the frame. Renderers use the primitives rather than rebuilding heroes, KPI rows, cards, badges or buttons from raw markup.
| Need | Component | Status in css/ |
|---|---|---|
| Result frame | .mvr wrapper | Present |
| Hero header | .mvr-head, .mvr-fav, .mvr-id | Present |
| Metric row | .mvr-kpis, .mvr-kpi, with -top, -label, -val, -sub | Present |
| Insight callout | .mvr-insight | Present |
| Buttons | .mvr-cta, .mvr-ghost, .mvr-chip | Present |
| Badge and tier | .mvr-tier with .good, .warn, .bad | Present |
| Empty state | .mvr-empty | Present |
| Table | The constitution names .mvr-tbl; the real class is .mvr-table | .mvr-tbl is not defined anywhere |
| Section card | The constitution names .mvr-card | Not defined in any css/ file |
There are 103 distinct .mvr-* class names across css/mvr.css and css/mvr-premium.css. If a component you need is missing, add it to mvr.css once. Do not inline it in a renderer. If two tools need it, it belongs in the shared layer.
.mvr-card and .mvr-tbl are documented in the constitution but were never implemented, and there are stray uses of both in dashboard.html that style nothing. Treat that table row as a plan, not a fact.
Three result families still coexist#
| Family | Where | Status |
|---|---|---|
.mvr-* | css/mvr.css, css/mvr-premium.css | The target system |
.glass-card and .mvx | css/legacy-mvx.css, bottom of css/mvr.css | Legacy. 382 occurrences of glass-card remain in dashboard.html |
.psi-* | css/psi.css, rendered by js/mv-psi.js | Self-contained on purpose, single-theme light to match Google |
css/mvr.css deliberately overrides the legacy .kw-table and .data-table rules, and it can do that only because it is linked after legacy-mvx.css. It also neutralises .mvx's pure-black --bg in dark mode, which otherwise makes every result render inside a black box.
The eleven forked token definitions#
The fork lives in legacy-mvx.css and is scoped to .mvx rather than :root, but its values differ from tokens.css, so a component rendered inside a .mvx wrapper gets different geometry:
| Token | Legacy value | tokens.css value |
|---|---|---|
--radius | 16px | 12px |
--radius-sm | 10px | 9px |
--radius-xs | 8px | 6px |
--gradient | magenta to purple | purple to magenta |
--shadow-sm, --shadow-md, --shadow-lg | purple-tinted | neutral-tinted |
Those definitions repeat inside the file's dark block, which is why the tracked count is eleven rather than seven. Getting that count to zero is Phase 0 of the migration, and the constitution is explicit that nothing else matters until tokens.css actually wins.
Data flow#
How the ratchet works#
node tests/design-lint.mjs
├── read tests/design-lint-baseline.json
├── recount all 6 rules across the 6 TARGET files
├── any count HIGHER than baseline → exit 1, print per-file deltas
├── any count LOWER than baseline → print "Debt reduced", exit 0
└── otherwise → "PASS: no design-debt counts increased. The ratchet holds."Exit codes: 0 when nothing increased, or for --summary and --update-baseline; 1 when at least one file and rule increased; 2 for a config error, which in practice means no baseline file.
The six scanned targets are dashboard.html, chat-widget.js, css/app.css, css/legacy-mvx.css, css/mvr.css and css/mvr-premium.css. css/tokens.css is deliberately not scanned, because it is the one place raw hex and token definitions belong. Every other file in css/ and js/ is also outside the target set and follows the rules by convention; each states so in its own header comment, which is how css/psi.css keeps Google's palette and css/social.css keeps six real logo colors without either becoming a second token source.
How each rule counts#
| Rule | What it matches |
|---|---|
hardcoded-hex | Any three or six digit hex literal |
inline-style | Any style=" or style=' attribute |
emoji-in-ui | Any \p{Extended_Pictographic} character. Plain check and cross text glyphs are excluded as borderline |
offscale-radius | Each border-radius declaration is parsed and its px values checked against {6, 9, 12, 16, 20}. Values of 999 or more are skipped, because that is the pill idiom |
left-accent-border | border-left: <n>px solid |
forked-token-def | In CSS files only: a definition of --brand, --brand-2, --radius*, --shadow-*, --space-N or --gradient |
Note: the forked-token rule does not include color names. That is why css/app.css scores forked-token-def: 0 despite re-declaring :root with a whole dark palette, which is a genuine second source of truth for color. It is a known, grandfathered exception, not an oversight in the file.
The current baseline#
tests/design-lint-baseline.json, verbatim:
| File | hex | inline-style | emoji | radius | left-accent | forked-token |
|---|---|---|---|---|---|---|
dashboard.html | 2372 | 6246 | 2 | 616 | 12 | 0 |
chat-widget.js | 18 | 12 | 0 | 16 | 1 | 0 |
css/app.css | 185 | 0 | 0 | 79 | 0 | 0 |
css/legacy-mvx.css | 1247 | 1564 | 97 | 324 | 55 | 11 |
css/mvr.css | 7 | 0 | 0 | 6 | 0 | 0 |
css/mvr-premium.css | 6 | 0 | 0 | 0 | 0 | 0 |
| Total | 3835 | 7822 | 99 | 1041 | 68 | 11 |
That is 12,876 tracked violations, down from the audit's 14,672.
How to update the baseline#
node tests/design-lint.mjs --summary # print counts, never fails
node tests/design-lint.mjs # the gate
node tests/design-lint.mjs --update-baseline # lock in a reduction--update-baseline rewrites tests/design-lint-baseline.json with the current counts and prints Baseline written: tests/design-lint-baseline.json plus the new total.
Only run it after a genuine reduction. Running it to clear a failure records your new debt as acceptable and defeats the entire mechanism. The correct response to a failure is to fix the code. The constitution's definition of done requires the baseline to be re-recorded when debt falls, and section 6 asks for a re-baseline after each conversion.
Warning: the constitution states that CI runs the lint via .github/workflows/design-lint.yml and that a pre-commit hook lives at .githooks/pre-commit. Neither exists. The workflows directory contains only cron.yml and deploy.yml, and there is no .githooks directory. The lint is run by hand, or by an agent following CLAUDE.md. Nothing stops an unchecked commit but you.
Migration order#
Phase 0 makes tokens authoritative by deleting or aliasing the forked :root in legacy-mvx.css, targeting forked-token-def: 0. Phase 1 builds the Shell function. Phase 2 converts bespoke tools, highest friction first: the AI-intel family (prompt_tracking, prompt_research, brand_performance, ai_questions, media_monitoring, ai_competitor_research), then the mv-* namespaces, then the six forced-dark .mvx workflow islands. Phase 3 centers the canvas, unifies the result container and the FAB systems, and deletes dead rules from the legacy sheet.
Failure modes#
| Failure | Symptom | Fix |
|---|---|---|
| A count went up | FAIL: new design debt was introduced (a tracked count went up): with per-file deltas | Replace the hex with a token, the inline style with a class, the emoji with inline SVG, the radius with a scale value |
| No baseline file | Exit 2, config: no baseline found. | node tests/design-lint.mjs --update-baseline |
| Baseline updated to hide a failure | The gate passes, the debt is permanent | Revert the baseline, fix the code |
| New CSS added to a scanned file | The counts rise even though the CSS is clean elsewhere | Put new component CSS in its own file in css/. That is why notifhub.css and merge-tabs.css exist as separate files |
| A token change looks wrong on another page | css/tokens.css is shared by the marketing site and the app | Say so before editing, and scope page-specific changes to that page's own stylesheet |
| A shipped style change does not appear | /css/* is served with a four-hour cache | Bump the ?v= on the link, and bump CACHE_NAME in sw.js |
The definition of done for any change that touches UI: the lint passes, new UI uses .mvr-* components and tokens only, a touched bespoke tool moved toward the Shell, the baseline was updated if debt fell, legacy-mvx.css did not grow, and the commit message explains why the change improves the system rather than only that it works.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.