Skip to content
Metric VaultHelp Center
Open app

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:

Note

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#

ConcernThe one sourceNever
Tokens: color, space, radius, shadow, typecss/tokens.cssA second :root anywhere else
Result layout: hero, KPIs, sectionsThe .mvr-* Result Shell (css/mvr.css, css/mvr-premium.css)A bespoke per-tool layout
App chrome and canvascss/app.cssInline layout on a view
Legacy, frozencss/legacy-mvx.cssGrowing 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.

#LawRatchet rule
1No hardcoded color. Use a token.hardcoded-hex
2No inline style=. Use a component or utility class.inline-style
3No emoji in UI. Inline SVG only.emoji-in-ui
4No off-scale radius. var(--radius-*) only.offscale-radius
5No left-accent border stripes. Use an icon chip plus a tier badge.left-accent-border
6No 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.

GroupTokens
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-brand is #ffffff in both themes. The brand surface stays purple in both, so its text must stay light; using --text there turns the label near-black on purple in light mode. That pairing is why --brand is #7a5af7 and not the older #7c5cfc: white on the old value measured 4.38:1, just under AA, on every primary button.
  • --mv-scrim stays 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

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.

NeedComponentStatus in css/
Result frame.mvr wrapperPresent
Hero header.mvr-head, .mvr-fav, .mvr-idPresent
Metric row.mvr-kpis, .mvr-kpi, with -top, -label, -val, -subPresent
Insight callout.mvr-insightPresent
Buttons.mvr-cta, .mvr-ghost, .mvr-chipPresent
Badge and tier.mvr-tier with .good, .warn, .badPresent
Empty state.mvr-emptyPresent
TableThe constitution names .mvr-tbl; the real class is .mvr-table.mvr-tbl is not defined anywhere
Section cardThe constitution names .mvr-cardNot 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#

FamilyWhereStatus
.mvr-*css/mvr.css, css/mvr-premium.cssThe target system
.glass-card and .mvxcss/legacy-mvx.css, bottom of css/mvr.cssLegacy. 382 occurrences of glass-card remain in dashboard.html
.psi-*css/psi.css, rendered by js/mv-psi.jsSelf-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:

TokenLegacy valuetokens.css value
--radius16px12px
--radius-sm10px9px
--radius-xs8px6px
--gradientmagenta to purplepurple to magenta
--shadow-sm, --shadow-md, --shadow-lgpurple-tintedneutral-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#

text
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#

RuleWhat it matches
hardcoded-hexAny three or six digit hex literal
inline-styleAny style=" or style=' attribute
emoji-in-uiAny \p{Extended_Pictographic} character. Plain check and cross text glyphs are excluded as borderline
offscale-radiusEach 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-borderborder-left: <n>px solid
forked-token-defIn CSS files only: a definition of --brand, --brand-2, --radius*, --shadow-*, --space-N or --gradient
Note

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:

Filehexinline-styleemojiradiusleft-accentforked-token
dashboard.html237262462616120
chat-widget.js181201610
css/app.css185007900
css/legacy-mvx.css12471564973245511
css/mvr.css700600
css/mvr-premium.css600000
Total383578229910416811

That is 12,876 tracked violations, down from the audit's 14,672.

How to update the baseline#

text
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

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#

FailureSymptomFix
A count went upFAIL: new design debt was introduced (a tracked count went up): with per-file deltasReplace the hex with a token, the inline style with a class, the emoji with inline SVG, the radius with a scale value
No baseline fileExit 2, config: no baseline found.node tests/design-lint.mjs --update-baseline
Baseline updated to hide a failureThe gate passes, the debt is permanentRevert the baseline, fix the code
New CSS added to a scanned fileThe counts rise even though the CSS is clean elsewherePut 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 pagecss/tokens.css is shared by the marketing site and the appSay 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 cacheBump 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?