From 50f390ad6af6be205134c933aedbb3fd54ab1740 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 10:48:53 -0700 Subject: [PATCH 01/17] docs(specs): design token CSS-var completion + docs visual review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A visual review of the docs site found a set of measured defects: both sticky rails are dead site-wide (body overflow-x makes a scroll container), .docs-table-scroll never scrolls because the table is width:100% with no min-width, and no scroll-margin exists so every deep link lands behind the 81px fixed nav. Roughly half the fixes are inexpressible in the inline style={{}} objects the components use, so the work decomposes into three projects: token CSS-var completion, substrate migration, then the polish arc. Adds the findings audit (evidence log, reproducible) and the spec for project one — emitting the type and space scales that generate-theme-css.ts never learned about, resolving 18 hardcoded literals in global.css, and adding a machine-checked parity test so the migration's core premise is verified rather than assumed. Co-Authored-By: Claude Opus 5 --- .../2026-08-29-docs-visual-review-findings.md | 235 +++++++++++++++ ...-design-token-css-var-completion-design.md | 272 ++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md create mode 100644 docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md 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..4edee353e --- /dev/null +++ b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md @@ -0,0 +1,235 @@ +# 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. + +--- + +## 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/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..7cf10af2a --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-design-token-css-var-completion-design.md @@ -0,0 +1,272 @@ +# 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; that namespace generates the +`max-w-container-page` utility the marketing `Container` primitive wants. + +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.** +`#555770` (×4) and `#8b8fa3` (×1) become `var(--color-text-secondary)` and +`var(--color-text-muted)`. Docs table body text, table headers, list markers, +figure captions, and code-block titles shift from blue-grey to the system +neutrals. 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. + +### 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 | +| `libs/design-tokens/src/lib/generate-theme-css.spec.ts` | extend to cover `tokens.css` | +| `apps/website/src/app/global.css` | literal audit — 18 literals, 3 categories | + +Six files. No component touched. + +## 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 text, table headers, + list markers, figure captions, and code-block titles. Any other delta is a + bug in the literal audit. + +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. From 0083fb49786afd67b92acd7231d16c25a50f68d5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 10:58:39 -0700 Subject: [PATCH 02/17] docs(plans): implementation plan for design token CSS-var completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten TDD tasks: parity spec first (red), then emit the type scale as Tailwind v4 composite --text-* tokens and the space scale, bring the orphaned tokens.css under the generator, ship it, and resolve global.css's 18 literals in three separately-committed categories so the one visible change is isolated. Validated the parity spec's logic against the real tokens in the real vitest runner before writing it down: 104 token leaves, 34 vars parsed today, and six spot-checked paths are value-identical, which is the premise the substrate migration rests on. Also corrects the spec: two of the five stale-literal uses are already var(--color-text-muted, #555770) fallbacks that render the token today, so the visible change is bounded to table header text, table body text, and code-block titles — not list markers and figure captions. Co-Authored-By: Claude Opus 5 --- ...6-08-29-design-token-css-var-completion.md | 1177 +++++++++++++++++ ...-design-token-css-var-completion-design.md | 46 +- 2 files changed, 1211 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-29-design-token-css-var-completion.md 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..ee9a9f24f --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-design-token-css-var-completion.md @@ -0,0 +1,1177 @@ +# 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; that namespace is + // what generates the `max-w-container-page` utility the Container primitive + // wants. + 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 +``` + +Expected: PASS. Use this exact command — `nx test website` does not exist. + +- [ ] **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 index 7cf10af2a..b4650d2ae 100644 --- 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 @@ -150,13 +150,26 @@ 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.** -`#555770` (×4) and `#8b8fa3` (×1) become `var(--color-text-secondary)` and -`var(--color-text-muted)`. Docs table body text, table headers, list markers, -figure captions, and code-block titles shift from blue-grey to the system -neutrals. 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. +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)` → @@ -234,11 +247,19 @@ beyond the new output. | `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 | +| `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 | -Six files. No component touched. +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 @@ -249,9 +270,10 @@ Six files. No component touched. - `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 text, table headers, - list markers, figure captions, and code-block titles. Any other delta is a - bug in the literal audit. + 375. Expected: **no change anywhere except** docs table header text, docs + table body text, and code-block titles. Any other delta — including list + markers and figure captions, whose `#555770` is a dead fallback — is a bug in + the literal audit. 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, From 637d15e3dd407e4508af83911f9606489a47e3bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:01:14 -0700 Subject: [PATCH 03/17] =?UTF-8?q?test(design-tokens):=20assert=20token?= =?UTF-8?q?=E2=86=94CSS-var=20parity=20(red=20=E2=80=94=20type=20and=20spa?= =?UTF-8?q?ce=20scales=20unemitted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/token-css-parity.spec.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 libs/design-tokens/src/lib/token-css-parity.spec.ts 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..72d234bd6 --- /dev/null +++ b/libs/design-tokens/src/lib/token-css-parity.spec.ts @@ -0,0 +1,170 @@ +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]); + }, + ); +}); From 90e0efe3acaea852416eafa205b707631afeb013 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:03:15 -0700 Subject: [PATCH 04/17] feat(design-tokens): emit the type scale as Tailwind v4 composite text tokens --- .../scripts/generate-theme-css.ts | 34 +++++++++++++++++++ libs/design-tokens/src/lib/theme.css | 19 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index a6b337323..fa373d8d6 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -88,6 +88,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 */'); diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index dbd03375f..d5a894dfd 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -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; From 23edeb1fa0ed3bfeaa972fc300dec8b00b8fe441 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:03:47 -0700 Subject: [PATCH 05/17] =?UTF-8?q?feat(design-tokens):=20emit=20the=20space?= =?UTF-8?q?=20scale;=20token=E2=86=94CSS=20parity=20now=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/design-tokens/scripts/generate-theme-css.ts | 15 ++++++++++++++- libs/design-tokens/src/lib/theme.css | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index fa373d8d6..38e30f933 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -36,7 +36,7 @@ const HEADER = `/* `; function buildThemeBlock(): string { - const { typography, radius, shadows, brand } = baseTokens; + const { typography, space, radius, shadows, brand } = baseTokens; const lines: string[] = ['@theme {']; @@ -139,6 +139,19 @@ 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; that namespace is + // what generates the `max-w-container-page` utility the Container primitive + // wants. + 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'; } diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index d5a894dfd..5bfcb18c0 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -82,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; } From ddba808c5689dca7f1417ea53a526bb997cc9e1f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:06:28 -0700 Subject: [PATCH 06/17] test(design-tokens): pin the --ds-* names cockpit and example apps reference --- .../src/lib/ds-var-contract.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 libs/design-tokens/src/lib/ds-var-contract.spec.ts 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..6390f82a5 --- /dev/null +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -0,0 +1,61 @@ +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([]); + }); +}); From 86895002c76d0e52b2dfa028850bab444cd1d264 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:07:43 -0700 Subject: [PATCH 07/17] refactor(design-tokens): generate tokens.css from light.ts so --ds-* can no longer drift --- .../scripts/generate-theme-css.ts | 116 +++++++++++++++++- .../src/lib/generate-theme-css.spec.ts | 9 +- libs/design-tokens/src/lib/tokens.css | 97 ++++++++------- 3 files changed, 177 insertions(+), 45 deletions(-) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index 38e30f933..7739df5a9 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 @@ -156,15 +157,126 @@ function buildThemeBlock(): string { 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); + 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}`); } // Only run main when invoked directly (not when imported by tests) 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/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; } From 7d52e0da5b3837f74b65a9df5525440408bc1c8f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:09:21 -0700 Subject: [PATCH 08/17] build(design-tokens): export and ship tokens.css alongside theme.css --- libs/design-tokens/package.json | 3 ++- libs/design-tokens/project.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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" } ] From bb3817b3c5acb0da0b67e4f147d148ccc3915283 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:10:18 -0700 Subject: [PATCH 09/17] refactor(website): use accent tokens for docs code chips and table rule --- apps/website/src/app/global.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 7bcf356bd..4cc3de61e 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -111,8 +111,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; @@ -192,7 +192,7 @@ 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 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 var(--color-accent-border); } .docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(0, 64, 144, 0.08); color: #555770; } .docs-prose td code { font-size: 0.8em; } From feb8c92474ddf0030b1e4bf6fd4f1b404d56dbdc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:12:49 -0700 Subject: [PATCH 10/17] 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. --- apps/website/src/app/global.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 4cc3de61e..86b7869ac 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -97,7 +97,7 @@ 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(--color-text-muted); padding: 0.5rem 1.5rem; background: #1a1b26; border-bottom: 1px solid rgba(255, 255, 255, 0.06); @@ -136,7 +136,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; @@ -167,7 +167,7 @@ html { 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; } @@ -192,8 +192,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 var(--color-accent-border); } -.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 rgba(0, 64, 144, 0.08); color: var(--color-text-secondary); } .docs-prose td code { font-size: 0.8em; } /* UI primitive — Card. From 0898f19e3846a7d5267dac1fffd96679e68462b1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:13:23 -0700 Subject: [PATCH 11/17] refactor(website): name the docs-local constants that are not design tokens --- apps/website/src/app/global.css | 45 +++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 86b7869ac..afdcb8784 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -1,6 +1,31 @@ @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. + */ +: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); +} + * { box-sizing: border-box; } @@ -55,7 +80,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 +99,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; @@ -99,8 +124,8 @@ html { font-size: 0.7rem; color: var(--color-text-muted); 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; } @@ -151,8 +176,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,7 +185,7 @@ 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; @@ -179,7 +204,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` @@ -193,7 +218,7 @@ 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: var(--color-text-muted); border-bottom: 1px solid var(--color-accent-border); } -.docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(0, 64, 144, 0.08); color: var(--color-text-secondary); } +.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. From 5967edf89d008e665b9ba75c85f2991ef268cf8d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 29 Aug 2026 11:18:26 -0700 Subject: [PATCH 12/17] docs(design-tokens): correct the utility name and record two browser findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections, all measured in a browser rather than reasoned about: 1. Tailwind strips the namespace prefix, so `--container-page` generates `max-w-page`, not `max-w-container-page`. The spec, the plan, and a committed code comment all had it wrong. Verified: `max-w-page` computes to 1200px, `p-section-y` to 64px, and `text-h2` to 36px/40.32px — which also confirms the `--text-*--line-height` sub-key is honoured. 2. `.shiki` and `[data-rehype-pretty-code-title]` are dead CSS. rehype-pretty-code runs with keepBackground:true, so it writes the theme background inline on the
       and never emits a .shiki class; no code fence uses `title=`. Zero
         matches on docs and blog. That narrows the visible surface of the token
         adoption to table header and table body text only. Recorded as findings §9;
         deleting dead rules is cleanup, not this project.
      
      3. color-mix resolves and Lightning CSS emits a hex fallback plus an @supports
         upgrade, so the open question in the spec resolves in its favour.
      
      Also documents the website suite's 5 pre-existing failures as the baseline, so
      Task 10 does not read them as a regression.
      
      Co-Authored-By: Claude Opus 5 
      ---
       .../2026-08-29-docs-visual-review-findings.md | 36 +++++++++++++++++++
       ...6-08-29-design-token-css-var-completion.md | 32 ++++++++++++++---
       ...-design-token-css-var-completion-design.md | 27 ++++++++++----
       .../scripts/generate-theme-css.ts             |  9 +++--
       4 files changed, 91 insertions(+), 13 deletions(-)
      
      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
      index 4edee353e..f9c3351de 100644
      --- a/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md
      +++ b/docs/superpowers/audits/2026-08-29-docs-visual-review-findings.md
      @@ -209,6 +209,42 @@ 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
      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
      index ee9a9f24f..4acfa1a0b 100644
      --- 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
      @@ -419,9 +419,12 @@ insert:
         // Space scale.
         //
         // `containerMax` goes to the --container-* namespace, not --spacing-*,
      -  // because it is a max-width rather than a spacing step; that namespace is
      -  // what generates the `max-w-container-page` utility the Container primitive
      -  // wants.
      +  // 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};`);
      @@ -1108,7 +1111,28 @@ Expected: PASS — `tokens`, `generate-theme-css` (×2), `ds-var-contract`,
       cd apps/website && npx vitest run --config vite.config.mts
       ```
       
      -Expected: PASS. Use this exact command — `nx test website` does not exist.
      +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**
       
      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
      index b4650d2ae..08cf6daf5 100644
      --- 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
      @@ -138,8 +138,15 @@ vars.
       ```
       
       `containerMax` goes to the `--container-*` namespace rather than `--spacing-*`
      -because it is a max-width, not a spacing step; that namespace generates the
      -`max-w-container-page` utility the marketing `Container` primitive wants.
      +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.
      @@ -197,6 +204,11 @@ belong in the design system and must not be promoted into it:
         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
      @@ -270,10 +282,13 @@ unambiguous message.
       - `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, docs
      -  table body text, and code-block titles. Any other delta — including list
      -  markers and figure captions, whose `#555770` is a dead fallback — is a bug in
      -  the literal audit.
      +  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,
      diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts
      index 7739df5a9..c5b21518b 100644
      --- a/libs/design-tokens/scripts/generate-theme-css.ts
      +++ b/libs/design-tokens/scripts/generate-theme-css.ts
      @@ -143,9 +143,12 @@ function buildThemeBlock(): string {
         // Space scale.
         //
         // `containerMax` goes to the --container-* namespace, not --spacing-*,
      -  // because it is a max-width rather than a spacing step; that namespace is
      -  // what generates the `max-w-container-page` utility the Container primitive
      -  // wants.
      +  // 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};`);
      
      From 0ab9f900578664ec52b13967a146794ccf77626f Mon Sep 17 00:00:00 2001
      From: Brian Love 
      Date: Sat, 29 Aug 2026 11:29:04 -0700
      Subject: [PATCH 13/17] fix(website): keep the code-title foreground with the
       syntax-theme constants
      
      var(--color-text-muted) is a light-surface role token; on the dark #1a1b26
      title bar it drops to 3.6:1, below AA. Move it to --docs-code-title-fg
      alongside the other tokyo-night-coupled values.
      ---
       apps/website/src/app/global.css | 6 +++++-
       1 file changed, 5 insertions(+), 1 deletion(-)
      
      diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css
      index afdcb8784..eff207321 100644
      --- a/apps/website/src/app/global.css
      +++ b/apps/website/src/app/global.css
      @@ -11,9 +11,13 @@
        * 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);
      @@ -122,7 +126,7 @@ html {
       .docs-prose [data-rehype-pretty-code-figure] [data-rehype-pretty-code-title] {
         font-family: var(--font-mono), monospace;
         font-size: 0.7rem;
      -  color: var(--color-text-muted);
      +  color: var(--docs-code-title-fg);
         padding: 0.5rem 1.5rem;
         background: var(--docs-code-bg);
         border-bottom: 1px solid var(--docs-code-title-rule);
      
      From bdcabecd8ab4a5437d6a35b1b7ee465400a09244 Mon Sep 17 00:00:00 2001
      From: Brian Love 
      Date: Sat, 29 Aug 2026 11:29:42 -0700
      Subject: [PATCH 14/17] test(design-tokens): --ds-render-green is
       consumer-referenced, add it to the contract
      
      Referenced 13x across cockpit/render. It was absent from the list because the
      list was derived against the pre-generator tokens.css, which did not define it.
      ---
       libs/design-tokens/src/lib/ds-var-contract.spec.ts | 1 +
       1 file changed, 1 insertion(+)
      
      diff --git a/libs/design-tokens/src/lib/ds-var-contract.spec.ts b/libs/design-tokens/src/lib/ds-var-contract.spec.ts
      index 6390f82a5..e3cac8c7c 100644
      --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts
      +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts
      @@ -32,6 +32,7 @@ const CONSUMER_REFERENCED = [
         '--ds-radius-md',
         '--ds-radius-sm',
         '--ds-radius-xl',
      +  '--ds-render-green',
         '--ds-shadow-lg',
         '--ds-shadow-md',
         '--ds-surface',
      
      From f8f40d6bb36c03dc7118b4982e432182202535b1 Mon Sep 17 00:00:00 2001
      From: Brian Love 
      Date: Sat, 29 Aug 2026 11:30:17 -0700
      Subject: [PATCH 15/17] test(design-tokens): enforce the invariant the light.*
       parity exclusion assumes
      
      ---
       .../src/lib/token-css-parity.spec.ts            | 17 +++++++++++++++++
       1 file changed, 17 insertions(+)
      
      diff --git a/libs/design-tokens/src/lib/token-css-parity.spec.ts b/libs/design-tokens/src/lib/token-css-parity.spec.ts
      index 72d234bd6..2228813cb 100644
      --- a/libs/design-tokens/src/lib/token-css-parity.spec.ts
      +++ b/libs/design-tokens/src/lib/token-css-parity.spec.ts
      @@ -158,6 +158,23 @@ describe('token ↔ CSS var parity', () => {
           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) => {
      
      From 5cec3585e0bdfb23a5b974bf608d89febd4e9812 Mon Sep 17 00:00:00 2001
      From: Brian Love 
      Date: Sat, 29 Aug 2026 11:30:49 -0700
      Subject: [PATCH 16/17] test(design-tokens): assert cssVars() and generated
       tokens.css agree
      
      Two hand-maintained emitters of the same --ds-* namespace, from the same
      sources, with nothing enforcing agreement. They match today; this keeps them
      matching.
      ---
       .../src/lib/ds-var-sources-agree.spec.ts      | 46 +++++++++++++++++++
       1 file changed, 46 insertions(+)
       create mode 100644 libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts
      
      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([]);
      +  });
      +});
      
      From 6be802cae13347bcf6b1d044c031a034c44c08ff Mon Sep 17 00:00:00 2001
      From: Brian Love 
      Date: Sat, 29 Aug 2026 11:31:51 -0700
      Subject: [PATCH 17/17] chore(design-tokens): align the regenerate command, fix
       a tint comment, drop an unused directive
      
      ---
       apps/website/src/app/global.css                  | 4 ++--
       libs/design-tokens/scripts/generate-theme-css.ts | 4 +---
       libs/design-tokens/src/lib/theme.css             | 2 +-
       3 files changed, 4 insertions(+), 6 deletions(-)
      
      diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css
      index eff207321..2869e1953 100644
      --- a/apps/website/src/app/global.css
      +++ b/apps/website/src/app/global.css
      @@ -23,8 +23,8 @@
         --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. */
      +  /* 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);
      diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts
      index c5b21518b..463b240e6 100644
      --- a/libs/design-tokens/scripts/generate-theme-css.ts
      +++ b/libs/design-tokens/scripts/generate-theme-css.ts
      @@ -25,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
      @@ -276,9 +276,7 @@ export function generateThemeCss(): string {
       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}`);
       }
       
      diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css
      index 5bfcb18c0..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