diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 7bcf356bd..2869e1953 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -1,6 +1,35 @@ @import "tailwindcss"; @import "@threadplane/design-tokens/theme.css"; +/* + * Local, non-token constants. + * + * These are deliberately NOT design tokens. Promoting them to + * @threadplane/design-tokens would imply the design system owns the syntax + * theme and the docs figure treatment. It does not. + * + * The --docs-code-* group is coupled to `rehypeOptions.theme` ('tokyo-night') + * in components/docs/MdxRenderer.tsx. Change the shiki theme and these must + * change with it. + * --docs-code-title-fg is here rather than on --color-text-muted for the same + * reason: it sits on a dark surface, where the light-theme muted token drops + * to 3.6:1 contrast. + */ +:root { + --docs-code-bg: #1a1b26; + --docs-code-title-fg: #8b8fa3; + --docs-code-border: rgba(0, 0, 0, 0.1); + --docs-code-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + --docs-code-title-rule: rgba(255, 255, 255, 0.06); + --docs-figure-shadow: 0 4px 16px rgba(0, 32, 72, 0.1); + --docs-figure-shadow-bare: 0 4px 16px rgba(0, 32, 72, 0.08); + /* Accent tints in the 3.5–10% range, around --color-accent-surface (6%). + * Derived rather than hardcoded so they track the accent. */ + --docs-accent-tint-faint: color-mix(in srgb, var(--color-accent) 3.5%, transparent); + --docs-accent-tint-soft: color-mix(in srgb, var(--color-accent) 8%, transparent); + --docs-accent-tint-line: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + * { box-sizing: border-box; } @@ -55,7 +84,7 @@ html { /* Shiki code blocks — tokyo-night theme */ .shiki { padding: 1.5rem; - background: #1a1b26 !important; + background: var(--docs-code-bg) !important; overflow-x: auto; } .shiki code { @@ -74,8 +103,8 @@ html { .docs-prose [data-rehype-pretty-code-figure] pre { padding: 1.25rem 1.5rem; border-radius: 0.75rem; - border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + border: 1px solid var(--docs-code-border); + box-shadow: var(--docs-code-shadow); overflow-x: auto; font-size: 0.8rem; line-height: 1.7; @@ -97,10 +126,10 @@ html { .docs-prose [data-rehype-pretty-code-figure] [data-rehype-pretty-code-title] { font-family: var(--font-mono), monospace; font-size: 0.7rem; - color: #8b8fa3; + color: var(--docs-code-title-fg); padding: 0.5rem 1.5rem; - background: #1a1b26; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: var(--docs-code-bg); + border-bottom: 1px solid var(--docs-code-title-rule); border-radius: 0.75rem 0.75rem 0 0; } @@ -111,8 +140,8 @@ html { .docs-prose :not(pre) > code { font-family: var(--font-mono), monospace; font-size: 0.85em; - background: rgba(0, 64, 144, 0.06); - color: #004090; + background: var(--color-accent-surface); + color: var(--color-accent); padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-weight: 400; @@ -136,7 +165,7 @@ html { .docs-prose ol { list-style-type: decimal; } .docs-prose li { margin-bottom: 0.5rem; line-height: 1.6; } .docs-prose li > p { margin-bottom: 0.5rem; } -.docs-prose li::marker { color: var(--color-text-muted, #555770); } +.docs-prose li::marker { color: var(--color-text-muted); } .docs-prose ul ul, .docs-prose ol ol, .docs-prose ul ol, .docs-prose ol ul { margin-top: 0.5rem; margin-bottom: 0.5rem; @@ -151,8 +180,8 @@ html { .docs-prose figure:has(> img) { margin: 2.5rem 0; padding: 0.75rem 0.75rem 0; - background: rgba(0, 64, 144, 0.035); - border: 1px solid rgba(0, 64, 144, 0.1); + background: var(--docs-accent-tint-faint); + border: 1px solid var(--docs-accent-tint-line); border-radius: 0.75rem; } .docs-prose figure:has(> img) > img { @@ -160,14 +189,14 @@ html { width: 100%; height: auto; border-radius: 0.5rem; - box-shadow: 0 4px 16px rgba(0, 32, 72, 0.1); + box-shadow: var(--docs-figure-shadow); } .docs-prose figure:has(> img) > figcaption { margin: 0; padding: 0.875rem 0.5rem 0.625rem; font-size: 0.875rem; line-height: 1.5; - color: var(--color-text-muted, #555770); + color: var(--color-text-muted); text-align: center; font-style: italic; } @@ -179,7 +208,7 @@ html { height: auto; margin: 2rem auto; border-radius: 0.5rem; - box-shadow: 0 4px 16px rgba(0, 32, 72, 0.08); + box-shadow: var(--docs-figure-shadow-bare); } /* Architecture diagrams are authored at exactly the width `.docs-prose` @@ -192,8 +221,8 @@ html { .docs-table-scroll { max-width: 100%; overflow-x: auto; margin: 1.5rem 0; } .docs-prose table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0; } -.docs-prose th { text-align: left; padding: 0.5rem 0.75rem; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; color: #555770; border-bottom: 1px solid rgba(0, 64, 144, 0.15); } -.docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(0, 64, 144, 0.08); color: #555770; } +.docs-prose th { text-align: left; padding: 0.5rem 0.75rem; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; color: var(--color-text-muted); border-bottom: 1px solid var(--color-accent-border); } +.docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--docs-accent-tint-soft); color: var(--color-text-secondary); } .docs-prose td code { font-size: 0.8em; } /* UI primitive — Card. diff --git a/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md new file mode 100644 index 000000000..f9c3351de --- /dev/null +++ b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md @@ -0,0 +1,271 @@ +# Docs visual review — findings + +**Date:** 2026-08-29 +**Status:** Findings captured. Fixes deferred to Project 3 of the substrate arc +(see `2026-08-29-design-token-css-var-completion-design.md` for the decomposition). + +This is the evidence log for a visual/usability review of the docs site. Every +item below was measured against a live dev server at 1280px, 768px, 375px, and +320px — not read off the source. Numbers are reproducible with the probe +described at the end. + +The fixes are **not** in this document's scope. They land after the token work +and the substrate migration, because roughly half of them (focus rings, +`:last-child`, media queries) cannot be expressed in the inline `style={{}}` +objects the components use today. + +--- + +## 1. `position: sticky` is dead site-wide + +The docs sidebar and the "On this page" TOC are both written as sticky rails. +Neither has ever stuck. + +At `scrollY = 3000` on `/docs/chat/components/chat` (1280×800): + +| element | `getBoundingClientRect().top` | expected | +|---|---|---| +| `DocsSidebar` | `-2920` | `80` | +| `DocsTOC` | `-2840` | `80` | + +Both scroll entirely off-screen. The reader loses navigation and the page +outline the moment they start reading. + +**Cause — two independent scroll containers, both must be removed:** + +1. `body { overflow-x: hidden }` — [`global.css:18`](../../../apps/website/src/app/global.css). When `html` + *and* `body` both set `overflow-x`, `html`'s value propagates to the viewport + and `body` keeps its own, which makes `` a scroll container. Sticky + descendants then position against ``, which never scrolls. +2. `overflow-x-hidden` on the docs shell — + [`page.tsx:98`](../../../apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx). + Same mechanism, one level down. + +Verified by elimination: clearing **both** at runtime pins both rails at +`top: 80` and holds it at `scrollY = 3000` and `5000`. Clearing only one leaves +them broken. `html { overflow-x: hidden }` alone does *not* break sticky — +`html`'s value propagates to the viewport and `html` itself computes to +`visible` — but this needs re-verification at implementation time rather than +being taken on faith. + +**Secondary defect.** `DocsSidebar` is `height: 10030px` — it stretches to the +article's full height because the parent flex row defaults to +`align-items: stretch` and the aside sets no `align-self`. Its +`overflow-y: auto` therefore never engages (`scrollHeight === clientHeight`). +Needs `align-self: flex-start` plus a `max-height` before the sticky fix has any +useful effect on long library sidebars. + +## 2. The `overflow-x` guards are masking real overflow + +They cannot simply be deleted. With both guards disabled, five pages overflow +the viewport for real: + +| page | viewport | actual `scrollWidth` | culprit | +|---|---|---|---| +| `/docs/langgraph/getting-started/quickstart` | 768 | **864** (+96) | `` body | +| same | 320 | **832** (+512) | same | +| `/docs/ag-ui/getting-started/quickstart` | 320 | 740 (+420) | same | +| `/`, `/pilot-to-prod`, `/solutions` | 375 | 421 (+46) | `.wp-grid` "Field report" card, fixed `392px` | +| `/` | 320 | 421 (+101) | `.why-row__body` + hero `demo.threadplane.ai` box | +| `/blog` | 320 | 324 (+4) | post cards, hard-coded `width: 300px` | +| `/about` | 320 | 329 (+9) | unbroken GitHub URL in an `` | + +**The `` culprit, precisely.** Ancestor chain measured at a 768px +viewport, walking down from `.docs-prose`: + +``` +DIV w=672 display:flex min-width:0px ← Steps wrapper, correctly constrained +DIV w=672 display:flex min-width:auto ← Step row +DIV w=772 display:block flex:1 1 0% min-width:auto ← Step body: OVERFLOWS +``` + +[`Steps.tsx`](../../../apps/website/src/components/docs/mdx/Steps.tsx) sets +`style={{ flex: 1, paddingBottom: 8 }}` on the step body. `flex: 1` leaves +`min-width: auto`, so the item refuses to shrink below its content's min-content +width and blows 100px past its 672px container. Fix is `min-width: 0` on the +flex item. + +Docs pages are otherwise clean at 320px. A sweep found 300 elements exceeding +the viewport on `/docs/chat/components/chat`, and **all 300 are shiki token +spans inside `
`** — correctly contained by their own `overflow-x: auto`
+scrollers, not page-level overflow. `documentElement.scrollWidth` stayed at
+320.
+
+## 3. Breadcrumb vertical alignment
+
+In [`DocsBreadcrumb.tsx`](../../../apps/website/src/components/docs/DocsBreadcrumb.tsx)
+the `crumb` style object is applied to the ``, not to the `
  • ` — but the +`/` separator `` is a sibling of the link, inside the `
  • `. The first +two crumbs therefore leave both the `
  • ` and the separator inheriting body +typography. + +Measured on `/docs/chat/components/chat`: + +| crumb | `li` font-size | separator font-size | separator `top` | link `top` | +|---|---|---|---|---| +| Docs | **16px** / 24px | **16px** | 106 | 109 | +| Chat | **16px** / 24px | **16px** | 106 | 109 | +| Components | 13px / 19.5px | 13px | 106 | — | +| ChatComponent | 13px / 19.5px | — | — | — | + +Two visible defects: the first two separators render **3px larger** than the +third, and every separator sits **3px above** the link text beside it. The +`
      ` computes `align-items: normal`, so nothing re-centers them. + +## 4. Tables do not scroll, and are unreadable on mobile + +`.docs-table-scroll` exists and sets `overflow-x: auto`, but +`.docs-prose table { width: 100% }` with no `min-width` means the table always +fits its container. Measured at 375px on the `ChatComponent` inputs table: + +``` +wrapper: width 343 scrollWidth 343 clientWidth 343 ← never scrolls +table: width 343 +columns: 49px | 81px | 75px | 138px +first row height: 227px tallest cell: 290px +``` + +The rendered result: `agent` breaks across three lines as `ag` / `en` / `t`, the +`INPUT` header becomes `INP` / `UT`, and `undefined` becomes `undefi` / `ned`. A +single props row is taller than half a phone screen. + +Related: `ApiDocRenderer` and `ApiRefTable` render tables with **no scroll +wrapper at all**. + +Related: `.docs-prose { word-break: break-word }` splits inline `code` chips +mid-token — `@threadplane/langgraph` renders as two separately-backgrounded +pills reading `@threadplane/lan` and `ggraph`. + +## 5. Every deep link lands under the fixed nav + +There is no `scroll-margin-top` or `scroll-padding-top` anywhere in the +codebase — `grep` across `apps/website/src` returns nothing. + +Jumping to `#message-templates` on `/docs/chat/components/chat`: + +``` +heading top: 0 +nav bottom: 81 +``` + +The heading and roughly 45px of following body text sit behind the fixed nav. +This affects every TOC click, every heading-anchor click, and every shared +anchor URL. + +## 6. Mobile layout defects + +Measured at 375×812 on `/docs/chat/components/chat`: + +- **22px of dead space.** Nav height is `58px`; the docs shell hard-codes + `paddingTop: 80`. The 80 matches desktop (`81px`), not mobile. +- **8px rail misalignment.** The breadcrumb and page-header wrapper use + `px-6` (`left: 24`); the `
      ` uses `px-4 sm:px-6 md:px-12` + (`left: 16`). The H1 and all body copy sit 8px left of the breadcrumb above + them, on every docs page below 640px. The API block and prev/next use a third + value. +- **Heading anchors are `display: none`** below 768px + ([`global.css:321`](../../../apps/website/src/app/global.css)), so there is no + way to copy a deep link from a phone. +- **Touch targets below 44px:** `PageActions` trigger is 32×32; the code-block + copy button is 28×28. +- The `AnnouncementToast` is `width: calc(100vw - 48px)` capped at 360 — 327px + of a 375px screen. + +## 7. Component detail and accessibility + +- **`` draws its connector below the last step.** The vertical rule is + rendered unconditionally per step, so the final step trails a dangling line. + Needs `:last-child`, which inline styles cannot express. +- **`` has no tab semantics** — no `role="tablist"` / `role="tab"` / + `role="tabpanel"`, no `aria-selected`, no arrow-key navigation, and the tab + bar does not scroll horizontally when labels overflow a narrow screen. +- **`DocsSearch`** is ⌘K-only with no mobile entry point (the trigger lives in + the desktop-only sidebar). The overlay has no `role="dialog"`, + no `aria-modal`, no focus trap, no focus restoration, no `listbox`/`option` + roles, and the keyboard-selected result is not scrolled into view. +- **`PageActions`** menu has no roving focus or arrow-key navigation, and its + items have no hover or focus styling. +- **Code blocks scroll but advertise nothing.** 11 of them on the + `ChatComponent` page have `scrollWidth > clientWidth` at 375px with no edge + fade or other affordance. Scrollable regions are also not keyboard-focusable + (WCAG 2.1.1). +- **No `prefers-reduced-motion` guard on `html { scroll-behavior: smooth }`.** + +## 8. Token drift already present in `global.css` + +`global.css` hardcodes values from the stale `--ds-*` surface that disagree with +the live `light.ts` tokens: + +| literal | occurrences | live token | live value | +|---|---|---|---| +| `#555770` | 4 (lines 139, 170, 195, 196) | `--color-text-secondary` | `rgb(70, 70, 70)` | +| `#8b8fa3` | 1 (line 100) | `--color-text-muted` | `rgb(115, 115, 115)` | +| `#004090` | 1 (line 115) | `--color-accent` | same value | +| `rgba(0, 64, 144, 0.06)` | 1 (line 114) | `--color-accent-surface` | same value | +| `rgba(0, 64, 144, 0.15)` | 1 (line 195) | `--color-accent-border` | same value | + +The first two are genuine colour drift, not just un-tokenised literals: docs +table text and code-block titles render in a blue-grey that no current token +produces. Resolving these is Project 1 scope, and it is a **visible** change, +not a refactor. + +## 9. Two rules in `global.css` are dead CSS + +Found while verifying the token work on 2026-08-29, not during the original +review. Measured on `/docs/langgraph/getting-started/quickstart` and +`/blog/langgraph-subgraphs-when-to-split`: + +``` +.shiki elements: 0 +[data-rehype-pretty-code-title] elements: 0 +[data-rehype-pretty-code-figure]: 5 +``` + +Both selectors match nothing, on docs **and** blog: + +- **`.shiki`** (`global.css:56-65`) — `rehype-pretty-code` is configured with + `keepBackground: true`, so it writes the theme background as an *inline + style* on the `
      ` (`style="background-color:#1a1b26;color:#a9b1d6"`)
      +  rather than emitting a `.shiki` class. The inline style is where the code
      +  background actually comes from.
      +- **`[data-rehype-pretty-code-title]`** (`global.css:97-109`, plus the
      +  `:has()` companion rule) — no code fence in `content/docs` or `content/blog`
      +  uses the `title=` meta, so the element is never generated.
      +
      +Consequences worth knowing:
      +
      +- The sibling rule `.docs-prose [data-rehype-pretty-code-figure] pre` **is**
      +  live — verified, its border computes to `rgba(0, 0, 0, 0.1)` and its shadow
      +  to `rgba(0, 0, 0, 0.08) 0px 2px 12px`.
      +- This narrows the visible surface of the token adoption: changing the
      +  code-title colour from `#8b8fa3` to `var(--color-text-muted)` is correct but
      +  renders nowhere today.
      +
      +**Not fixed here.** Deleting dead rules is a cleanup, not a literal audit, and
      +the `.shiki` rule would become live again if `keepBackground` were turned off.
      +It belongs in the polish arc alongside the rest of the docs CSS work.
      +
      +---
      +
      +## Reproducing
      +
      +The measurements above came from a headless probe run against
      +`nx serve website`. Scripts were written to `tmp/probe/` (gitignored) and are
      +not committed; the method is:
      +
      +1. Inject `html, body { overflow-x: visible !important }` and
      +   `[class*="overflow-x-hidden"] { overflow-x: visible !important }` to unmask
      +   real overflow.
      +2. Compare `document.documentElement.scrollWidth` against `innerWidth` —
      +   that, not per-element rects, is the authority on page overflow.
      +3. For offenders, collect elements whose `right > innerWidth`, filter out any
      +   with a `pre` / `.docs-table-scroll` / `.shiki` ancestor (correctly
      +   contained), then keep only those with no offending child to find the leaf.
      +4. For sticky, read `getBoundingClientRect().top` after `window.scrollTo` at
      +   two different offsets — a sticky element reports the same `top` at both.
      +
      +Both the overflow assertion and the sticky assertion **fail silently if written
      +wrong** (an empty offender list and an off-screen `top` both look like a pass
      +under a careless predicate). Any regression test built from this must be
      +mutation-tested — break the fix, confirm the test goes red — before it is
      +trusted. See `feedback_tests_that_pass_vacuously`.
      diff --git a/docs/superpowers/plans/2026-08-29-design-token-css-var-completion.md b/docs/superpowers/plans/2026-08-29-design-token-css-var-completion.md
      new file mode 100644
      index 000000000..4acfa1a0b
      --- /dev/null
      +++ b/docs/superpowers/plans/2026-08-29-design-token-css-var-completion.md
      @@ -0,0 +1,1201 @@
      +# Design Token CSS-Var Completion Implementation Plan
      +
      +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
      +
      +**Goal:** Make every design token the website consumes reachable from CSS at a value identical to its JS counterpart, verified by a test rather than assumed.
      +
      +**Architecture:** `libs/design-tokens` generates `theme.css` from TypeScript token sources via a committed generator with a drift guard. That generator currently emits only colors, fonts, radii, and shadows. We extend it to also emit the type scale (as Tailwind v4 composite `--text-*` tokens) and the space scale, bring the orphaned `tokens.css` under the same generator so it can no longer drift, and add a parity spec that walks the token tree and fails if any leaf lacks a CSS counterpart or is not explicitly excluded. Finally we replace the hardcoded color literals in the website's `global.css`.
      +
      +**Tech Stack:** TypeScript, Node `tsx` scripts, Vitest, Nx, Tailwind CSS v4 (`@theme`), Next.js 16.
      +
      +**Spec:** `docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md`
      +**Findings this unblocks:** `docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md`
      +
      +---
      +
      +## Context an engineer needs before starting
      +
      +**This library has an unusual guard you must not fight.** `theme.css` is a
      +*generated, committed* file. `generate-theme-css.spec.ts` re-runs the generator
      +in-process and asserts the output is byte-identical to the committed file. So
      +the loop for any generator change is always:
      +
      +1. Edit `scripts/generate-theme-css.ts`.
      +2. Run `npx nx run design-tokens:generate-theme-css` to rewrite the committed CSS.
      +3. Run the tests.
      +
      +If you hand-edit `theme.css`, the drift guard goes red and the fix is to
      +regenerate, never to edit the guard.
      +
      +**Commands.** Run everything from the workspace root.
      +
      +- Library tests: `npx nx test design-tokens`
      +- A single spec: `cd libs/design-tokens && npx vitest run src/lib/.spec.ts --config vite.config.mts`
      +  — **must run from the library directory.** The config's `include` is
      +  `src/**/*.spec.ts`, resolved against the config's own root, so invoking it
      +  from the workspace root reports "No test files found" and exits 1, which
      +  reads like a broken spec but is a wrong cwd.
      +- Regenerate CSS: `npx nx run design-tokens:generate-theme-css`
      +- Website tests: `cd apps/website && npx vitest run --config vite.config.mts`
      +  — **the website has no `nx test` target**; `nx test website` fails, and 20+
      +  specs silently stopped running once because of it. Never use it.
      +- Website dev server: use the Browser pane / `preview_start` with the
      +  `website-dev` entry in `.claude/launch.json`. Never `npm run dev` in Bash.
      +
      +**Values you will need.** These are the current TS sources; the generator reads
      +them, you should not retype them into CSS by hand.
      +
      +```ts
      +// libs/design-tokens/src/lib/typography.ts (excerpt)
      +h1:      { size: 'clamp(48px, 6vw, 72px)',  line: 1.08, family: 'var(--font-garamond)' }
      +h2:      { size: 'clamp(36px, 4.5vw, 56px)', line: 1.12, family: 'var(--font-garamond)' }
      +h3:      { size: '28px', line: 1.25, family: 'var(--font-inter)', weight: 600 }
      +eyebrow: { size: '12px', line: 1.4, family: 'var(--font-mono)', weight: 700,
      +           letterSpacing: '0.12em', transform: 'uppercase' }
      +bodyLg:  { size: '20px', line: 1.6, family: 'var(--font-inter)' }
      +body:    { size: '16px', line: 1.6, family: 'var(--font-inter)' }
      +caption: { size: '14px', line: 1.5, family: 'var(--font-inter)' }
      +
      +// libs/design-tokens/src/lib/space.ts
      +sectionY: 'clamp(64px, 8vw, 120px)'   sectionYTight: 'clamp(48px, 6vw, 80px)'
      +containerX: 'clamp(20px, 4vw, 40px)'  containerMax: '1200px'
      +```
      +
      +---
      +
      +## File Structure
      +
      +| File | Responsibility |
      +|---|---|
      +| `libs/design-tokens/scripts/generate-theme-css.ts` | Modify. Owns *both* generated stylesheets. The type and space scales are appended inside the existing `buildThemeBlock()`; `tokens.css` gets its own `buildTokensBlock()` + exported `generateTokensCss()`. |
      +| `libs/design-tokens/src/lib/theme.css` | Regenerated. Tailwind `@theme` block. |
      +| `libs/design-tokens/src/lib/tokens.css` | Regenerated. Plain `:root { --ds-* }` for non-Tailwind consumers. |
      +| `libs/design-tokens/src/lib/generate-theme-css.spec.ts` | Modify. Drift guard — extended to cover `tokens.css`. |
      +| `libs/design-tokens/src/lib/ds-var-contract.spec.ts` | Create. Asserts the `--ds-*` names consumers reference never disappear. |
      +| `libs/design-tokens/src/lib/token-css-parity.spec.ts` | Create. The load-bearing guard: every token leaf has a CSS var of identical value, or is explicitly excluded. |
      +| `libs/design-tokens/package.json` | Modify. Export `./tokens.css`. |
      +| `libs/design-tokens/project.json` | Modify. Copy `tokens.css` in the build `assets`. |
      +| `apps/website/src/app/global.css` | Modify. Replace 18 hardcoded literals across three categories. |
      +
      +The two new specs are separate files on purpose: they guard different
      +contracts (JS↔CSS value parity vs. consumer-facing var-name stability) and
      +should be able to fail independently with a clear message.
      +
      +---
      +
      +## Task 1: Parity spec — the guard that makes Project 2 safe
      +
      +This is the load-bearing deliverable. Write it first, watch it go red for the
      +right reason, then make it green in Tasks 2 and 3.
      +
      +**Files:**
      +- Create: `libs/design-tokens/src/lib/token-css-parity.spec.ts`
      +
      +- [ ] **Step 1: Write the failing test**
      +
      +Create `libs/design-tokens/src/lib/token-css-parity.spec.ts`:
      +
      +```ts
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +import { tokens } from './tokens';
      +
      +const THEME_CSS = resolve(__dirname, 'theme.css');
      +
      +/**
      + * Every leaf in the `tokens` tree must either map to a CSS custom property in
      + * theme.css (holding an identical value) or appear in EXCLUDED with a reason.
      + *
      + * This is the premise the inline-style migration rests on: `tokens.X.Y` and
      + * `var(--z)` are interchangeable. Do not weaken this test to make a new token
      + * pass — add the token to the generator, or exclude it here with a comment
      + * saying why it can never have a var.
      + */
      +const CSS_VAR_BY_PATH: Record = {
      +  // Brand (theme-invariant)
      +  'brand.accent': '--color-accent',
      +  'brand.accentLight': '--color-accent-light',
      +  'brand.angularRed': '--color-angular-red',
      +  'brand.renderGreen': '--color-render-green',
      +  'brand.chatPurple': '--color-chat-purple',
      +
      +  // Font families
      +  'typography.fontSerif': '--font-garamond',
      +  'typography.fontSans': '--font-inter',
      +  'typography.fontMono': '--font-mono',
      +
      +  // Type scale — size
      +  'typography.h1.size': '--text-h1',
      +  'typography.h2.size': '--text-h2',
      +  'typography.h3.size': '--text-h3',
      +  'typography.eyebrow.size': '--text-eyebrow',
      +  'typography.bodyLg.size': '--text-body-lg',
      +  'typography.body.size': '--text-body',
      +  'typography.caption.size': '--text-caption',
      +
      +  // Type scale — line height
      +  'typography.h1.line': '--text-h1--line-height',
      +  'typography.h2.line': '--text-h2--line-height',
      +  'typography.h3.line': '--text-h3--line-height',
      +  'typography.eyebrow.line': '--text-eyebrow--line-height',
      +  'typography.bodyLg.line': '--text-body-lg--line-height',
      +  'typography.body.line': '--text-body--line-height',
      +  'typography.caption.line': '--text-caption--line-height',
      +
      +  // Type scale — weight / tracking
      +  'typography.h3.weight': '--text-h3--font-weight',
      +  'typography.eyebrow.weight': '--text-eyebrow--font-weight',
      +  'typography.eyebrow.letterSpacing': '--text-eyebrow--letter-spacing',
      +
      +  // Space scale
      +  'space.sectionY': '--spacing-section-y',
      +  'space.sectionYTight': '--spacing-section-y-tight',
      +  'space.containerX': '--spacing-container-x',
      +  'space.containerMax': '--container-page',
      +
      +  // Radii
      +  'radius.sm': '--radius-sm',
      +  'radius.md': '--radius-md',
      +  'radius.lg': '--radius-lg',
      +  'radius.xl': '--radius-xl',
      +  'radius.full': '--radius-full',
      +
      +  // Shadows
      +  'shadows.sm': '--shadow-sm',
      +  'shadows.md': '--shadow-md',
      +  'shadows.lg': '--shadow-lg',
      +  'shadows.focus': '--shadow-focus',
      +
      +  // Light-resolved colour aliases (what the website actually imports)
      +  'colors.accent': '--color-accent',
      +  'colors.accentLight': '--color-accent-light',
      +  'colors.angularRed': '--color-angular-red',
      +  'colors.renderGreen': '--color-render-green',
      +  'colors.chatPurple': '--color-chat-purple',
      +  'colors.bg': '--color-bg',
      +  'colors.accentHover': '--color-accent-hover',
      +  'colors.accentGlow': '--color-accent-glow',
      +  'colors.accentBorder': '--color-accent-border',
      +  'colors.accentBorderHover': '--color-accent-border-hover',
      +  'colors.accentSurface': '--color-accent-surface',
      +  'colors.textInverted': '--color-text-inverted',
      +  'colors.textPrimary': '--color-text-primary',
      +  'colors.textSecondary': '--color-text-secondary',
      +  'colors.textMuted': '--color-text-muted',
      +  'colors.sidebarBg': '--color-sidebar-bg',
      +
      +  // Light-resolved surface aliases
      +  'surfaces.canvas': '--color-canvas',
      +  'surfaces.surface': '--color-surface',
      +  'surfaces.surfaceTinted': '--color-surface-tinted',
      +  'surfaces.surfaceDim': '--color-surface-dim',
      +  'surfaces.border': '--color-border',
      +  'surfaces.borderStrong': '--color-border-strong',
      +};
      +
      +/** Leaves that intentionally have no CSS var, with the reason. */
      +const EXCLUDED: ReadonlyArray<{ prefix: string; why: string }> = [
      +  {
      +    prefix: 'typography.h1.family',
      +    why: 'value is already `var(--font-garamond)` — a var about a var buys nothing',
      +  },
      +  { prefix: 'typography.h2.family', why: 'see h1.family' },
      +  { prefix: 'typography.h3.family', why: 'see h1.family' },
      +  { prefix: 'typography.eyebrow.family', why: 'see h1.family' },
      +  { prefix: 'typography.bodyLg.family', why: 'see h1.family' },
      +  { prefix: 'typography.body.family', why: 'see h1.family' },
      +  { prefix: 'typography.caption.family', why: 'see h1.family' },
      +  {
      +    prefix: 'typography.eyebrow.transform',
      +    why: 'plain `text-transform: uppercase` keyword; Tailwind --text-* has no transform sub-key',
      +  },
      +  {
      +    prefix: 'light.',
      +    why: 'theme-resolution source; consumed via the colors/surfaces aliases which are mapped',
      +  },
      +  { prefix: 'dark.', why: 'dark theme is not emitted — the website is light-only' },
      +];
      +
      +function parseCssVars(css: string): Record {
      +  const out: Record = {};
      +  for (const m of css.matchAll(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/gm)) {
      +    out[m[1]] = m[2].trim();
      +  }
      +  return out;
      +}
      +
      +/** Flatten the frozen token tree to `dotted.path -> primitive value`. */
      +function flatten(node: unknown, prefix = ''): Array<[string, string]> {
      +  if (node === null || typeof node !== 'object') {
      +    return [[prefix, String(node)]];
      +  }
      +  return Object.entries(node as Record).flatMap(([k, v]) =>
      +    flatten(v, prefix ? `${prefix}.${k}` : k),
      +  );
      +}
      +
      +const leaves = flatten(tokens);
      +const cssVars = parseCssVars(readFileSync(THEME_CSS, 'utf-8'));
      +const isExcluded = (path: string) =>
      +  EXCLUDED.some((e) => path === e.prefix || path.startsWith(e.prefix));
      +
      +describe('token ↔ CSS var parity', () => {
      +  it('finds a non-trivial number of token leaves (guards against a walker that visits nothing)', () => {
      +    expect(leaves.length).toBeGreaterThan(60);
      +  });
      +
      +  it('parses a non-trivial number of vars from theme.css', () => {
      +    expect(Object.keys(cssVars).length).toBeGreaterThan(30);
      +  });
      +
      +  it('maps or explicitly excludes every token leaf', () => {
      +    const unaccounted = leaves
      +      .map(([path]) => path)
      +      .filter((path) => !CSS_VAR_BY_PATH[path] && !isExcluded(path));
      +    expect(unaccounted).toEqual([]);
      +  });
      +
      +  it.each(Object.entries(CSS_VAR_BY_PATH))(
      +    '%s has an identical value in theme.css as %s',
      +    (path, varName) => {
      +      const leaf = leaves.find(([p]) => p === path);
      +      if (!leaf) throw new Error(`token path ${path} does not exist`);
      +      expect(cssVars[varName], `${varName} missing from theme.css`).toBeDefined();
      +      expect(cssVars[varName]).toBe(leaf[1]);
      +    },
      +  );
      +});
      +```
      +
      +- [ ] **Step 2: Run the test and verify it fails for the right reason**
      +
      +Run:
      +
      +```bash
      +cd libs/design-tokens && npx vitest run src/lib/token-css-parity.spec.ts --config vite.config.mts; cd -
      +```
      +
      +Expected: FAIL. Specifically the `--text-*`, `--spacing-*`, and `--container-page`
      +cases fail with `... missing from theme.css`, and the "maps or explicitly
      +excludes" case passes (every leaf is accounted for in the map, the vars just do
      +not exist yet).
      +
      +If instead you see failures on `--color-*` or `--radius-*` cases, stop — that
      +means the existing generator disagrees with the TS sources and you have found a
      +pre-existing bug that must be understood before continuing.
      +
      +- [ ] **Step 3: Commit the red test**
      +
      +```bash
      +git add libs/design-tokens/src/lib/token-css-parity.spec.ts
      +git commit -m "test(design-tokens): assert token↔CSS-var parity (red — type and space scales unemitted)"
      +```
      +
      +---
      +
      +## Task 2: Emit the type scale
      +
      +**Files:**
      +- Modify: `libs/design-tokens/scripts/generate-theme-css.ts`
      +- Regenerate: `libs/design-tokens/src/lib/theme.css`
      +
      +- [ ] **Step 1: Add the type-scale block to the generator**
      +
      +In `buildThemeBlock()`, immediately after the `/* Fonts */` block (the three
      +`--font-*` lines) and before `/* Radii */`, insert:
      +
      +```ts
      +  // Type scale — Tailwind v4 composite text tokens.
      +  //
      +  // `--text-{name}` plus the optional `--line-height` / `--font-weight` /
      +  // `--letter-spacing` sub-keys collapse a whole type step into a single
      +  // `text-{name}` utility, which is an exact structural match for the
      +  // composite objects in typography.ts.
      +  //
      +  // `family` is deliberately not emitted: those values are already
      +  // `var(--font-garamond)` and friends, and Tailwind's --text-* bundle has no
      +  // font-family sub-key. `eyebrow.transform` is likewise a plain
      +  // `text-transform` keyword, not a token. Both are excluded in
      +  // token-css-parity.spec.ts with that reasoning.
      +  lines.push('');
      +  lines.push('  /* Type scale */');
      +  const typeSteps = [
      +    ['h1', typography.h1],
      +    ['h2', typography.h2],
      +    ['h3', typography.h3],
      +    ['eyebrow', typography.eyebrow],
      +    ['body-lg', typography.bodyLg],
      +    ['body', typography.body],
      +    ['caption', typography.caption],
      +  ] as const;
      +  for (const [name, step] of typeSteps) {
      +    lines.push(`  --text-${name}: ${step.size};`);
      +    lines.push(`  --text-${name}--line-height: ${step.line};`);
      +    if ('weight' in step) {
      +      lines.push(`  --text-${name}--font-weight: ${step.weight};`);
      +    }
      +    if ('letterSpacing' in step) {
      +      lines.push(`  --text-${name}--letter-spacing: ${step.letterSpacing};`);
      +    }
      +  }
      +```
      +
      +`typography` is already destructured from `baseTokens` at the top of
      +`buildThemeBlock()` — no import change needed.
      +
      +- [ ] **Step 2: Regenerate theme.css**
      +
      +```bash
      +npx nx run design-tokens:generate-theme-css
      +```
      +
      +Expected output: `wrote /…/libs/design-tokens/src/lib/theme.css`
      +
      +- [ ] **Step 3: Eyeball the generated block**
      +
      +```bash
      +sed -n '/Type scale/,/Radii/p' libs/design-tokens/src/lib/theme.css
      +```
      +
      +Expected to include exactly these, among others:
      +
      +```css
      +  --text-h1: clamp(48px, 6vw, 72px);
      +  --text-h1--line-height: 1.08;
      +  --text-h3--font-weight: 600;
      +  --text-eyebrow--letter-spacing: 0.12em;
      +  --text-body-lg: 20px;
      +```
      +
      +No `--text-*--font-family` and no `--text-eyebrow--text-transform` lines. If
      +either is present, remove the code that emitted it.
      +
      +- [ ] **Step 4: Run the parity spec**
      +
      +```bash
      +cd libs/design-tokens && npx vitest run src/lib/token-css-parity.spec.ts --config vite.config.mts; cd -
      +```
      +
      +Expected: all `--text-*` cases now PASS. The four space-scale cases
      +(`--spacing-section-y`, `--spacing-section-y-tight`, `--spacing-container-x`,
      +`--container-page`) still FAIL — that is Task 3.
      +
      +- [ ] **Step 5: Commit**
      +
      +```bash
      +git add libs/design-tokens/scripts/generate-theme-css.ts libs/design-tokens/src/lib/theme.css
      +git commit -m "feat(design-tokens): emit the type scale as Tailwind v4 composite text tokens"
      +```
      +
      +---
      +
      +## Task 3: Emit the space scale
      +
      +**Files:**
      +- Modify: `libs/design-tokens/scripts/generate-theme-css.ts`
      +- Regenerate: `libs/design-tokens/src/lib/theme.css`
      +
      +- [ ] **Step 1: Destructure `space` from baseTokens**
      +
      +At the top of `buildThemeBlock()`, change:
      +
      +```ts
      +  const { typography, radius, shadows, brand } = baseTokens;
      +```
      +
      +to:
      +
      +```ts
      +  const { typography, space, radius, shadows, brand } = baseTokens;
      +```
      +
      +- [ ] **Step 2: Add the space-scale block**
      +
      +After the `/* Shadows */` block and before the closing `lines.push('}')`,
      +insert:
      +
      +```ts
      +  // Space scale.
      +  //
      +  // `containerMax` goes to the --container-* namespace, not --spacing-*,
      +  // because it is a max-width rather than a spacing step.
      +  //
      +  // Tailwind strips the namespace prefix when naming the utility, so
      +  // `--container-page` generates `max-w-page` (NOT `max-w-container-page`).
      +  // Verified in a browser: `max-w-page` computes to 1200px. `--spacing-*`
      +  // behaves the same way — `--spacing-section-y` gives `p-section-y`.
      +  lines.push('');
      +  lines.push('  /* Space scale */');
      +  lines.push(`  --spacing-section-y: ${space.sectionY};`);
      +  lines.push(`  --spacing-section-y-tight: ${space.sectionYTight};`);
      +  lines.push(`  --spacing-container-x: ${space.containerX};`);
      +  lines.push(`  --container-page: ${space.containerMax};`);
      +```
      +
      +- [ ] **Step 3: Regenerate and run the full library suite**
      +
      +```bash
      +npx nx run design-tokens:generate-theme-css && npx nx test design-tokens
      +```
      +
      +Expected: PASS, including `generate-theme-css` (the drift guard, because you
      +regenerated) and every case in `token-css-parity`.
      +
      +- [ ] **Step 4: Mutation-test the parity guard**
      +
      +The parity spec fails silently if written wrong, so prove it bites. Temporarily
      +break one value:
      +
      +```bash
      +sed -i '' "s/sectionY: 'clamp(64px, 8vw, 120px)'/sectionY: 'clamp(64px, 8vw, 999px)'/" libs/design-tokens/src/lib/space.ts
      +cd libs/design-tokens && npx vitest run src/lib/token-css-parity.spec.ts --config vite.config.mts; cd -
      +```
      +
      +Expected: FAIL on `space.sectionY has an identical value in theme.css as
      +--spacing-section-y`. If it PASSES, the guard is vacuous — fix it before going
      +further.
      +
      +Revert:
      +
      +```bash
      +git checkout -- libs/design-tokens/src/lib/space.ts
      +npx nx test design-tokens
      +```
      +
      +Expected: PASS.
      +
      +- [ ] **Step 5: Commit**
      +
      +```bash
      +git add libs/design-tokens/scripts/generate-theme-css.ts libs/design-tokens/src/lib/theme.css
      +git commit -m "feat(design-tokens): emit the space scale; token↔CSS parity now green"
      +```
      +
      +---
      +
      +## Task 4: Lock the `--ds-*` consumer contract
      +
      +`tokens.css` is orphaned — zero importers, not in the package `exports` map —
      +but 45 cockpit and example files reference `--ds-*` properties, always with a
      +fallback (`var(--ds-canvas, #111)`), so they render on fallbacks today. Before
      +regenerating that file we pin the names those consumers use, so regeneration
      +cannot silently drop one.
      +
      +**Files:**
      +- Create: `libs/design-tokens/src/lib/ds-var-contract.spec.ts`
      +
      +- [ ] **Step 1: Write the test**
      +
      +Create `libs/design-tokens/src/lib/ds-var-contract.spec.ts`:
      +
      +```ts
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +
      +const TOKENS_CSS = resolve(__dirname, 'tokens.css');
      +
      +/**
      + * The `--ds-*` names that cockpit and example apps actually reference today.
      + *
      + * They all reference them with fallbacks and nothing imports tokens.css yet,
      + * so dropping a name causes no immediate breakage — it would just silently
      + * pin those apps to their fallback colours forever. Hence this list.
      + *
      + * Derived from:
      + *   grep -rhoE -- "--ds-[a-z0-9-]+" cockpit examples apps | sort -u
      + * intersected with the names tokens.css defined before it came under the
      + * generator. Add to this list when a consumer starts using a new name.
      + */
      +const CONSUMER_REFERENCED = [
      +  '--ds-accent',
      +  '--ds-accent-border',
      +  '--ds-accent-glow',
      +  '--ds-accent-hover',
      +  '--ds-accent-surface',
      +  '--ds-border',
      +  '--ds-border-strong',
      +  '--ds-canvas',
      +  '--ds-font-mono',
      +  '--ds-font-sans',
      +  '--ds-font-serif',
      +  '--ds-radius-lg',
      +  '--ds-radius-md',
      +  '--ds-radius-sm',
      +  '--ds-radius-xl',
      +  '--ds-shadow-lg',
      +  '--ds-shadow-md',
      +  '--ds-surface',
      +  '--ds-surface-dim',
      +  '--ds-surface-tinted',
      +  '--ds-text-inverted',
      +  '--ds-text-muted',
      +  '--ds-text-primary',
      +  '--ds-text-secondary',
      +] as const;
      +
      +function definedNames(css: string): Set {
      +  return new Set([...css.matchAll(/^\s*(--ds-[a-z0-9-]+):/gm)].map((m) => m[1]));
      +}
      +
      +describe('--ds-* consumer contract', () => {
      +  const defined = definedNames(readFileSync(TOKENS_CSS, 'utf-8'));
      +
      +  it('parses a non-trivial number of names (guards a regex that matches nothing)', () => {
      +    expect(defined.size).toBeGreaterThan(20);
      +  });
      +
      +  it('defines every --ds-* name a cockpit or example app references', () => {
      +    const missing = CONSUMER_REFERENCED.filter((n) => !defined.has(n));
      +    expect(missing).toEqual([]);
      +  });
      +});
      +```
      +
      +- [ ] **Step 2: Run it against the current hand-written tokens.css**
      +
      +```bash
      +cd libs/design-tokens && npx vitest run src/lib/ds-var-contract.spec.ts --config vite.config.mts; cd -
      +```
      +
      +Expected: PASS. This is the baseline — it documents what regeneration must
      +preserve. A red result here means the contract list is wrong; recompute it with
      +the `grep` in the docblock before proceeding.
      +
      +- [ ] **Step 3: Mutation-test it**
      +
      +```bash
      +sed -i '' 's/^  --ds-canvas:/  --ds-canvas-XX:/' libs/design-tokens/src/lib/tokens.css
      +cd libs/design-tokens && npx vitest run src/lib/ds-var-contract.spec.ts --config vite.config.mts; cd -
      +```
      +
      +Expected: FAIL listing `--ds-canvas` as missing. Then revert:
      +
      +```bash
      +git checkout -- libs/design-tokens/src/lib/tokens.css
      +```
      +
      +- [ ] **Step 4: Commit**
      +
      +```bash
      +git add libs/design-tokens/src/lib/ds-var-contract.spec.ts
      +git commit -m "test(design-tokens): pin the --ds-* names cockpit and example apps reference"
      +```
      +
      +---
      +
      +## Task 5: Bring `tokens.css` under the generator
      +
      +`tokens.css` is hand-written and has drifted from `light.ts` —
      +`--ds-text-secondary: #555770` against the live `rgb(70, 70, 70)`. It is also
      +incomplete: it omits `--ds-render-green` and the `bodyLg` / `body` / `caption`
      +line-heights. Generating it from the same source removes the third disagreeing
      +surface.
      +
      +**Files:**
      +- Modify: `libs/design-tokens/scripts/generate-theme-css.ts`
      +- Regenerate: `libs/design-tokens/src/lib/tokens.css`
      +- Modify: `libs/design-tokens/src/lib/generate-theme-css.spec.ts`
      +
      +- [ ] **Step 1: Add the tokens.css generator**
      +
      +In `scripts/generate-theme-css.ts`, add next to `OUTPUT_PATH`:
      +
      +```ts
      +const TOKENS_OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'tokens.css');
      +```
      +
      +Then add these two functions above `generateThemeCss()`:
      +
      +```ts
      +const TOKENS_HEADER = `/*
      + * @threadplane/design-tokens/tokens.css
      + *
      + * GENERATED FILE — DO NOT EDIT BY HAND.
      + *
      + * Plain \`:root { --ds-* }\` custom properties for consumers that do not run
      + * Tailwind (the Angular cockpit and example apps). Same values as theme.css,
      + * different naming convention and no \`@theme\` wrapper.
      + *
      + * Regenerate with:
      + *   npx nx run design-tokens:generate-theme-css
      + *
      + * Source of truth:
      + *   - libs/design-tokens/src/lib/light.ts
      + *   - libs/design-tokens/src/lib/base.ts
      + *
      + * The names here are a consumer contract — cockpit and example apps reference
      + * them. ds-var-contract.spec.ts fails if one disappears.
      + */
      +`;
      +
      +function buildTokensBlock(): string {
      +  const { typography, space, radius, shadows, brand } = baseTokens;
      +  const lines: string[] = [':root {'];
      +
      +  lines.push('  /* Colors */');
      +  lines.push(`  --ds-bg: ${lightOverrides.bg};`);
      +  lines.push(`  --ds-accent: ${lightOverrides.accent};`);
      +  lines.push(`  --ds-accent-hover: ${lightOverrides.accentHover};`);
      +  lines.push(`  --ds-accent-light: ${brand.accentLight};`);
      +  lines.push(`  --ds-accent-glow: ${lightOverrides.accentGlow};`);
      +  lines.push(`  --ds-accent-border: ${lightOverrides.accentBorder};`);
      +  lines.push(`  --ds-accent-border-hover: ${lightOverrides.accentBorderHover};`);
      +  lines.push(`  --ds-accent-surface: ${lightOverrides.accentSurface};`);
      +  lines.push(`  --ds-text-primary: ${lightOverrides.textPrimary};`);
      +  lines.push(`  --ds-text-secondary: ${lightOverrides.textSecondary};`);
      +  lines.push(`  --ds-text-muted: ${lightOverrides.textMuted};`);
      +  lines.push(`  --ds-text-inverted: ${lightOverrides.textInverted};`);
      +  lines.push(`  --ds-sidebar-bg: ${lightOverrides.sidebarBg};`);
      +  lines.push(`  --ds-angular-red: ${brand.angularRed};`);
      +  lines.push(`  --ds-render-green: ${brand.renderGreen};`);
      +  lines.push(`  --ds-chat-purple: ${brand.chatPurple};`);
      +
      +  lines.push('');
      +  lines.push('  /* Surfaces */');
      +  lines.push(`  --ds-canvas: ${lightOverrides.canvas};`);
      +  lines.push(`  --ds-surface: ${lightOverrides.surface};`);
      +  lines.push(`  --ds-surface-tinted: ${lightOverrides.surfaceTinted};`);
      +  lines.push(`  --ds-surface-dim: ${lightOverrides.surfaceDim};`);
      +  lines.push(`  --ds-border: ${lightOverrides.border};`);
      +  lines.push(`  --ds-border-strong: ${lightOverrides.borderStrong};`);
      +
      +  lines.push('');
      +  lines.push('  /* Typography */');
      +  lines.push(`  --ds-font-serif: ${typography.fontSerif};`);
      +  lines.push(`  --ds-font-sans: ${typography.fontSans};`);
      +  lines.push(`  --ds-font-mono: ${typography.fontMono};`);
      +
      +  lines.push('');
      +  lines.push('  /* Typography — type scale */');
      +  // `-spacing` (not `-letter-spacing`) preserves the pre-existing name.
      +  const dsSteps = [
      +    ['h1', typography.h1],
      +    ['h2', typography.h2],
      +    ['h3', typography.h3],
      +    ['eyebrow', typography.eyebrow],
      +    ['body-lg', typography.bodyLg],
      +    ['body', typography.body],
      +    ['caption', typography.caption],
      +  ] as const;
      +  for (const [name, step] of dsSteps) {
      +    lines.push(`  --ds-${name}-size: ${step.size};`);
      +    lines.push(`  --ds-${name}-line: ${step.line};`);
      +    if ('weight' in step) lines.push(`  --ds-${name}-weight: ${step.weight};`);
      +    if ('letterSpacing' in step) {
      +      lines.push(`  --ds-${name}-spacing: ${step.letterSpacing};`);
      +    }
      +  }
      +
      +  lines.push('');
      +  lines.push('  /* Shadows */');
      +  lines.push(`  --ds-shadow-sm: ${shadows.sm};`);
      +  lines.push(`  --ds-shadow-md: ${shadows.md};`);
      +  lines.push(`  --ds-shadow-lg: ${shadows.lg};`);
      +  lines.push(`  --ds-shadow-focus: ${shadows.focus};`);
      +
      +  lines.push('');
      +  lines.push('  /* Radius */');
      +  lines.push(`  --ds-radius-sm: ${radius.sm};`);
      +  lines.push(`  --ds-radius-md: ${radius.md};`);
      +  lines.push(`  --ds-radius-lg: ${radius.lg};`);
      +  lines.push(`  --ds-radius-xl: ${radius.xl};`);
      +  lines.push(`  --ds-radius-full: ${radius.full};`);
      +
      +  lines.push('');
      +  lines.push('  /* Space */');
      +  lines.push(`  --ds-section-y: ${space.sectionY};`);
      +  lines.push(`  --ds-section-y-tight: ${space.sectionYTight};`);
      +  lines.push(`  --ds-container-x: ${space.containerX};`);
      +  lines.push(`  --ds-container-max: ${space.containerMax};`);
      +
      +  lines.push('}');
      +  return lines.join('\n') + '\n';
      +}
      +
      +export function generateTokensCss(): string {
      +  return TOKENS_HEADER + buildTokensBlock();
      +}
      +```
      +
      +- [ ] **Step 2: Write both files from `main()`**
      +
      +Replace the body of `main()` with:
      +
      +```ts
      +function main() {
      +  writeFileSync(OUTPUT_PATH, generateThemeCss());
      +  writeFileSync(TOKENS_OUTPUT_PATH, generateTokensCss());
      +  // eslint-disable-next-line no-console
      +  console.log(`wrote ${OUTPUT_PATH}`);
      +  // eslint-disable-next-line no-console
      +  console.log(`wrote ${TOKENS_OUTPUT_PATH}`);
      +}
      +```
      +
      +- [ ] **Step 3: Extend the drift guard**
      +
      +In `libs/design-tokens/src/lib/generate-theme-css.spec.ts`, change the import
      +line to pull in both generators and add a second case:
      +
      +```ts
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +import { generateThemeCss, generateTokensCss } from '../../scripts/generate-theme-css';
      +
      +const COMMITTED_PATH = resolve(__dirname, 'theme.css');
      +const COMMITTED_TOKENS_PATH = resolve(__dirname, 'tokens.css');
      +
      +describe('generate-theme-css', () => {
      +  it('produces output that matches the committed theme.css', () => {
      +    const expected = readFileSync(COMMITTED_PATH, 'utf-8');
      +    const actual = generateThemeCss();
      +    expect(actual).toBe(expected);
      +  });
      +
      +  it('produces output that matches the committed tokens.css', () => {
      +    const expected = readFileSync(COMMITTED_TOKENS_PATH, 'utf-8');
      +    const actual = generateTokensCss();
      +    expect(actual).toBe(expected);
      +  });
      +});
      +```
      +
      +- [ ] **Step 4: Regenerate and run the suite**
      +
      +```bash
      +npx nx run design-tokens:generate-theme-css && npx nx test design-tokens
      +```
      +
      +Expected: PASS on all four specs — `tokens`, `generate-theme-css` (both cases),
      +`ds-var-contract`, and `token-css-parity`.
      +
      +`ds-var-contract` passing is the important one: it proves regeneration kept
      +every name the cockpit apps reference.
      +
      +- [ ] **Step 5: Confirm the drift is actually gone**
      +
      +```bash
      +grep -nE "555770|8b8fa3|1a1a2e|f8f9fc" libs/design-tokens/src/lib/tokens.css
      +```
      +
      +Expected: **no output**. Those were the stale hand-written values; they are now
      +sourced from `light.ts`.
      +
      +- [ ] **Step 6: Commit**
      +
      +```bash
      +git add libs/design-tokens/scripts/generate-theme-css.ts \
      +        libs/design-tokens/src/lib/tokens.css \
      +        libs/design-tokens/src/lib/generate-theme-css.spec.ts
      +git commit -m "refactor(design-tokens): generate tokens.css from light.ts so --ds-* can no longer drift"
      +```
      +
      +---
      +
      +## Task 6: Make `tokens.css` importable
      +
      +It is generated and correct now, but still unreachable — not in `exports`, not
      +copied by the build.
      +
      +**Files:**
      +- Modify: `libs/design-tokens/package.json`
      +- Modify: `libs/design-tokens/project.json`
      +
      +- [ ] **Step 1: Add the export**
      +
      +In `libs/design-tokens/package.json`, the `exports` map currently reads:
      +
      +```json
      +"exports": {
      +  ".": { "types": "./src/index.d.ts", "default": "./src/index.js" },
      +  "./theme.css": "./src/lib/theme.css"
      +}
      +```
      +
      +Add the third entry:
      +
      +```json
      +"exports": {
      +  ".": { "types": "./src/index.d.ts", "default": "./src/index.js" },
      +  "./theme.css": "./src/lib/theme.css",
      +  "./tokens.css": "./src/lib/tokens.css"
      +}
      +```
      +
      +- [ ] **Step 2: Copy it in the build**
      +
      +In `libs/design-tokens/project.json`, the `build.options.assets` array has one
      +entry globbing `theme.css`. Change that glob to cover both files:
      +
      +```json
      +"assets": [
      +  {
      +    "input": "libs/design-tokens/src/lib",
      +    "glob": "*.css",
      +    "output": "src/lib"
      +  }
      +]
      +```
      +
      +- [ ] **Step 3: Build and verify both files land**
      +
      +```bash
      +npx nx build design-tokens && ls dist/libs/design-tokens/src/lib/*.css
      +```
      +
      +Expected: both `theme.css` and `tokens.css` listed.
      +
      +- [ ] **Step 4: Commit**
      +
      +```bash
      +git add libs/design-tokens/package.json libs/design-tokens/project.json
      +git commit -m "build(design-tokens): export and ship tokens.css alongside theme.css"
      +```
      +
      +---
      +
      +## Task 7: `global.css` — swaps with no visual change
      +
      +Three literals in the website's `global.css` already hold exactly the token
      +value. Swapping them must produce a byte-identical render.
      +
      +**Files:**
      +- Modify: `apps/website/src/app/global.css:114-115`, `:195`
      +
      +- [ ] **Step 1: Make the three swaps**
      +
      +In `.docs-prose :not(pre) > code` (around line 111), change:
      +
      +```css
      +  background: rgba(0, 64, 144, 0.06);
      +  color: #004090;
      +```
      +
      +to:
      +
      +```css
      +  background: var(--color-accent-surface);
      +  color: var(--color-accent);
      +```
      +
      +In `.docs-prose th` (line 195), change `border-bottom: 1px solid rgba(0, 64, 144, 0.15);`
      +to `border-bottom: 1px solid var(--color-accent-border);`. Leave the `color`
      +on that line alone — it is Task 8.
      +
      +- [ ] **Step 2: Verify the values really are identical**
      +
      +```bash
      +grep -E "accent-surface|--color-accent:|accent-border:" libs/design-tokens/src/lib/theme.css
      +```
      +
      +Expected:
      +
      +```
      +  --color-accent: #004090;
      +  --color-accent-border: rgba(0, 64, 144, 0.15);
      +  --color-accent-surface: rgba(0, 64, 144, 0.06);
      +```
      +
      +If any differs from the literal you replaced, that swap belongs in Task 8
      +instead — it is a visual change, not a rename.
      +
      +- [ ] **Step 3: Confirm visually**
      +
      +Start the dev server via `preview_start` with the `website-dev` config, open
      +`/docs/chat/components/chat`, and screenshot at 1280 wide. Compare against a
      +screenshot taken before the edit. Expected: **no difference at all.** Inline
      +code chips keep their pale blue background and navy text.
      +
      +- [ ] **Step 4: Commit**
      +
      +```bash
      +git add apps/website/src/app/global.css
      +git commit -m "refactor(website): use accent tokens for docs code chips and table rule"
      +```
      +
      +---
      +
      +## Task 8: `global.css` — adopt the live tokens (visible change)
      +
      +This is the one reviewable design decision in the project. Three rules render a
      +blue-grey (`#555770`, `#8b8fa3`) from the old drifted token surface that no
      +current token produces.
      +
      +Note that two *other* uses of `#555770` — `li::marker` (line 139) and
      +`figcaption` (line 170) — are already written as
      +`var(--color-text-muted, #555770)`. The var is defined, so the fallback is dead
      +text and removing it changes nothing. Do not count those as visual changes.
      +
      +**Files:**
      +- Modify: `apps/website/src/app/global.css:100`, `:139`, `:170`, `:195-196`
      +
      +- [ ] **Step 1: Capture the "before" screenshots**
      +
      +With the dev server running, screenshot `/docs/chat/components/chat` at 1280
      +wide, scrolled to the "Inputs" props table, and a second shot of any page with
      +a titled code block. Keep them for the PR description.
      +
      +- [ ] **Step 2: Adopt the tokens in the three live rules**
      +
      +Line 100, inside `[data-rehype-pretty-code-title]`:
      +
      +```css
      +  color: var(--color-text-muted);
      +```
      +
      +Line 195, inside `.docs-prose th` — `th` is an uppercase mono label, so it takes
      +the muted token:
      +
      +```css
      +  color: var(--color-text-muted);
      +```
      +
      +Line 196, inside `.docs-prose td` — `td` is body content and should match
      +`--tw-prose-body`, which `MdxRenderer` sets to `colors.textSecondary`:
      +
      +```css
      +  color: var(--color-text-secondary);
      +```
      +
      +- [ ] **Step 3: Drop the two dead fallbacks**
      +
      +Line 139: `.docs-prose li::marker { color: var(--color-text-muted, #555770); }`
      +becomes `.docs-prose li::marker { color: var(--color-text-muted); }`
      +
      +Line 170, inside the `figcaption` rule: `color: var(--color-text-muted, #555770);`
      +becomes `color: var(--color-text-muted);`
      +
      +- [ ] **Step 4: Verify no stale literal survives**
      +
      +```bash
      +grep -nE "555770|8b8fa3" apps/website/src/app/global.css
      +```
      +
      +Expected: **no output.**
      +
      +- [ ] **Step 5: Capture "after" screenshots and confirm the change is bounded**
      +
      +Re-screenshot the same two views. Expected differences, and *only* these:
      +
      +- props-table header text: blue-grey → `rgb(115, 115, 115)`
      +- props-table body text: blue-grey → `rgb(70, 70, 70)`
      +- code-block title text: blue-grey → `rgb(115, 115, 115)`
      +
      +List markers and figure captions must look **identical** — if they changed, the
      +fallback was live and something else is wrong.
      +
      +- [ ] **Step 6: Commit**
      +
      +```bash
      +git add apps/website/src/app/global.css
      +git commit -m "fix(website): docs tables and code titles use the live text tokens
      +
      +The literals #555770 and #8b8fa3 came from the old --ds-* surface and no
      +longer match any token. Visible change, bounded to table header text, table
      +body text, and code-block titles."
      +```
      +
      +---
      +
      +## Task 9: `global.css` — name the literals that are not tokens
      +
      +Eight remaining literals do not belong to the design system and must not be
      +promoted into it. They become local, named, commented vars.
      +
      +**Files:**
      +- Modify: `apps/website/src/app/global.css`
      +
      +- [ ] **Step 1: Declare the local vars**
      +
      +Immediately after the two `@import` lines at the top of `global.css`, insert:
      +
      +```css
      +/*
      + * Local, non-token constants.
      + *
      + * These are deliberately NOT design tokens. Promoting them to
      + * @threadplane/design-tokens would imply the design system owns the syntax
      + * theme and the docs figure treatment. It does not.
      + *
      + * The --docs-code-* group is coupled to `rehypeOptions.theme` ('tokyo-night')
      + * in components/docs/MdxRenderer.tsx. Change the shiki theme and these must
      + * change with it.
      + */
      +:root {
      +  --docs-code-bg: #1a1b26;
      +  --docs-code-border: rgba(0, 0, 0, 0.1);
      +  --docs-code-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
      +  --docs-code-title-rule: rgba(255, 255, 255, 0.06);
      +  --docs-figure-shadow: 0 4px 16px rgba(0, 32, 72, 0.1);
      +  --docs-figure-shadow-bare: 0 4px 16px rgba(0, 32, 72, 0.08);
      +  /* Accent tints between --color-accent-surface (6%) and --color-accent-border
      +   * (15%). Derived rather than hardcoded so they track the accent. */
      +  --docs-accent-tint-faint: color-mix(in srgb, var(--color-accent) 3.5%, transparent);
      +  --docs-accent-tint-soft: color-mix(in srgb, var(--color-accent) 8%, transparent);
      +  --docs-accent-tint-line: color-mix(in srgb, var(--color-accent) 10%, transparent);
      +}
      +```
      +
      +- [ ] **Step 2: Point the rules at them**
      +
      +| line | rule | replace | with |
      +|---|---|---|---|
      +| 58 | `.shiki` | `background: #1a1b26 !important;` | `background: var(--docs-code-bg) !important;` |
      +| 77 | figure `pre` | `border: 1px solid rgba(0, 0, 0, 0.1);` | `border: 1px solid var(--docs-code-border);` |
      +| 78 | figure `pre` | `box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);` | `box-shadow: var(--docs-code-shadow);` |
      +| 102 | code title | `background: #1a1b26;` | `background: var(--docs-code-bg);` |
      +| 103 | code title | `border-bottom: 1px solid rgba(255, 255, 255, 0.06);` | `border-bottom: 1px solid var(--docs-code-title-rule);` |
      +| 154 | `figure:has(> img)` | `background: rgba(0, 64, 144, 0.035);` | `background: var(--docs-accent-tint-faint);` |
      +| 155 | `figure:has(> img)` | `border: 1px solid rgba(0, 64, 144, 0.1);` | `border: 1px solid var(--docs-accent-tint-line);` |
      +| 163 | figure `img` | `box-shadow: 0 4px 16px rgba(0, 32, 72, 0.1);` | `box-shadow: var(--docs-figure-shadow);` |
      +| 182 | bare `img` | `box-shadow: 0 4px 16px rgba(0, 32, 72, 0.08);` | `box-shadow: var(--docs-figure-shadow-bare);` |
      +| 196 | `.docs-prose td` | `border-bottom: 1px solid rgba(0, 64, 144, 0.08);` | `border-bottom: 1px solid var(--docs-accent-tint-soft);` |
      +
      +- [ ] **Step 3: Verify no bare literal remains outside the `:root` block**
      +
      +```bash
      +awk '/^:root \{/,/^\}/ { next } { print FILENAME":"NR": "$0 }' apps/website/src/app/global.css \
      +  | grep -E "#[0-9a-fA-F]{3,8}|rgba?\("
      +```
      +
      +Expected: **no output.** Every colour literal now lives in the `:root` block or
      +comes from a token.
      +
      +- [ ] **Step 4: Verify `color-mix` renders**
      +
      +Reload `/docs/langgraph/concepts/threads-and-runs` (a page with a figure) and
      +run in the browser console via `javascript_tool`:
      +
      +```js
      +getComputedStyle(document.querySelector('.docs-prose figure:has(> img)')).backgroundColor
      +```
      +
      +Expected: a resolved `rgba(...)` / `color(...)` value, **not** the literal
      +string `color-mix(...)` and not `rgba(0, 0, 0, 0)`. If it does not resolve,
      +replace the three `--docs-accent-tint-*` definitions with the literal values
      +they replaced and note it in the PR — the spec flags this as an open question.
      +
      +- [ ] **Step 5: Screenshot to confirm nothing moved**
      +
      +Re-screenshot `/docs/chat/components/chat` and a figure-bearing page. Expected:
      +identical to the end of Task 8. This task is a pure rename.
      +
      +- [ ] **Step 6: Commit**
      +
      +```bash
      +git add apps/website/src/app/global.css
      +git commit -m "refactor(website): name the docs-local constants that are not design tokens"
      +```
      +
      +---
      +
      +## Task 10: Full verification
      +
      +**Files:** none — verification only.
      +
      +- [ ] **Step 1: Library suite**
      +
      +```bash
      +npx nx test design-tokens
      +```
      +
      +Expected: PASS — `tokens`, `generate-theme-css` (×2), `ds-var-contract`,
      +`token-css-parity`.
      +
      +- [ ] **Step 2: Website suite**
      +
      +```bash
      +cd apps/website && npx vitest run --config vite.config.mts
      +```
      +
      +Use this exact command — `nx test website` does not exist.
      +
      +**The website suite is NOT green, and was not green before this project started.**
      +Because there is no `nx test` target, these specs never ran in CI and drifted
      +red. Measured baseline at `959c6db0` (the commit this branch started from):
      +
      +```
      +Test Files  3 failed | 33 passed (36)
      +     Tests  5 failed | 341 passed (346)
      +```
      +
      +The five are content assertions with no relationship to CSS custom properties:
      +
      +| spec | assertion that drifted |
      +|---|---|
      +| `blog/PostCard.spec.tsx` | expects the text `2026-05-17` |
      +| `landing/Differentiator.spec.tsx` | expects the text `MIT + self-hosted` |
      +| `app/thanks/page.spec.tsx` (×3) | heading, `provideChat()` mention, docs links |
      +
      +**Expected here: exactly those same 5 failures and no others.** A sixth failure,
      +or a different one, is caused by this project and must be fixed. Do not "fix"
      +the five — they are pre-existing drift and belong to their own cleanup.
      +
      +- [ ] **Step 3: Lint**
      +
      +```bash
      +npx nx lint design-tokens && npx nx lint website
      +```
      +
      +CI tolerates warnings but fails on errors. To count errors, strip ANSI first —
      +`grep -cE ' error '` on raw output silently returns 0:
      +
      +```bash
      +npx nx lint website 2>&1 | sed -r 's/\x1b\[[0-9;]*m//g' | grep -cE '  error  '
      +```
      +
      +Expected: `0`.
      +
      +- [ ] **Step 4: Production build**
      +
      +```bash
      +npx nx build website --configuration=production
      +```
      +
      +Expected: success. The prod config has a bundle budget that dev does not; a dev
      +build passing proves nothing about deploy.
      +
      +- [ ] **Step 5: Confirm the generated CSS is committed and clean**
      +
      +```bash
      +git status --short
      +```
      +
      +Expected: **empty.** A dirty `theme.css` or `tokens.css` means you edited a
      +generated file without regenerating, and the drift guard will fail in CI.
      +
      +- [ ] **Step 6: Sanity-check the new utilities exist**
      +
      +The `@theme` additions generate Tailwind utilities. Confirm the type scale is
      +live by adding `class="text-h2"` to any element in a dev page, checking the
      +computed `font-size` is `clamp(36px, 4.5vw, 56px)`, then removing it. This
      +verifies `@theme` picked the tokens up rather than silently ignoring them.
      +
      +- [ ] **Step 7: Final commit if anything changed**
      +
      +```bash
      +git status --short
      +```
      +
      +If empty, nothing to do — the work is already committed task by task.
      +
      +---
      +
      +## What this deliberately does not do
      +
      +Restating so a reviewer does not ask for it:
      +
      +- **No component is touched.** Zero `style={{}}` objects change. That is
      +  Project 2, and it is what these vars exist to enable.
      +- **No fix from the findings audit lands.** The dead sticky rails, the crushed
      +  mobile tables, and the anchors behind the nav all stay broken through
      +  Projects 1 and 2. That was accepted when the arc was ordered this way.
      +- **The cockpit apps are not wired to `tokens.css`.** They still render on
      +  their `var(--ds-*, fallback)` fallbacks. Importing the now-correct file would
      +  flip nine example apps from dark fallbacks to light token values — a real
      +  visual change that wants its own ticket.
      +- **No dark theme.** `darkOverrides` exists and `cssVars('dark')` resolves it,
      +  but nothing emits it and the site stays light-only.
      diff --git a/docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md b/docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md
      new file mode 100644
      index 000000000..08cf6daf5
      --- /dev/null
      +++ b/docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md
      @@ -0,0 +1,309 @@
      +# Design token CSS-var completion — design
      +
      +**Date:** 2026-08-29
      +**Status:** Proposed. Project 1 of a three-project arc.
      +
      +## Where this sits
      +
      +A visual review of the docs site (`audits/2026-08-29-docs-visual-review-findings.md`)
      +turned up a set of real defects — dead sticky rails, tables that render `agent`
      +as `ag`/`en`/`t` on a phone, deep links landing behind the fixed nav. Roughly
      +half of the fixes cannot be written, because the components style themselves
      +with inline `style={{}}` objects and inline styles cannot express
      +`:focus-visible`, `:hover`, `:last-child`, or a media query.
      +
      +The decision was to migrate the site off inline styles first, site-wide, and
      +then apply the polish. That splits into three projects:
      +
      +1. **Token CSS-var completion** — this document. Make every token the site uses
      +   reachable from CSS.
      +2. **Substrate migration** — 802 inline style objects across 90 files, in
      +   reviewable batches, ending with an ESLint rule.
      +3. **The polish arc** — three PRs against the findings audit.
      +
      +Project 2 is blocked on this one: 29% of the site's token references have no CSS
      +counterpart to migrate *to*.
      +
      +## Problem
      +
      +The website reads design tokens two ways that are supposed to agree.
      +
      +- **From JS** — `import { tokens } from '@threadplane/design-tokens'`, then
      +  `tokens.colors.accent` inside a `style={{}}` object. 1,188 such references.
      +- **From CSS** — `var(--color-accent)`, from the generated `theme.css` that
      +  `global.css` imports.
      +
      +Both derive from `light.ts`, so where a var exists the values are identical *by
      +construction* — a JS-to-CSS swap is provably value-preserving. That is what
      +makes Project 2 tractable at all.
      +
      +But `theme.css` only emits colors, font families, radii, and shadows.
      +[`generate-theme-css.ts`](../../../libs/design-tokens/scripts/generate-theme-css.ts)
      +never learned about the **type scale** or the **space scale**, so a large slice
      +of the site's styling has nowhere to go:
      +
      +| token group | refs | reachable from CSS today |
      +|---|---:|---|
      +| `colors.*` | 512 | ✅ `--color-*` |
      +| `surfaces.*` | 175 | ✅ `--color-*` |
      +| `typography.fontSans/Mono/Serif` | 86 | ✅ `--font-*` |
      +| `radius.*` | 48 | ✅ `--radius-*` |
      +| `shadows.*` | 24 | ✅ `--shadow-*` |
      +| composite `.family` (h1/h2/h3/eyebrow/bodyLg/body/caption) | 122 | ✅ — the JS value is *already* `var(--font-garamond)` etc. |
      +| `eyebrow.transform` | 4 | ✅ — plain `text-transform`, needs no var |
      +| **type scale** `.size` / `.line` / `.weight` / `.letterSpacing` | **210** | ❌ |
      +| **`space.*`** | **7** | ❌ |
      +| | **1,188** | **217 unreachable** |
      +
      +Separately, `global.css` hardcodes colour literals, two of which are stale
      +values from a third token surface and **no longer match any live token**:
      +
      +| literal | uses | live token | live value |
      +|---|---:|---|---|
      +| `#555770` | 4 | `--color-text-secondary` | `rgb(70, 70, 70)` |
      +| `#8b8fa3` | 1 | `--color-text-muted` | `rgb(115, 115, 115)` |
      +
      +Docs table text and code-block titles therefore render in a blue-grey the design
      +system stopped producing. Migrating around that drift would bake it in
      +permanently.
      +
      +## Goal
      +
      +Every token the website consumes is reachable from CSS at a value identical to
      +its JS counterpart, and that identity is machine-checked rather than asserted.
      +
      +## Non-goals
      +
      +- **Migrating any component.** No `style={{}}` is touched. That is Project 2.
      +- **Any fix from the findings audit.** That is Project 3.
      +- **Dark theme.** `darkOverrides` exists and `cssVars('dark')` resolves it, but
      +  the website is light-only and stays so here.
      +- **Rewiring cockpit examples.** See "The third surface" below — real, adjacent,
      +  out of scope.
      +- **Growing the token API for one-off tints.** See "Literals that are not
      +  tokens".
      +
      +## Design
      +
      +### 1. Emit the type scale as Tailwind v4 composite text tokens
      +
      +Tailwind v4 lets one `@theme` entry carry a whole type step:
      +
      +```css
      +--text-h1: clamp(48px, 6vw, 72px);
      +--text-h1--line-height: 1.08;
      +```
      +
      +`--text-{name}` also accepts `--line-height`, `--font-weight`, and
      +`--letter-spacing` sub-keys, which is an exact structural match for the
      +composite token shape in `typography.ts`. A single `text-h1` utility then sets
      +size, leading, weight, and tracking together — so the migration in Project 2
      +collapses four inline properties into one class, not four `var()` calls.
      +
      +Seven steps, generated from `baseTokens.typography`:
      +
      +| token | `--text-*` | line-height | other |
      +|---|---|---|---|
      +| `h1` | `--text-h1` | 1.08 | |
      +| `h2` | `--text-h2` | 1.12 | |
      +| `h3` | `--text-h3` | 1.25 | `--font-weight: 600` |
      +| `eyebrow` | `--text-eyebrow` | 1.4 | `--font-weight: 700`, `--letter-spacing: 0.12em` |
      +| `bodyLg` | `--text-body-lg` | 1.6 | |
      +| `body` | `--text-body` | 1.6 | |
      +| `caption` | `--text-caption` | 1.5 | |
      +
      +**What deliberately gets no var.** `family` and `transform` are excluded, and a
      +reviewer should reject a version that adds them:
      +
      +- Every `.family` value is *already* `var(--font-garamond)` / `var(--font-inter)`
      +  / `var(--font-mono)`. Those vars exist. Emitting `--text-h1--font-family`
      +  would be a second name for a thing that already has one, and Tailwind's
      +  `--text-*` bundle does not support it anyway.
      +- `eyebrow.transform` is `uppercase` — a plain `text-transform` declaration.
      +  A var indirecting a keyword buys nothing.
      +
      +These two exclusions are why 336 composite references reduce to 210 needing new
      +vars.
      +
      +`@theme` is additive in Tailwind v4, so these sit alongside the built-in
      +`--text-xs … --text-9xl` without collision. No namespace reset.
      +
      +### 2. Emit the space scale
      +
      +```css
      +--spacing-section-y: clamp(64px, 8vw, 120px);
      +--spacing-section-y-tight: clamp(48px, 6vw, 80px);
      +--spacing-container-x: clamp(20px, 4vw, 40px);
      +--container-page: 1200px;
      +```
      +
      +`containerMax` goes to the `--container-*` namespace rather than `--spacing-*`
      +because it is a max-width, not a spacing step.
      +
      +Tailwind strips the namespace prefix when it names the utility, so
      +`--container-page` generates **`max-w-page`**, not `max-w-container-page`.
      +`--spacing-*` behaves the same way (`--spacing-section-y` → `p-section-y`).
      +Measured after implementation: `max-w-page` computes to `1200px`,
      +`p-section-y` to `64px` at a 320px viewport (the `clamp` lower bound), and
      +`text-h2` to `36px` with a `40.32px` line-height — confirming the
      +`--text-*--line-height` sub-key is honoured.
      +
      +Seven references, four vars. Small, but it is the difference between `Section`
      +and `Container` being migratable in Project 2 and not.
      +
      +### 3. Resolve the hardcoded literals in `global.css`
      +
      +Three categories, three different answers. The categorisation is the actual
      +work here; the edits are trivial.
      +
      +**Genuine drift — adopt the live token, accept a visible change.**
      +Only three of the five stale-literal uses actually render stale. Two are
      +already `var(--color-text-muted, #555770)` — on `li::marker` (line 139) and
      +`figcaption` (line 170) — where the var *is* defined, so the fallback is dead
      +text and deleting it changes nothing. The visible set is exactly:
      +
      +| line | rule | now | becomes |
      +|---|---|---|---|
      +| 195 | `.docs-prose th` | `#555770` | `var(--color-text-muted)` |
      +| 196 | `.docs-prose td` | `#555770` | `var(--color-text-secondary)` |
      +| 100 | `[data-rehype-pretty-code-title]` | `#8b8fa3` | `var(--color-text-muted)` |
      +
      +`th` and `td` deliberately diverge rather than both taking one token: `td` is
      +body content and should match `--tw-prose-body` (which `MdxRenderer` already
      +sets to `colors.textSecondary`), while `th` is an uppercase mono label and
      +belongs with the other muted labels, like the TOC heading.
      +
      +This is a **visible design change, not a refactor** — it needs before/after
      +screenshots at review, and it is the one part of this project that could
      +reasonably be rejected on taste. If it is rejected, the correct outcome is a new
      +token, not a retained literal.
      +
      +**Already-matching literals — swap, no visual change.**
      +`#004090` → `var(--color-accent)`, `rgba(0, 64, 144, 0.06)` →
      +`var(--color-accent-surface)`, `rgba(0, 64, 144, 0.15)` →
      +`var(--color-accent-border)`. Byte-identical output; a screenshot diff must show
      +nothing.
      +
      +**Literals that are not tokens — keep local, name them.** Three sets do not
      +belong in the design system and must not be promoted into it:
      +
      +- `#1a1b26` (×2), `rgba(0, 0, 0, 0.08 / 0.1)` and `rgba(255, 255, 255, 0.06)`
      +  are all code-figure chrome — the tokyo-night background plus the border and
      +  shadow tuned against it. They are coupled to `rehypeOptions.theme` in
      +  `MdxRenderer`, so they become a local `--docs-code-bg` / `--docs-code-border`
      +  / `--docs-code-shadow` group in `global.css`, commented with that coupling.
      +  Promoting them to the token package would imply the design system owns the
      +  syntax theme. It does not.
      +- `rgba(0, 32, 72, 0.08 / 0.1)` are figure-shadow tints → local
      +  `--docs-figure-shadow`.
      +- `rgba(0, 64, 144, 0.035 / 0.08 / 0.1)` are accent tints that fall between
      +  `--color-accent-surface` (0.06) and `--color-accent-border` (0.15). Rather
      +  than invent three tokens for three call sites, derive them:
      +  `color-mix(in srgb, var(--color-accent) 3.5%, transparent)`. They then track
      +  the accent automatically. **Open question for review:** `color-mix` is
      +  baseline across current browsers but this is the only use in the codebase; if
      +  that is unwanted, three local `--docs-accent-tint-*` vars are the fallback.
      +
      +  **Resolved 2026-08-29:** measured in a browser after implementation. The `td`
      +  border computes to `color(srgb 0 0.25098 0.564706 / 0.08)` — `#004090` at 8%,
      +  numerically identical to the `rgba(0, 64, 144, 0.08)` it replaced. `color-mix`
      +  stays.
      +
      +### 4. The third surface
      +
      +`libs/design-tokens/src/lib/tokens.css` defines a `--ds-*` set whose values have
      +drifted from `light.ts` — `--ds-text-secondary: #555770` against
      +`rgb(70, 70, 70)`. It is where the `global.css` drift above came from.
      +
      +Measured state: **zero importers.** It is not in the package `exports` map
      +(only `.` and `./theme.css` are), and nothing in the repo imports it. Forty-five
      +cockpit and example files reference `--ds-*` properties, but every one supplies
      +a fallback — `var(--ds-canvas, #111)` — so those apps are rendering on their
      +fallbacks today and have been all along.
      +
      +**Decision: bring `tokens.css` under the generator**, emitting `--ds-*` from
      +`light.ts` so the third surface can no longer disagree with the first two. Not
      +deletion: the file records an intent (a plain-CSS token drop for non-Tailwind
      +consumers) that the cockpit apps clearly still want, and deleting it silently
      +blesses 45 files running on fallback colours.
      +
      +Actually wiring those cockpit apps to import it is **out of scope** and wants
      +its own ticket — it is a visual change to nine example apps, unrelated to the
      +website.
      +
      +### 5. Machine-check the parity
      +
      +This is the load-bearing deliverable. Project 2 rewrites 1,188 call sites on the
      +premise that `tokens.X.Y` and its CSS var hold the same value. That premise
      +should be a test, not a belief.
      +
      +A new spec in `libs/design-tokens`:
      +
      +- Walks the `tokens` object.
      +- For every leaf with a designated CSS-var counterpart, asserts the value in
      +  `theme.css` is string-identical.
      +- Asserts every leaf either has a counterpart or is on an explicit, commented
      +  exclusion list (`.family`, `.transform`, `light`, `dark`).
      +
      +The exclusion list is the point — it is what stops a future token being added
      +with no CSS var and nobody noticing until Project 2 hits it.
      +
      +The existing `generate-theme-css.spec.ts` drift guard (re-runs the generator,
      +diffs against the committed file) already covers staleness and needs no change
      +beyond the new output.
      +
      +## Files
      +
      +| file | change |
      +|---|---|
      +| `libs/design-tokens/scripts/generate-theme-css.ts` | emit type scale + space scale; emit `tokens.css` |
      +| `libs/design-tokens/src/lib/theme.css` | regenerated |
      +| `libs/design-tokens/src/lib/tokens.css` | regenerated from `light.ts` |
      +| `libs/design-tokens/src/lib/token-css-parity.spec.ts` | new — JS↔CSS value parity |
      +| `libs/design-tokens/src/lib/ds-var-contract.spec.ts` | new — `--ds-*` name stability |
      +| `libs/design-tokens/src/lib/generate-theme-css.spec.ts` | extend to cover `tokens.css` |
      +| `libs/design-tokens/package.json` | export `./tokens.css` |
      +| `libs/design-tokens/project.json` | ship `tokens.css` in the build assets |
      +| `apps/website/src/app/global.css` | literal audit — 18 literals, 3 categories |
      +
      +Nine files. No component touched.
      +
      +The two new specs stay separate because they guard different contracts —
      +value parity between JS and CSS, versus stability of the `--ds-*` names
      +cockpit apps reference — and each should be able to fail with a clear,
      +unambiguous message.
      +
      +## Verification
      +
      +- `nx test design-tokens` — parity spec and both drift guards.
      +- `cd apps/website && npx vitest run --config vite.config.mts` — the website has
      +  **no `nx test` target**; `nx test website` fails and 20+ specs silently stopped
      +  running once before. Use the direct invocation.
      +- `nx build website --configuration=production` before claiming deploy-ready —
      +  the prod bundle-budget and env wiring differ from dev.
      +- Screenshot diff on `/docs/chat/components/chat`, `/docs`, and `/` at 1280 and
      +  375. Expected: **no change anywhere except docs table header text and docs
      +  table body text.** Any other delta — including list markers and figure
      +  captions, whose `#555770` is a dead fallback — is a bug in the literal audit.
      +
      +  The code-title colour changes too, but renders nowhere: no code fence in the
      +  repo uses the `title=` meta, so `[data-rehype-pretty-code-title]` is never
      +  generated. Verified in a browser, recorded in the findings audit §9.
      +
      +The parity spec fails silently if written wrong — a walker that visits nothing
      +passes. Mutation-test it: change one value in `light.ts` without regenerating,
      +confirm red. See `feedback_tests_that_pass_vacuously`.
      +
      +## Risks
      +
      +- **The `#555770` change is visible and subjective.** Called out above rather
      +  than buried; it is the one reviewable design decision in an otherwise
      +  mechanical project.
      +- **`@theme` additions generate utilities.** `--text-body` produces a `text-body`
      +  class. Harmless, but it widens the utility surface; worth a glance at the
      +  generated CSS size.
      +- **`color-mix`** — flagged as an open question in §3.
      +- **This project unblocks, but does not deliver, anything a reader sees.** The
      +  docs defects in the findings audit stay live through Projects 1 and 2. That
      +  was an accepted trade when the arc was ordered this way; it is recorded here
      +  so nobody rediscovers it as a surprise.
      diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json
      index 106271e0a..3531bb954 100644
      --- a/libs/design-tokens/package.json
      +++ b/libs/design-tokens/package.json
      @@ -7,7 +7,8 @@
             "types": "./src/index.d.ts",
             "default": "./src/index.js"
           },
      -    "./theme.css": "./src/lib/theme.css"
      +    "./theme.css": "./src/lib/theme.css",
      +    "./tokens.css": "./src/lib/tokens.css"
         },
         "repository": {
           "type": "git",
      diff --git a/libs/design-tokens/project.json b/libs/design-tokens/project.json
      index c89bee7eb..83c8b08fb 100644
      --- a/libs/design-tokens/project.json
      +++ b/libs/design-tokens/project.json
      @@ -24,7 +24,7 @@
               "assets": [
                 {
                   "input": "libs/design-tokens/src/lib",
      -            "glob": "theme.css",
      +            "glob": "*.css",
                   "output": "src/lib"
                 }
               ]
      diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts
      index a6b337323..463b240e6 100644
      --- a/libs/design-tokens/scripts/generate-theme-css.ts
      +++ b/libs/design-tokens/scripts/generate-theme-css.ts
      @@ -17,6 +17,7 @@ import { lightOverrides } from '../src/lib/light';
       
       const HERE = fileURLToPath(new URL('.', import.meta.url));
       const OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'theme.css');
      +const TOKENS_OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'tokens.css');
       
       const HEADER = `/*
        * @threadplane/design-tokens/theme.css
      @@ -24,7 +25,7 @@ const HEADER = `/*
        * GENERATED FILE — DO NOT EDIT BY HAND.
        *
        * Regenerate with:
      - *   pnpm nx run design-tokens:generate-theme-css
      + *   npx nx run design-tokens:generate-theme-css
        *
        * Source of truth:
        *   - libs/design-tokens/src/lib/light.ts
      @@ -36,7 +37,7 @@ const HEADER = `/*
       `;
       
       function buildThemeBlock(): string {
      -  const { typography, radius, shadows, brand } = baseTokens;
      +  const { typography, space, radius, shadows, brand } = baseTokens;
       
         const lines: string[] = ['@theme {'];
       
      @@ -88,6 +89,40 @@ function buildThemeBlock(): string {
         lines.push(`  --font-inter: ${typography.fontSans};`);
         lines.push(`  --font-mono: ${typography.fontMono};`);
       
      +  // Type scale — Tailwind v4 composite text tokens.
      +  //
      +  // `--text-{name}` plus the optional `--line-height` / `--font-weight` /
      +  // `--letter-spacing` sub-keys collapse a whole type step into a single
      +  // `text-{name}` utility, which is an exact structural match for the
      +  // composite objects in typography.ts.
      +  //
      +  // `family` is deliberately not emitted: those values are already
      +  // `var(--font-garamond)` and friends, and Tailwind's --text-* bundle has no
      +  // font-family sub-key. `eyebrow.transform` is likewise a plain
      +  // `text-transform` keyword, not a token. Both are excluded in
      +  // token-css-parity.spec.ts with that reasoning.
      +  lines.push('');
      +  lines.push('  /* Type scale */');
      +  const typeSteps = [
      +    ['h1', typography.h1],
      +    ['h2', typography.h2],
      +    ['h3', typography.h3],
      +    ['eyebrow', typography.eyebrow],
      +    ['body-lg', typography.bodyLg],
      +    ['body', typography.body],
      +    ['caption', typography.caption],
      +  ] as const;
      +  for (const [name, step] of typeSteps) {
      +    lines.push(`  --text-${name}: ${step.size};`);
      +    lines.push(`  --text-${name}--line-height: ${step.line};`);
      +    if ('weight' in step) {
      +      lines.push(`  --text-${name}--font-weight: ${step.weight};`);
      +    }
      +    if ('letterSpacing' in step) {
      +      lines.push(`  --text-${name}--letter-spacing: ${step.letterSpacing};`);
      +    }
      +  }
      +
         // Radii
         lines.push('');
         lines.push('  /* Radii */');
      @@ -105,19 +140,144 @@ function buildThemeBlock(): string {
         lines.push(`  --shadow-lg: ${shadows.lg};`);
         lines.push(`  --shadow-focus: ${shadows.focus};`);
       
      +  // Space scale.
      +  //
      +  // `containerMax` goes to the --container-* namespace, not --spacing-*,
      +  // because it is a max-width rather than a spacing step.
      +  //
      +  // Tailwind strips the namespace prefix when naming the utility, so
      +  // `--container-page` generates `max-w-page` (NOT `max-w-container-page`).
      +  // Verified in a browser: `max-w-page` computes to 1200px. `--spacing-*`
      +  // behaves the same way — `--spacing-section-y` gives `p-section-y`.
      +  lines.push('');
      +  lines.push('  /* Space scale */');
      +  lines.push(`  --spacing-section-y: ${space.sectionY};`);
      +  lines.push(`  --spacing-section-y-tight: ${space.sectionYTight};`);
      +  lines.push(`  --spacing-container-x: ${space.containerX};`);
      +  lines.push(`  --container-page: ${space.containerMax};`);
      +
         lines.push('}');
         return lines.join('\n') + '\n';
       }
       
      +const TOKENS_HEADER = `/*
      + * @threadplane/design-tokens/tokens.css
      + *
      + * GENERATED FILE — DO NOT EDIT BY HAND.
      + *
      + * Plain \`:root { --ds-* }\` custom properties for consumers that do not run
      + * Tailwind (the Angular cockpit and example apps). Same values as theme.css,
      + * different naming convention and no \`@theme\` wrapper.
      + *
      + * Regenerate with:
      + *   npx nx run design-tokens:generate-theme-css
      + *
      + * Source of truth:
      + *   - libs/design-tokens/src/lib/light.ts
      + *   - libs/design-tokens/src/lib/base.ts
      + *
      + * The names here are a consumer contract — cockpit and example apps reference
      + * them. ds-var-contract.spec.ts fails if one disappears.
      + */
      +`;
      +
      +function buildTokensBlock(): string {
      +  const { typography, space, radius, shadows, brand } = baseTokens;
      +  const lines: string[] = [':root {'];
      +
      +  lines.push('  /* Colors */');
      +  lines.push(`  --ds-bg: ${lightOverrides.bg};`);
      +  lines.push(`  --ds-accent: ${lightOverrides.accent};`);
      +  lines.push(`  --ds-accent-hover: ${lightOverrides.accentHover};`);
      +  lines.push(`  --ds-accent-light: ${brand.accentLight};`);
      +  lines.push(`  --ds-accent-glow: ${lightOverrides.accentGlow};`);
      +  lines.push(`  --ds-accent-border: ${lightOverrides.accentBorder};`);
      +  lines.push(`  --ds-accent-border-hover: ${lightOverrides.accentBorderHover};`);
      +  lines.push(`  --ds-accent-surface: ${lightOverrides.accentSurface};`);
      +  lines.push(`  --ds-text-primary: ${lightOverrides.textPrimary};`);
      +  lines.push(`  --ds-text-secondary: ${lightOverrides.textSecondary};`);
      +  lines.push(`  --ds-text-muted: ${lightOverrides.textMuted};`);
      +  lines.push(`  --ds-text-inverted: ${lightOverrides.textInverted};`);
      +  lines.push(`  --ds-sidebar-bg: ${lightOverrides.sidebarBg};`);
      +  lines.push(`  --ds-angular-red: ${brand.angularRed};`);
      +  lines.push(`  --ds-render-green: ${brand.renderGreen};`);
      +  lines.push(`  --ds-chat-purple: ${brand.chatPurple};`);
      +
      +  lines.push('');
      +  lines.push('  /* Surfaces */');
      +  lines.push(`  --ds-canvas: ${lightOverrides.canvas};`);
      +  lines.push(`  --ds-surface: ${lightOverrides.surface};`);
      +  lines.push(`  --ds-surface-tinted: ${lightOverrides.surfaceTinted};`);
      +  lines.push(`  --ds-surface-dim: ${lightOverrides.surfaceDim};`);
      +  lines.push(`  --ds-border: ${lightOverrides.border};`);
      +  lines.push(`  --ds-border-strong: ${lightOverrides.borderStrong};`);
      +
      +  lines.push('');
      +  lines.push('  /* Typography */');
      +  lines.push(`  --ds-font-serif: ${typography.fontSerif};`);
      +  lines.push(`  --ds-font-sans: ${typography.fontSans};`);
      +  lines.push(`  --ds-font-mono: ${typography.fontMono};`);
      +
      +  lines.push('');
      +  lines.push('  /* Typography — type scale */');
      +  // `-spacing` (not `-letter-spacing`) preserves the pre-existing name.
      +  const dsSteps = [
      +    ['h1', typography.h1],
      +    ['h2', typography.h2],
      +    ['h3', typography.h3],
      +    ['eyebrow', typography.eyebrow],
      +    ['body-lg', typography.bodyLg],
      +    ['body', typography.body],
      +    ['caption', typography.caption],
      +  ] as const;
      +  for (const [name, step] of dsSteps) {
      +    lines.push(`  --ds-${name}-size: ${step.size};`);
      +    lines.push(`  --ds-${name}-line: ${step.line};`);
      +    if ('weight' in step) lines.push(`  --ds-${name}-weight: ${step.weight};`);
      +    if ('letterSpacing' in step) {
      +      lines.push(`  --ds-${name}-spacing: ${step.letterSpacing};`);
      +    }
      +  }
      +
      +  lines.push('');
      +  lines.push('  /* Shadows */');
      +  lines.push(`  --ds-shadow-sm: ${shadows.sm};`);
      +  lines.push(`  --ds-shadow-md: ${shadows.md};`);
      +  lines.push(`  --ds-shadow-lg: ${shadows.lg};`);
      +  lines.push(`  --ds-shadow-focus: ${shadows.focus};`);
      +
      +  lines.push('');
      +  lines.push('  /* Radius */');
      +  lines.push(`  --ds-radius-sm: ${radius.sm};`);
      +  lines.push(`  --ds-radius-md: ${radius.md};`);
      +  lines.push(`  --ds-radius-lg: ${radius.lg};`);
      +  lines.push(`  --ds-radius-xl: ${radius.xl};`);
      +  lines.push(`  --ds-radius-full: ${radius.full};`);
      +
      +  lines.push('');
      +  lines.push('  /* Space */');
      +  lines.push(`  --ds-section-y: ${space.sectionY};`);
      +  lines.push(`  --ds-section-y-tight: ${space.sectionYTight};`);
      +  lines.push(`  --ds-container-x: ${space.containerX};`);
      +  lines.push(`  --ds-container-max: ${space.containerMax};`);
      +
      +  lines.push('}');
      +  return lines.join('\n') + '\n';
      +}
      +
      +export function generateTokensCss(): string {
      +  return TOKENS_HEADER + buildTokensBlock();
      +}
      +
       export function generateThemeCss(): string {
         return HEADER + buildThemeBlock();
       }
       
       function main() {
      -  const content = generateThemeCss();
      -  writeFileSync(OUTPUT_PATH, content);
      -  // eslint-disable-next-line no-console
      +  writeFileSync(OUTPUT_PATH, generateThemeCss());
      +  writeFileSync(TOKENS_OUTPUT_PATH, generateTokensCss());
         console.log(`wrote ${OUTPUT_PATH}`);
      +  console.log(`wrote ${TOKENS_OUTPUT_PATH}`);
       }
       
       // Only run main when invoked directly (not when imported by tests)
      diff --git a/libs/design-tokens/src/lib/ds-var-contract.spec.ts b/libs/design-tokens/src/lib/ds-var-contract.spec.ts
      new file mode 100644
      index 000000000..e3cac8c7c
      --- /dev/null
      +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts
      @@ -0,0 +1,62 @@
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +
      +const TOKENS_CSS = resolve(__dirname, 'tokens.css');
      +
      +/**
      + * The `--ds-*` names that cockpit and example apps actually reference today.
      + *
      + * They all reference them with fallbacks and nothing imports tokens.css yet,
      + * so dropping a name causes no immediate breakage — it would just silently
      + * pin those apps to their fallback colours forever. Hence this list.
      + *
      + * Derived from:
      + *   grep -rhoE -- "--ds-[a-z0-9-]+" cockpit examples apps | sort -u
      + * intersected with the names tokens.css defined before it came under the
      + * generator. Add to this list when a consumer starts using a new name.
      + */
      +const CONSUMER_REFERENCED = [
      +  '--ds-accent',
      +  '--ds-accent-border',
      +  '--ds-accent-glow',
      +  '--ds-accent-hover',
      +  '--ds-accent-surface',
      +  '--ds-border',
      +  '--ds-border-strong',
      +  '--ds-canvas',
      +  '--ds-font-mono',
      +  '--ds-font-sans',
      +  '--ds-font-serif',
      +  '--ds-radius-lg',
      +  '--ds-radius-md',
      +  '--ds-radius-sm',
      +  '--ds-radius-xl',
      +  '--ds-render-green',
      +  '--ds-shadow-lg',
      +  '--ds-shadow-md',
      +  '--ds-surface',
      +  '--ds-surface-dim',
      +  '--ds-surface-tinted',
      +  '--ds-text-inverted',
      +  '--ds-text-muted',
      +  '--ds-text-primary',
      +  '--ds-text-secondary',
      +] as const;
      +
      +function definedNames(css: string): Set {
      +  return new Set([...css.matchAll(/^\s*(--ds-[a-z0-9-]+):/gm)].map((m) => m[1]));
      +}
      +
      +describe('--ds-* consumer contract', () => {
      +  const defined = definedNames(readFileSync(TOKENS_CSS, 'utf-8'));
      +
      +  it('parses a non-trivial number of names (guards a regex that matches nothing)', () => {
      +    expect(defined.size).toBeGreaterThan(20);
      +  });
      +
      +  it('defines every --ds-* name a cockpit or example app references', () => {
      +    const missing = CONSUMER_REFERENCED.filter((n) => !defined.has(n));
      +    expect(missing).toEqual([]);
      +  });
      +});
      diff --git a/libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts b/libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts
      new file mode 100644
      index 000000000..f9d304660
      --- /dev/null
      +++ b/libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts
      @@ -0,0 +1,46 @@
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +import { cssVars } from './css-vars';
      +
      +const TOKENS_CSS = resolve(__dirname, 'tokens.css');
      +
      +/**
      + * `cssVars('light')` and the generated tokens.css both emit the `--ds-*`
      + * namespace from the same TypeScript sources, as two independently maintained
      + * lists. Nothing structurally prevents them drifting, so this asserts they
      + * agree.
      + *
      + * tokens.css is a superset: it also carries the type scale (`--ds-h1-size`,
      + * ...), which `cssVars` does not emit. That direction is allowed; the reverse
      + * is not.
      + */
      +function parseDsVars(css: string): Record {
      +  const out: Record = {};
      +  for (const m of css.matchAll(/^\s*(--ds-[a-z0-9-]+):\s*(.+?);\s*$/gm)) {
      +    out[m[1]] = m[2].trim();
      +  }
      +  return out;
      +}
      +
      +describe('--ds-* emitters agree', () => {
      +  const fromCss = parseDsVars(readFileSync(TOKENS_CSS, 'utf-8'));
      +  const fromFn = cssVars('light') as Record;
      +
      +  it('parses a non-trivial number of names from each source', () => {
      +    expect(Object.keys(fromCss).length).toBeGreaterThan(30);
      +    expect(Object.keys(fromFn).length).toBeGreaterThan(30);
      +  });
      +
      +  it('tokens.css defines every name cssVars(light) emits', () => {
      +    const missing = Object.keys(fromFn).filter((k) => !(k in fromCss));
      +    expect(missing).toEqual([]);
      +  });
      +
      +  it('agrees on every shared value', () => {
      +    const mismatches = Object.keys(fromFn)
      +      .filter((k) => k in fromCss && fromCss[k] !== String(fromFn[k]))
      +      .map((k) => `${k}: css=${fromCss[k]} fn=${String(fromFn[k])}`);
      +    expect(mismatches).toEqual([]);
      +  });
      +});
      diff --git a/libs/design-tokens/src/lib/generate-theme-css.spec.ts b/libs/design-tokens/src/lib/generate-theme-css.spec.ts
      index 2ac73a0b7..ad2d820b8 100644
      --- a/libs/design-tokens/src/lib/generate-theme-css.spec.ts
      +++ b/libs/design-tokens/src/lib/generate-theme-css.spec.ts
      @@ -1,9 +1,10 @@
       import { readFileSync } from 'node:fs';
       import { resolve } from 'node:path';
       import { describe, it, expect } from 'vitest';
      -import { generateThemeCss } from '../../scripts/generate-theme-css';
      +import { generateThemeCss, generateTokensCss } from '../../scripts/generate-theme-css';
       
       const COMMITTED_PATH = resolve(__dirname, 'theme.css');
      +const COMMITTED_TOKENS_PATH = resolve(__dirname, 'tokens.css');
       
       describe('generate-theme-css', () => {
         it('produces output that matches the committed theme.css', () => {
      @@ -11,4 +12,10 @@ describe('generate-theme-css', () => {
           const actual = generateThemeCss();
           expect(actual).toBe(expected);
         });
      +
      +  it('produces output that matches the committed tokens.css', () => {
      +    const expected = readFileSync(COMMITTED_TOKENS_PATH, 'utf-8');
      +    const actual = generateTokensCss();
      +    expect(actual).toBe(expected);
      +  });
       });
      diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css
      index dbd03375f..a3fc033c6 100644
      --- a/libs/design-tokens/src/lib/theme.css
      +++ b/libs/design-tokens/src/lib/theme.css
      @@ -4,7 +4,7 @@
        * GENERATED FILE — DO NOT EDIT BY HAND.
        *
        * Regenerate with:
      - *   pnpm nx run design-tokens:generate-theme-css
      + *   npx nx run design-tokens:generate-theme-css
        *
        * Source of truth:
        *   - libs/design-tokens/src/lib/light.ts
      @@ -51,6 +51,25 @@
         --font-inter: Inter, system-ui, sans-serif;
         --font-mono: "JetBrains Mono", monospace;
       
      +  /* Type scale */
      +  --text-h1: clamp(48px, 6vw, 72px);
      +  --text-h1--line-height: 1.08;
      +  --text-h2: clamp(36px, 4.5vw, 56px);
      +  --text-h2--line-height: 1.12;
      +  --text-h3: 28px;
      +  --text-h3--line-height: 1.25;
      +  --text-h3--font-weight: 600;
      +  --text-eyebrow: 12px;
      +  --text-eyebrow--line-height: 1.4;
      +  --text-eyebrow--font-weight: 700;
      +  --text-eyebrow--letter-spacing: 0.12em;
      +  --text-body-lg: 20px;
      +  --text-body-lg--line-height: 1.6;
      +  --text-body: 16px;
      +  --text-body--line-height: 1.6;
      +  --text-caption: 14px;
      +  --text-caption--line-height: 1.5;
      +
         /* Radii */
         --radius-sm: 6px;
         --radius-md: 10px;
      @@ -63,4 +82,10 @@
         --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.10), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
         --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
         --shadow-focus: 0 0 0 3px rgba(0, 64, 144, 0.25);
      +
      +  /* Space scale */
      +  --spacing-section-y: clamp(64px, 8vw, 120px);
      +  --spacing-section-y-tight: clamp(48px, 6vw, 80px);
      +  --spacing-container-x: clamp(20px, 4vw, 40px);
      +  --container-page: 1200px;
       }
      diff --git a/libs/design-tokens/src/lib/token-css-parity.spec.ts b/libs/design-tokens/src/lib/token-css-parity.spec.ts
      new file mode 100644
      index 000000000..2228813cb
      --- /dev/null
      +++ b/libs/design-tokens/src/lib/token-css-parity.spec.ts
      @@ -0,0 +1,187 @@
      +import { readFileSync } from 'node:fs';
      +import { resolve } from 'node:path';
      +import { describe, it, expect } from 'vitest';
      +import { tokens } from './tokens';
      +
      +const THEME_CSS = resolve(__dirname, 'theme.css');
      +
      +/**
      + * Every leaf in the `tokens` tree must either map to a CSS custom property in
      + * theme.css (holding an identical value) or appear in EXCLUDED with a reason.
      + *
      + * This is the premise the inline-style migration rests on: `tokens.X.Y` and
      + * `var(--z)` are interchangeable. Do not weaken this test to make a new token
      + * pass — add the token to the generator, or exclude it here with a comment
      + * saying why it can never have a var.
      + */
      +const CSS_VAR_BY_PATH: Record = {
      +  // Brand (theme-invariant)
      +  'brand.accent': '--color-accent',
      +  'brand.accentLight': '--color-accent-light',
      +  'brand.angularRed': '--color-angular-red',
      +  'brand.renderGreen': '--color-render-green',
      +  'brand.chatPurple': '--color-chat-purple',
      +
      +  // Font families
      +  'typography.fontSerif': '--font-garamond',
      +  'typography.fontSans': '--font-inter',
      +  'typography.fontMono': '--font-mono',
      +
      +  // Type scale — size
      +  'typography.h1.size': '--text-h1',
      +  'typography.h2.size': '--text-h2',
      +  'typography.h3.size': '--text-h3',
      +  'typography.eyebrow.size': '--text-eyebrow',
      +  'typography.bodyLg.size': '--text-body-lg',
      +  'typography.body.size': '--text-body',
      +  'typography.caption.size': '--text-caption',
      +
      +  // Type scale — line height
      +  'typography.h1.line': '--text-h1--line-height',
      +  'typography.h2.line': '--text-h2--line-height',
      +  'typography.h3.line': '--text-h3--line-height',
      +  'typography.eyebrow.line': '--text-eyebrow--line-height',
      +  'typography.bodyLg.line': '--text-body-lg--line-height',
      +  'typography.body.line': '--text-body--line-height',
      +  'typography.caption.line': '--text-caption--line-height',
      +
      +  // Type scale — weight / tracking
      +  'typography.h3.weight': '--text-h3--font-weight',
      +  'typography.eyebrow.weight': '--text-eyebrow--font-weight',
      +  'typography.eyebrow.letterSpacing': '--text-eyebrow--letter-spacing',
      +
      +  // Space scale
      +  'space.sectionY': '--spacing-section-y',
      +  'space.sectionYTight': '--spacing-section-y-tight',
      +  'space.containerX': '--spacing-container-x',
      +  'space.containerMax': '--container-page',
      +
      +  // Radii
      +  'radius.sm': '--radius-sm',
      +  'radius.md': '--radius-md',
      +  'radius.lg': '--radius-lg',
      +  'radius.xl': '--radius-xl',
      +  'radius.full': '--radius-full',
      +
      +  // Shadows
      +  'shadows.sm': '--shadow-sm',
      +  'shadows.md': '--shadow-md',
      +  'shadows.lg': '--shadow-lg',
      +  'shadows.focus': '--shadow-focus',
      +
      +  // Light-resolved colour aliases (what the website actually imports)
      +  'colors.accent': '--color-accent',
      +  'colors.accentLight': '--color-accent-light',
      +  'colors.angularRed': '--color-angular-red',
      +  'colors.renderGreen': '--color-render-green',
      +  'colors.chatPurple': '--color-chat-purple',
      +  'colors.bg': '--color-bg',
      +  'colors.accentHover': '--color-accent-hover',
      +  'colors.accentGlow': '--color-accent-glow',
      +  'colors.accentBorder': '--color-accent-border',
      +  'colors.accentBorderHover': '--color-accent-border-hover',
      +  'colors.accentSurface': '--color-accent-surface',
      +  'colors.textInverted': '--color-text-inverted',
      +  'colors.textPrimary': '--color-text-primary',
      +  'colors.textSecondary': '--color-text-secondary',
      +  'colors.textMuted': '--color-text-muted',
      +  'colors.sidebarBg': '--color-sidebar-bg',
      +
      +  // Light-resolved surface aliases
      +  'surfaces.canvas': '--color-canvas',
      +  'surfaces.surface': '--color-surface',
      +  'surfaces.surfaceTinted': '--color-surface-tinted',
      +  'surfaces.surfaceDim': '--color-surface-dim',
      +  'surfaces.border': '--color-border',
      +  'surfaces.borderStrong': '--color-border-strong',
      +};
      +
      +/** Leaves that intentionally have no CSS var, with the reason. */
      +const EXCLUDED: ReadonlyArray<{ prefix: string; why: string }> = [
      +  {
      +    prefix: 'typography.h1.family',
      +    why: 'value is already `var(--font-garamond)` — a var about a var buys nothing',
      +  },
      +  { prefix: 'typography.h2.family', why: 'see h1.family' },
      +  { prefix: 'typography.h3.family', why: 'see h1.family' },
      +  { prefix: 'typography.eyebrow.family', why: 'see h1.family' },
      +  { prefix: 'typography.bodyLg.family', why: 'see h1.family' },
      +  { prefix: 'typography.body.family', why: 'see h1.family' },
      +  { prefix: 'typography.caption.family', why: 'see h1.family' },
      +  {
      +    prefix: 'typography.eyebrow.transform',
      +    why: 'plain `text-transform: uppercase` keyword; Tailwind --text-* has no transform sub-key',
      +  },
      +  {
      +    prefix: 'light.',
      +    why: 'theme-resolution source; consumed via the colors/surfaces aliases which are mapped',
      +  },
      +  { prefix: 'dark.', why: 'dark theme is not emitted — the website is light-only' },
      +];
      +
      +function parseCssVars(css: string): Record {
      +  const out: Record = {};
      +  for (const m of css.matchAll(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/gm)) {
      +    out[m[1]] = m[2].trim();
      +  }
      +  return out;
      +}
      +
      +/** Flatten the frozen token tree to `dotted.path -> primitive value`. */
      +function flatten(node: unknown, prefix = ''): Array<[string, string]> {
      +  if (node === null || typeof node !== 'object') {
      +    return [[prefix, String(node)]];
      +  }
      +  return Object.entries(node as Record).flatMap(([k, v]) =>
      +    flatten(v, prefix ? `${prefix}.${k}` : k),
      +  );
      +}
      +
      +const leaves = flatten(tokens);
      +const cssVars = parseCssVars(readFileSync(THEME_CSS, 'utf-8'));
      +const isExcluded = (path: string) =>
      +  EXCLUDED.some((e) => path === e.prefix || path.startsWith(e.prefix));
      +
      +describe('token ↔ CSS var parity', () => {
      +  it('finds a non-trivial number of token leaves (guards against a walker that visits nothing)', () => {
      +    expect(leaves.length).toBeGreaterThan(60);
      +  });
      +
      +  it('parses a non-trivial number of vars from theme.css', () => {
      +    expect(Object.keys(cssVars).length).toBeGreaterThan(30);
      +  });
      +
      +  it('maps or explicitly excludes every token leaf', () => {
      +    const unaccounted = leaves
      +      .map(([path]) => path)
      +      .filter((path) => !CSS_VAR_BY_PATH[path] && !isExcluded(path));
      +    expect(unaccounted).toEqual([]);
      +  });
      +
      +  // The `light.` exclusion above rests on every light-theme value being
      +  // reachable through the `colors.*` / `surfaces.*` aliases, which ARE mapped.
      +  // Nothing enforced that, so a new token in light.ts could slip through the
      +  // exclusion unmapped. This closes it.
      +  it('every light.* value is reachable through a mapped colors/surfaces alias', () => {
      +    const aliased = new Map(
      +      leaves
      +        .filter(([p]) => p.startsWith('colors.') || p.startsWith('surfaces.'))
      +        .map(([, v]) => [v, true]),
      +    );
      +    const unreachable = leaves
      +      .filter(([p]) => p.startsWith('light.'))
      +      .filter(([, v]) => !aliased.has(v))
      +      .map(([p]) => p);
      +    expect(unreachable).toEqual([]);
      +  });
      +
      +  it.each(Object.entries(CSS_VAR_BY_PATH))(
      +    '%s has an identical value in theme.css as %s',
      +    (path, varName) => {
      +      const leaf = leaves.find(([p]) => p === path);
      +      if (!leaf) throw new Error(`token path ${path} does not exist`);
      +      expect(cssVars[varName], `${varName} missing from theme.css`).toBeDefined();
      +      expect(cssVars[varName]).toBe(leaf[1]);
      +    },
      +  );
      +});
      diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css
      index 35b272a38..5c1f2978a 100644
      --- a/libs/design-tokens/src/lib/tokens.css
      +++ b/libs/design-tokens/src/lib/tokens.css
      @@ -1,44 +1,77 @@
      -/**
      - * Design Tokens — CSS Custom Properties
      +/*
      + * @threadplane/design-tokens/tokens.css
        *
      - * Single source of truth for the Threadplane design system.
      - * Import this file in any app to get all tokens as CSS vars.
      + * GENERATED FILE — DO NOT EDIT BY HAND.
        *
      - * Variable naming: --ds-{category}-{name}
      - * Matches the TS token objects in this library.
      + * Plain `:root { --ds-* }` custom properties for consumers that do not run
      + * Tailwind (the Angular cockpit and example apps). Same values as theme.css,
      + * different naming convention and no `@theme` wrapper.
      + *
      + * Regenerate with:
      + *   npx nx run design-tokens:generate-theme-css
      + *
      + * Source of truth:
      + *   - libs/design-tokens/src/lib/light.ts
      + *   - libs/design-tokens/src/lib/base.ts
      + *
      + * The names here are a consumer contract — cockpit and example apps reference
      + * them. ds-var-contract.spec.ts fails if one disappears.
        */
       :root {
         /* Colors */
      -  --ds-bg: #f8f9fc;
      +  --ds-bg: rgb(255, 255, 255);
         --ds-accent: #004090;
      +  --ds-accent-hover: #003070;
         --ds-accent-light: #64C3FD;
         --ds-accent-glow: rgba(0, 64, 144, 0.2);
         --ds-accent-border: rgba(0, 64, 144, 0.15);
         --ds-accent-border-hover: rgba(0, 64, 144, 0.3);
         --ds-accent-surface: rgba(0, 64, 144, 0.06);
      -  --ds-text-primary: #1a1a2e;
      -  --ds-text-secondary: #555770;
      -  --ds-text-muted: #8b8fa3;
      +  --ds-text-primary: rgb(28, 28, 28);
      +  --ds-text-secondary: rgb(70, 70, 70);
      +  --ds-text-muted: rgb(115, 115, 115);
      +  --ds-text-inverted: rgb(255, 255, 255);
         --ds-sidebar-bg: rgba(255, 255, 255, 0.45);
         --ds-angular-red: #DD0031;
      +  --ds-render-green: #1a7a40;
      +  --ds-chat-purple: #5a00c8;
      +
      +  /* Surfaces */
      +  --ds-canvas: rgb(255, 255, 255);
      +  --ds-surface: rgb(255, 255, 255);
      +  --ds-surface-tinted: rgb(251, 251, 251);
      +  --ds-surface-dim: rgb(245, 245, 245);
      +  --ds-border: rgb(229, 229, 229);
      +  --ds-border-strong: rgb(200, 200, 200);
       
         /* Typography */
      -  --ds-font-serif: 'EB Garamond', Georgia, serif;
      +  --ds-font-serif: "EB Garamond", Georgia, serif;
         --ds-font-sans: Inter, system-ui, sans-serif;
      -  --ds-font-mono: 'JetBrains Mono', monospace;
      +  --ds-font-mono: "JetBrains Mono", monospace;
       
      -  /* Surfaces */
      -  --ds-canvas: #fafbfc;
      -  --ds-surface: #ffffff;
      -  --ds-surface-tinted: #f4f6fb;
      -  --ds-surface-dim: #eef1f7;
      -  --ds-border: #e6e8ee;
      -  --ds-border-strong: #d2d6e0;
      +  /* Typography — type scale */
      +  --ds-h1-size: clamp(48px, 6vw, 72px);
      +  --ds-h1-line: 1.08;
      +  --ds-h2-size: clamp(36px, 4.5vw, 56px);
      +  --ds-h2-line: 1.12;
      +  --ds-h3-size: 28px;
      +  --ds-h3-line: 1.25;
      +  --ds-h3-weight: 600;
      +  --ds-eyebrow-size: 12px;
      +  --ds-eyebrow-line: 1.4;
      +  --ds-eyebrow-weight: 700;
      +  --ds-eyebrow-spacing: 0.12em;
      +  --ds-body-lg-size: 20px;
      +  --ds-body-lg-line: 1.6;
      +  --ds-body-size: 16px;
      +  --ds-body-line: 1.6;
      +  --ds-caption-size: 14px;
      +  --ds-caption-line: 1.5;
       
         /* Shadows */
      -  --ds-shadow-sm: 0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03);
      -  --ds-shadow-md: 0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04);
      -  --ds-shadow-lg: 0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05);
      +  --ds-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
      +  --ds-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.10), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
      +  --ds-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
         --ds-shadow-focus: 0 0 0 3px rgba(0, 64, 144, 0.25);
       
         /* Radius */
      @@ -53,24 +86,4 @@
         --ds-section-y-tight: clamp(48px, 6vw, 80px);
         --ds-container-x: clamp(20px, 4vw, 40px);
         --ds-container-max: 1200px;
      -
      -  /* Colors — extensions */
      -  --ds-accent-hover: #003070;
      -  --ds-text-inverted: #ffffff;
      -
      -  /* Typography — type scale */
      -  --ds-h1-size: clamp(48px, 6vw, 72px);
      -  --ds-h1-line: 1.08;
      -  --ds-h2-size: clamp(36px, 4.5vw, 56px);
      -  --ds-h2-line: 1.12;
      -  --ds-h3-size: 28px;
      -  --ds-h3-line: 1.25;
      -  --ds-h3-weight: 600;
      -  --ds-eyebrow-size: 12px;
      -  --ds-eyebrow-line: 1.4;
      -  --ds-eyebrow-weight: 700;
      -  --ds-eyebrow-spacing: 0.12em;
      -  --ds-body-lg-size: 20px;
      -  --ds-body-size: 16px;
      -  --ds-caption-size: 14px;
       }