From 0635d81b23e87a7e9f18e6c5e27252731bb3134e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 19:37:42 -0700 Subject: [PATCH 01/24] =?UTF-8?q?docs:=20spec=20=E2=80=94=20ATC=20theme=20?= =?UTF-8?q?spike=20(aviation=20yellow)=20for=20the=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reskins the website to the visual language of ATC — Live Air Traffic Radio: aviation yellow #FFAF00, Archivo Black display type, and instrument-panel data strips. Built in libs/design-tokens rather than as a website override, so the spike measures what adopting the theme actually costs. The central finding is that the accent role has to split: #FFAF00 on white is 1.84:1, so yellow is a fill colour only and the interactive ink moves to the app's scope navy #15253E (15.37:1, higher than the #004090 it replaces). The focus ring deliberately stays navy on light surfaces. Records the load-bearing risk: the homepage architecture diagram e2e added by #1048 measures text runs against their cards after document.fonts.ready, and Archivo's metrics differ from Inter's. Co-Authored-By: Claude Opus 5 --- .../2026-09-07-atc-theme-spike-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md diff --git a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md new file mode 100644 index 000000000..bdd4e8490 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md @@ -0,0 +1,261 @@ +# ATC theme spike — aviation yellow on the Threadplane website + +**Date:** 2026-09-07 +**Status:** Approved, ready for planning +**Branch:** `blove/threadplane-theme-update-55baa6` + +## 1. Goal + +Reskin the Threadplane website to the visual language of **ATC — Live Air +Traffic Radio** (Enhanced Radar, App Store id6755132266): aviation yellow, a +heavy grotesk display face, flat saturated blocks, and instrument-panel data +strips. + +This is a spike in intent — it exists to be looked at and judged — but it is +built the real way, in `libs/design-tokens`, not as a throwaway override. The +branch is the isolation. + +## 2. The reference + +Values sampled from the App Store screenshots, not approximated: + +| Value | Role in the app | +| --- | --- | +| `#FFAF00` | App icon ground. The aviation yellow. | +| `#FFB700` | The frequency strip at the bottom of the player. | +| `#15253E` | The radar/scope panel navy. | +| `#FF3200` | LIVE badges and marketing grounds. | +| `#6155F5` | The followed-flight card. Not adopted — see §4.1. | +| `#0A0A0A` | The wordmark black. | + +The app's design language is flat saturated colour blocks with no gradients, +very heavy grotesk display type set tight, squircle and pill rounding, and +solid full-bleed data strips carrying callsigns and frequencies. + +## 3. Direction + +Of three directions considered — yellow as a signal colour on the existing +light page, a fully dark navy "scope" page, and a yellow-forward hero block — +the chosen direction is **the yellow-forward hero block on an otherwise +disciplined light page**. + +The hero is the app icon blown up: a full-bleed `#FFAF00` panel with black type +and a black primary button, terminated by a `#15253E` data strip. Below the +strip the page returns to white and behaves. Yellow does not reappear as a +wash — only as the primary button, a 3px rule, or a link underline. + +The rejected alternatives, and why: + +- **Fully dark scope page.** Closest to the app's real product surface, but the + site is light-only today; docs, code blocks, diagrams and the embedded live + demo all assume a white ground. Largest blast radius for the least additional + signal. +- **Yellow as accent only.** Safe and cheap, but reads as "a site that uses + yellow" rather than as the reference. It does not answer the question the + spike is asking. + +## 4. Token architecture + +### 4.1 The accent role splits in two + +Today `--color-accent` does two jobs: it fills the primary button *and* colours +links, borders and focus rings. Navy `#004090` could do both. Yellow cannot — +`#FFAF00` on white is **1.84:1**, which is not a text colour under any +circumstances. + +So the retheme is a remap of token *roles*, not a substitution of values. +Measured ratios: + +| Pair | Ratio | Verdict | +| --- | --- | --- | +| `#FFAF00` on `#FFFFFF` | 1.84:1 | Fails. Fill only, never text. | +| `#0A0A0A` on `#FFAF00` | 10.73:1 | Passes AA. The yellow/black pairing. | +| `#15253E` on `#FFFFFF` | 15.37:1 | Passes AA — higher than the `#004090` it replaces (9.83:1). | +| `#FFAF00` on `#15253E` | 8.33:1 | Passes AA. Accent flips to yellow on dark. | +| `#FF3200` on `#FFFFFF` | 3.68:1 | Large/bold text or non-text only. | + +**Brand layer — `libs/design-tokens/src/lib/base.ts`** (theme-invariant): + +| Token | Value | Job | +| --- | --- | --- | +| `signal` | `#FFAF00` | Hero block, primary button fill, marker highlight, logo mark. | +| `signalStrong` | `#FFB700` | Full-bleed data strips only. | +| `scope` | `#15253E` | Interactive ink on light; the ground of the website's dark band. | +| `alert` | `#FF3200` | LIVE status fills. Never small text. | +| `ink` | `#0A0A0A` | Text on yellow; the new `textPrimary`. | + +`angularRed` (`#DD0031`) is unchanged — it is Angular's trademark colour, not +ours to retheme. `renderGreen` and `chatPurple` are unchanged; retinting them +is unrelated churn. The app's indigo `#6155F5` is **not** adopted: it would +collide with `chatPurple` for no gain. + +These are *additive* brand entries. Each needs a line in +`generate-theme-css.ts` (emitting `--color-signal`, `--color-signal-strong`, +`--color-scope`, `--color-alert`, `--color-ink` for Tailwind and the matching +`--ds-*` names for plain consumers), an entry in the `token-css-parity.spec.ts` +source→var map, and — because they become names consumers may reference — an +entry in the `ds-var-contract.spec.ts` list. + +`signal` and `accentLight` resolve to the same value rather than one replacing +the other, and that is deliberate: `dark.ts` already derives its accent as +`baseTokens.brand.accentLight`, so repointing `accentLight` to `#FFAF00` makes +the dark accent flip to yellow with no second edit. `--color-signal` is the +name new work should use; `--color-accent-light` stays for its 4 existing call +sites. + +**Light theme — `libs/design-tokens/src/lib/light.ts`:** + +| Role | Was | Becomes | Rationale | +| --- | --- | --- | --- | +| `accent` | `#004090` | `#15253E` | Interactive ink. From the ATC palette, and higher contrast than what it replaces. | +| `accentLight` | `#64C3FD` | `#FFAF00` | The signal. Fill only. | +| `accentHover` | `#003070` | `#0E1B2E` | Darker scope navy. | +| `accentSurface` / `accentBorder` / `accentGlow` | navy tints | yellow tints | Tints are backgrounds, so they carry the yellow; the ink on top stays `accent`. This is already how `[data-ui="pill"][data-variant="accent"]` composes. | +| `shadowFocus` | navy ring | `#15253E` ring | **Deliberate.** A yellow focus ring on white is 1.84:1 and effectively invisible. | +| `textPrimary` | `rgb(28,28,28)` | `#0A0A0A` | Matches the app's harder near-black. | + +**Dark theme — `libs/design-tokens/src/lib/dark.ts`:** its `accent` needs *no* +edit — line 28 already derives it as `baseTokens.brand.accentLight`, so it +becomes `#FFAF00` (8.33:1) the moment `accentLight` is repointed. What does +need editing is lines 29–33, which hardcode the blue: `accentHover`, +`accentGlow`, `accentBorder`, `accentBorderHover` and `accentSurface` all carry +literal `#8dd4ff` / `rgba(100, 195, 253, …)` values and must be re-derived from +the yellow. Leaving them is the likeliest silent bug in this whole change: the +accent would flip to yellow while its own hover and border tints stayed blue. + +Its *surfaces* stay neutral, because that file states they are deliberately +aligned to `@threadplane/chat` so embedded chat has no colour seam, and the +cockpit consumes them. + +The website's own `[data-ui="section"][data-surface="dark"]` scope +(`apps/website/src/styles/ui.css`) is website-local, so that band moves off +neutral `#111` onto scope navy `#15253E`, and its 1px boundary seam changes +from blue to yellow. + +### 4.2 Why the library and not a website override + +An override layer in `apps/website` would have kept `libs/design-tokens` +untouched and left every existing token spec green. It was rejected in favour +of doing it properly: the library is the source of truth, and a spike that +routes around it would not tell us what adopting the theme actually costs. + +The accepted consequence is that the cockpit and example apps inherit the new +`--ds-*` values. They consume them with fallbacks and nothing imports +`tokens.css` yet, so nothing breaks at runtime — but they are not visually +reviewed as part of this work (§7). + +## 5. Typography + +| Var | Was | Becomes | +| --- | --- | --- | +| `--font-display` (was `--font-garamond`) | EB Garamond | **Archivo Black** | +| `--font-sans` (was `--font-inter`) | Inter | **Archivo** 400–700 | +| `--font-mono` | JetBrains Mono | unchanged | + +EB Garamond is removed from `layout.tsx` entirely. A serif has no place in this +system, and dropping it removes six loaded font files. + +Archivo Black is display-only (a single weight) and is used for h1/h2 and the +wordmark; Archivo carries body and UI. This pairing is what keeps the docs +readable — Archivo is a text face, so a long page still reads, which Archivo +Black alone would not. + +JetBrains Mono uppercase on the eyebrows and the data strips is doing more work +than it looks: it is the instrument-panel cue. Without it the theme leans +entirely on the yellow. + +### 5.1 The rename + +The names `--font-garamond` and `--font-inter` become lies after the swap, so +they are renamed to `--font-display` and `--font-sans`. This is wider than a +find-and-replace in `apps/website`, because the names are generated: + +- `libs/design-tokens/src/lib/typography.ts` — `fontSerif` → `fontDisplay` +- `libs/design-tokens/src/lib/css-vars.ts` — `--ds-font-serif` → `--ds-font-display` +- `libs/design-tokens/scripts/generate-theme-css.ts` — emits both `--font-garamond` and `--ds-font-serif` +- `libs/design-tokens/src/lib/token-css-parity.spec.ts` — the source→var map +- `libs/design-tokens/src/lib/ds-var-contract.spec.ts` — the published `--ds-*` name list +- `libs/workspace-react/src/styles/workspace.css` +- `apps/website` — 55 uses of `--font-garamond`, 128 of `--font-inter` + +Risk is low despite the breadth: `ds-var-contract.spec.ts` documents that +consumers reference `--ds-*` names with fallbacks and that nothing imports +`tokens.css` yet, so removing `--ds-font-serif` has no runtime effect. + +The rename lands as **its own commit**, before the design commit, so review of +the design is not buried under ~190 mechanical replacements. + +## 6. Surfaces in scope + +Yellow appears in exactly three forms. Anywhere else it is reduced to a 3px +rule or an underline colour. + +1. **Nav** — white ground, black text, yellow logo mark, yellow "Get started" + button with black text. +2. **Homepage hero** — full-bleed `#FFAF00` block, Archivo Black headline in + `#0A0A0A`, black primary button, terminated by a `#15253E` strip carrying + the trust line in JetBrains Mono. Below the strip, white. +3. **Docs shell** — a document first. Yellow is the active sidebar item's 3px + left rule, the callout's left rule over a near-white tint, and keyword + colour inside scope-navy code blocks. Links are `#15253E` with a yellow + underline. + +Every other marketing page (pricing, blog, about, solutions, pilot-to-prod) +inherits the new tokens and fonts automatically — that is the point of the +library approach — but is not hand-tuned. Those pages get a smoke pass only. + +## 7. Explicitly out of scope + +Named here so the divergence reads as a decision rather than a defect: + +- **The OG / social card pipeline** (`apps/website/src/app/card/`, + `opengraph-image.tsx`, `blog/[slug]/opengraph-image.tsx`, `og-font.ts`). It + bundles its own `EBGaramond-Bold.ttf` and carries its own `#004090` in + `card/tokens.ts`, and `card.spec.ts` asserts the bundled filenames. It keeps + navy and Garamond, so a shared link will not match the site. +- **`apps/website/scripts/generate-whitepaper.ts`** — same reason. +- **`apps/website/src/app/icon.svg`** — the favicon stays navy. +- **Cockpit and example apps** — they inherit the new values but are not + visually reviewed here. + +## 8. Verification + +The e2e added by #1048 is load-bearing for this work. +`apps/website/e2e/home-architecture.spec.ts` waits on `document.fonts.ready` +and then runs an overflow report against every card in the homepage +architecture diagram, with the comment "a fallback face lies about widths." +**Archivo's metrics differ from Inter's, so text that fits a diagram card today +may overflow after the swap.** Expect to adjust the diagram's geometry data +module or its type sizes, and treat that e2e as a required gate rather than an +afterthought. + +Specs that pin a value being deliberately changed, and must be updated as part +of the work: + +- `libs/design-tokens/src/lib/css-vars.spec.ts` — `--ds-accent` pinned to + `#004090`, dark to `#64C3FD`, and the serif light/dark agreement +- `libs/design-tokens/src/lib/ds-var-contract.spec.ts` — `--ds-font-serif` +- `libs/design-tokens/src/lib/token-css-parity.spec.ts` — the font var map +- `tokens.spec.ts`, `theme.spec.ts`, `ds-var-sources-agree.spec.ts`, + `generate-theme-css.spec.ts` — wherever they assert changed values +- `apps/website/src/styles/style-contracts.spec.ts` + +Sequence: + +1. `rm -rf apps/website/.next` first. This worktree carries a 347 MB stale + `.next` from earlier dev use, which panics the production build. +2. Edit `base.ts` / `light.ts` / `dark.ts` / `typography.ts`, then regenerate + with `npx nx run design-tokens:generate-theme-css`. Never hand-edit + `tokens.css`, `tokens-dark.css` or `theme.css` — they are generated. +3. `npx nx test design-tokens` green. +4. `npx nx test website` green. +5. `npx nx e2e website` — in particular `home-architecture.spec.ts`. +6. Browser pane on `/` and a docs page at desktop and at 390px, and a contrast + check taken from the rendered page rather than from the table in §4.1. + +## 9. Known debt if the theme is adopted + +- The social card, whitepaper and favicon pipelines need the new face bundled + and their own `#004090` replaced. +- The non-hand-tuned marketing pages need a real pass. +- The cockpit and example apps need a visual review against the new `--ds-*`. From b474cc2f8f0d8836617fefce36035c691cbda075 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 19:43:32 -0700 Subject: [PATCH 02/24] =?UTF-8?q?docs:=20spec=20=E2=80=94=20diagrams=20kee?= =?UTF-8?q?p=20Inter=20via=20a=20--font-diagram=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagrams do not take the brand face. They stay clean and minimal on Inter through a new --font-diagram token, for two reasons: a heavy grotesk fights a 16-node drawing, and the diagram geometry is tuned to Inter's metrics. That second reason is the load-bearing one. EnterpriseArchitecture pins every rectangle to an 8px grid and home-architecture.spec.ts measures each rendered text run against its card, so swapping diagrams to Archivo would force a geometry rework that could exceed the retheme itself. Keeping Inter makes it zero. The carve-out is three declarations — .arch-figure text in landing.css, and the two sans rules in the docs diagram kit — which the mechanical --font-inter rename must send to --font-diagram rather than --font-sans. Section 8 now treats the overflow e2e as the gate on that carve-out holding. Co-Authored-By: Claude Opus 5 --- .../2026-09-07-atc-theme-spike-design.md | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md index bdd4e8490..dfd8f1c91 100644 --- a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md +++ b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md @@ -150,10 +150,12 @@ reviewed as part of this work (§7). | --- | --- | --- | | `--font-display` (was `--font-garamond`) | EB Garamond | **Archivo Black** | | `--font-sans` (was `--font-inter`) | Inter | **Archivo** 400–700 | +| `--font-diagram` | *(new)* | **Inter** — retained for diagrams only | | `--font-mono` | JetBrains Mono | unchanged | EB Garamond is removed from `layout.tsx` entirely. A serif has no place in this -system, and dropping it removes six loaded font files. +system, and dropping it removes six loaded font files. Inter is **not** +removed — it stays loaded to serve `--font-diagram` (§5.1). Archivo Black is display-only (a single weight) and is used for h1/h2 and the wordmark; Archivo carries body and UI. This pairing is what keeps the docs @@ -164,7 +166,41 @@ JetBrains Mono uppercase on the eyebrows and the data strips is doing more work than it looks: it is the instrument-panel cue. Without it the theme leans entirely on the yellow. -### 5.1 The rename +### 5.1 Diagrams keep Inter + +Diagrams do not take the brand face. They stay on Inter via a new +`--font-diagram` token, and they stay clean and minimal: neutral type, no +Archivo Black, no display weights. + +Two reasons, and the second is the load-bearing one: + +1. **Diagrams are information, not brand surface.** A heavy grotesk in a + 16-node architecture drawing fights the drawing. The brand lives in the + hero; the diagram's job is to be read. +2. **The geometry is tuned to Inter's metrics.** `EnterpriseArchitecture.tsx` + pins every rectangle to an 8px grid in a data module, and + `home-architecture.spec.ts` measures every rendered text run against its + card after `document.fonts.ready`. Swapping the diagram to Archivo would + change every glyph width and force a geometry rework that could easily + exceed the retheme itself. Keeping Inter makes that rework zero. + +The carve-out is three declarations, not a scatter: + +- `apps/website/src/styles/landing.css` — `.arch-figure text`, a single blanket + rule over every text node in the architecture figure +- `apps/website/src/styles/docs.css` — `.tp-diagram-node[data-title="sans"] + .tp-diagram-title` and `.tp-diagram-meta`, the shared docs diagram kit + +Diagram rules already on `var(--font-mono)` (eyebrows, titles, `data-meta="mono"`) +are unchanged — mono was never Inter and is not being retyped. + +**Trap:** the §5.2 rename is a mechanical `--font-inter` → `--font-sans` +replacement, and these three declarations must become `--font-diagram` +instead. A blind find-and-replace silently rethemes the diagrams to Archivo +and breaks the overflow e2e — or worse, passes at desktop and overflows at +390px. Do the carve-out in the same commit as the rename, not after it. + +### 5.2 The rename The names `--font-garamond` and `--font-inter` become lies after the swap, so they are renamed to `--font-display` and `--font-sans`. This is wider than a @@ -220,14 +256,20 @@ Named here so the divergence reads as a decision rather than a defect: ## 8. Verification -The e2e added by #1048 is load-bearing for this work. +The e2e added by #1048 is the gate on the §5.1 carve-out. `apps/website/e2e/home-architecture.spec.ts` waits on `document.fonts.ready` and then runs an overflow report against every card in the homepage architecture diagram, with the comment "a fallback face lies about widths." -**Archivo's metrics differ from Inter's, so text that fits a diagram card today -may overflow after the swap.** Expect to adjust the diagram's geometry data -module or its type sizes, and treat that e2e as a required gate rather than an -afterthought. + +Because diagrams keep Inter, this should pass **unchanged** — and that is +exactly what makes it useful. A green run means the carve-out held; a red run +means the rename swept the diagram rules into Archivo. If it goes red, the fix +is to restore `--font-diagram` on the three declarations in §5.1, **not** to +adjust the diagram geometry. Adjusting geometry would be treating the symptom +and would bake Archivo's metrics into the data module. + +Run it at both viewports. The spec's second test drops to 390px, and a +metrics regression can pass at desktop while overflowing on a phone. Specs that pin a value being deliberately changed, and must be updated as part of the work: From c87e8884e93ec637e0f0f3b2835ae7511619c0e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:22:52 -0700 Subject: [PATCH 03/24] docs: implementation plan for the ATC theme spike Eleven tasks, TDD where there is something to assert: the three value-pinning token specs are updated before the values move, so each colour change has a red test first. Notable ordering constraints baked into the plan: - dark.ts needs no edit to `accent` (it derives from accentLight) but does need lines 29-33, which hardcode the old blue. Task 2 makes that a step of its own with a grep that proves no blue survives. - The font-var rename and the --font-diagram carve-out land in ONE commit. Split apart, a blind find-and-replace sends the diagram rules to Archivo and the overflow e2e can fail at 390px only. - The site is visibly broken between Task 4 and Task 5, which the plan says out loud so nobody stops to debug it. Selectors verified against the source rather than guessed: the logo mark takes the signal via `color` (PlaneMark fills with currentColor), and the docs active item is [data-docs-navlink][data-active], already at docs.css:917. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-07-atc-theme-spike.md | 1027 +++++++++++++++++ 1 file changed, 1027 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-atc-theme-spike.md diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md new file mode 100644 index 000000000..5908d4476 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -0,0 +1,1027 @@ +# ATC Theme Spike 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:** Reskin the Threadplane website to the ATC app's visual language — aviation yellow `#FFAF00`, scope navy `#15253E`, Archivo Black display type — by rethemeing `libs/design-tokens` and hand-tuning the nav, homepage hero and docs shell. + +**Architecture:** The retheme happens in the token library, which is the source of truth; `apps/website` inherits it. The accent role splits: `--color-accent` becomes scope navy (the interactive ink) and `--color-accent-light` becomes the yellow (fill only), because `#FFAF00` on white is 1.84:1 and cannot be a text colour. The yellow hero is a new `Section surface="signal"` variant that re-scopes `--color-*` for its block — the same mechanism the existing `data-surface="dark"` band already uses. Diagrams are deliberately carved out onto a new `--font-diagram` token that keeps Inter, because their geometry is pinned to Inter's metrics by an e2e. + +**Tech Stack:** Nx monorepo, TypeScript token sources compiled to CSS by a generator script, Next.js 15 App Router with `next/font/google`, Tailwind v4 `@theme`, Vitest for unit tests, Playwright for e2e. + +**Spec:** `docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md` + +--- + +## Background an engineer needs before starting + +**The token files are generated.** `libs/design-tokens/src/lib/tokens.css`, `tokens-dark.css` and `theme.css` all begin with "GENERATED FILE — DO NOT EDIT BY HAND". They are produced by `libs/design-tokens/scripts/generate-theme-css.ts` from the TypeScript sources (`base.ts`, `light.ts`, `dark.ts`, `typography.ts`, `shadows.ts`). Every colour change in this plan edits a `.ts` source and then runs the generator. If you edit a `.css` file directly, `ds-var-sources-agree.spec.ts` will fail and you will have wasted the time. + +**Two naming conventions, one source.** The generator emits each token twice: `--color-*` / `--font-*` (Tailwind v4 `@theme`, consumed by `apps/website`) and `--ds-*` (plain custom properties, consumed by the Angular cockpit and example apps). `token-css-parity.spec.ts` asserts the first set matches the TS sources; `ds-var-sources-agree.spec.ts` asserts the `--ds-*` set agrees between `cssVars()` and `tokens.css`; `ds-var-contract.spec.ts` asserts no `--ds-*` name silently disappears. + +**Three specs pin values this plan deliberately changes.** They are updated as part of the work, test-first: `css-vars.spec.ts`, `ds-var-contract.spec.ts`, `token-css-parity.spec.ts`. `tokens.spec.ts`, `theme.spec.ts`, `ds-var-sources-agree.spec.ts` and `generate-theme-css.spec.ts` were checked and hardcode none of the changed values — they should pass untouched, and if one fails you have broken something structural rather than merely changed a colour. + +**Two e2e specs are load-bearing and are the reason several steps exist:** +- `apps/website/e2e/home-architecture.spec.ts` waits for `document.fonts.ready` then measures every text run in the homepage architecture diagram against its card. It is the gate proving the `--font-diagram` carve-out held. If it goes red, restore `--font-diagram` on the three declarations in Task 5 — **do not** adjust the diagram geometry. +- `apps/website/e2e/home-hero.spec.ts` exercises the hero demo iframe, which only loads when `HeroDemo.tsx:69`'s `IntersectionObserver` at `threshold: 0.25` fires. Adding vertical padding to the hero pushes that stage down the page. If it goes red, scroll the stage into view in the test — do not shrink the hero copy. + +**Commands** (run from the worktree root, never `cd` elsewhere): +- Regenerate CSS: `npx nx run design-tokens:generate-theme-css` +- Token tests: `npx nx test design-tokens` +- Website unit tests: `npx nx test website` +- Website e2e: `npx nx e2e website` + +--- + +## File Structure + +**Token library — `libs/design-tokens/`** + +| File | Responsibility | Change | +| --- | --- | --- | +| `src/lib/base.ts` | Theme-invariant brand identity | Add 5 ATC brand tokens; repoint `accent` / `accentLight` | +| `src/lib/light.ts` | Light-theme resolved roles | Accent family → navy ink + yellow tints; `textPrimary` → `#0A0A0A` | +| `src/lib/dark.ts` | Dark-theme resolved roles | Lines 29–33 only: blue tints → yellow | +| `src/lib/typography.ts` | Font families + type scale | `fontSerif`→`fontDisplay` (Archivo Black); `fontSans`→Archivo; add `fontDiagram` | +| `src/lib/shadows.ts` | Elevation + focus ring | `focus` → scope-navy tint | +| `src/lib/css-vars.ts` | `--ds-*` emitter (runtime fn) | Rename serif var; add the 5 brand vars + `--ds-font-diagram` | +| `scripts/generate-theme-css.ts` | Emits all three CSS files | Rename font vars; emit the 5 brand vars + diagram font | +| `src/lib/*.spec.ts` | The three value-pinning specs | Updated test-first | + +**Website — `apps/website/`** + +| File | Responsibility | Change | +| --- | --- | --- | +| `src/app/layout.tsx` | Loads fonts via `next/font` | Archivo Black + Archivo + Inter + JetBrains Mono; EB Garamond removed | +| `src/components/ui/Section.tsx` | Section surface variants | Add `'signal'` to the `Surface` union | +| `src/styles/ui.css` | UI primitives + surface scopes | Add `[data-surface="signal"]` scope; retint the dark scope | +| `src/styles/landing.css` | Hero + landing + arch diagram | Hero on the signal block; `marker-highlight` navy→yellow; `--font-diagram` carve-out | +| `src/styles/docs.css` | Docs shell + diagram kit | Active-item rule, callout rule, `--font-diagram` carve-out | +| `src/styles/chrome.css` | Nav | Yellow logo mark, yellow CTA | +| `src/components/landing/Hero.tsx` | Homepage hero | `surface="signal"` + trust strip | + +--- + +## Task 1: Clear the stale build output + +**Files:** +- Delete: `apps/website/.next/` + +This worktree carries a 347 MB `.next` directory from earlier dev use. Turbopack panics on a stale root when a production build runs after a worktree has been used for dev, and the failure message points at the wrong thing entirely. + +- [ ] **Step 1: Remove it** + +```bash +rm -rf apps/website/.next +``` + +- [ ] **Step 2: Confirm it is gone** + +```bash +test -d apps/website/.next && echo "STILL THERE" || echo "clean" +``` + +Expected: `clean` + +No commit — `.next` is gitignored. + +--- + +## Task 2: Brand and light-theme colour values + +**Files:** +- Modify: `libs/design-tokens/src/lib/base.ts` +- Modify: `libs/design-tokens/src/lib/light.ts` +- Modify: `libs/design-tokens/src/lib/dark.ts:27-33` +- Modify: `libs/design-tokens/src/lib/shadows.ts` +- Test: `libs/design-tokens/src/lib/css-vars.spec.ts:12-14,16-18` + +- [ ] **Step 1: Update the failing assertions in `css-vars.spec.ts`** + +Replace the two `it` blocks inside `describe('light')`: + +```typescript + it('uses scope-navy accent as the interactive ink', () => { + expect(vars['--ds-accent']).toBe('#15253E'); + }); + + it('uses near-black text on light surfaces', () => { + expect(vars['--ds-text-primary']).toBe('#0A0A0A'); + }); +``` + +And add a new one directly after them, still inside `describe('light')`: + +```typescript + it('exposes aviation yellow as a fill-only signal, not as the ink', () => { + expect(vars['--ds-accent-light']).toBe('#FFAF00'); + expect(vars['--ds-accent']).not.toBe(vars['--ds-accent-light']); + }); +``` + +- [ ] **Step 2: Run the test and watch it fail** + +```bash +npx nx test design-tokens +``` + +Expected: FAIL — `expected '#004090' to be '#15253E'`. + +- [ ] **Step 3: Add the ATC brand tokens to `base.ts`** + +Replace the `brand` block: + +```typescript + brand: Object.freeze({ + /** Scope navy — the ATC radar panel. The light-theme interactive ink. */ + accent: '#15253E', + /** + * Aviation yellow. Fill only: 1.84:1 on white, so it is never a text or + * icon colour on a light surface. Doubles as the dark-theme accent, where + * it reaches 8.33:1 — dark.ts derives its accent from this name. + */ + accentLight: '#FFAF00', + /** Aviation yellow, by its intended name. Same value as accentLight. */ + signal: '#FFAF00', + /** Frequency-strip amber. Full-bleed data strips only. */ + signalStrong: '#FFB700', + /** Scope navy — dark grounds and data strips. */ + scope: '#15253E', + /** LIVE red-orange. 3.68:1, so status fills and large bold text only. */ + alert: '#FF3200', + /** Near-black. Text on yellow, and the light-theme text primary. */ + ink: '#0A0A0A', + /** Angular brand red — trademark colour, not ours to retheme */ + angularRed: '#DD0031', + /** Render library green */ + renderGreen: '#1a7a40', + /** Chat library purple */ + chatPurple: '#5a00c8', + }), +``` + +- [ ] **Step 4: Update the accent family and text primary in `light.ts`** + +Replace the `// Text` block's first line and the `// Semantic accent` block: + +```typescript + // Text + textPrimary: '#0A0A0A', +``` + +```typescript + // Semantic accent is the interactive INK (links, focus, borders) and stays + // dark: aviation yellow is 1.84:1 on white and cannot carry text. The tints + // below are backgrounds, so they carry the yellow instead — which is how + // [data-ui="pill"][data-variant="accent"] already composes: yellow surface, + // navy ink on top. + accent: baseTokens.brand.accent, + accentHover: '#0E1B2E', + accentGlow: 'rgba(255, 175, 0, 0.28)', + accentBorder: 'rgba(255, 175, 0, 0.35)', + accentBorderHover: 'rgba(255, 175, 0, 0.55)', + accentSurface: 'rgba(255, 175, 0, 0.10)', +``` + +- [ ] **Step 5: Repoint the focus ring in `shadows.ts`** + +```typescript + /** + * Keyboard focus ring. Deliberately scope navy and NOT the signal yellow: + * a yellow ring on a white page is 1.84:1 and effectively invisible. + */ + focus: '0 0 0 3px rgba(21, 37, 62, 0.30)', +``` + +- [ ] **Step 6: Re-derive the dark theme's accent tints** + +This is the likeliest silent bug in the whole change. `dark.ts:28` derives +`accent` as `baseTokens.brand.accentLight`, so it flips to yellow on its own +from Step 3 — but lines 29–33 hardcode the old blue. Left alone, the dark +accent turns yellow while its own hover and border tints stay blue, and +nothing fails. + +In `libs/design-tokens/src/lib/dark.ts`, replace the accent block: + +```typescript + // Semantic accent maps to aviation yellow (8.33:1 on the dark ground). + // `accent` derives from the brand token; the tints below must be re-derived + // with it or they silently stay blue. + accent: baseTokens.brand.accentLight, + accentHover: '#ffc233', + accentGlow: 'rgba(255, 175, 0, 0.25)', + accentBorder: 'rgba(255, 175, 0, 0.22)', + accentBorderHover: 'rgba(255, 175, 0, 0.4)', + accentSurface: 'rgba(255, 175, 0, 0.1)', +``` + +Leave the surface and text values above it untouched: that file states they are +deliberately aligned to `@threadplane/chat` so embedded chat has no colour +seam, and the cockpit consumes them. + +- [ ] **Step 7: Confirm no blue survives in the token sources** + +```bash +grep -rn "64C3FD\|64c3fd\|8dd4ff\|100, 195, 253\|004090" libs/design-tokens/src/lib/*.ts || echo "clean" +``` + +Expected: `clean` + +- [ ] **Step 8: Regenerate the CSS** + +```bash +npx nx run design-tokens:generate-theme-css +``` + +- [ ] **Step 9: Run the tests** + +```bash +npx nx test design-tokens +``` + +Expected: PASS. If `token-css-parity.spec.ts` fails complaining about `brand.signal` and friends having no CSS var, that is expected — Task 3 adds them. Note which names it lists and continue. + +- [ ] **Step 10: Commit** + +```bash +git add libs/design-tokens/src/lib/base.ts libs/design-tokens/src/lib/light.ts libs/design-tokens/src/lib/dark.ts libs/design-tokens/src/lib/shadows.ts libs/design-tokens/src/lib/css-vars.spec.ts libs/design-tokens/src/lib/tokens.css libs/design-tokens/src/lib/tokens-dark.css libs/design-tokens/src/lib/theme.css +git commit -m "feat(design-tokens): ATC palette — the accent role splits into navy ink and yellow signal" +``` + +--- + +## Task 3: Emit the new brand tokens through the generator + +The five new brand entries exist in TypeScript but reach no stylesheet yet. + +**Files:** +- Modify: `libs/design-tokens/scripts/generate-theme-css.ts` (the `/* Brand */` block near line 80, and the `--ds-*` brand lines near line 205) +- Modify: `libs/design-tokens/src/lib/css-vars.ts:49-52` +- Test: `libs/design-tokens/src/lib/token-css-parity.spec.ts:19-23` +- Test: `libs/design-tokens/src/lib/ds-var-contract.spec.ts` + +- [ ] **Step 1: Add the parity-map entries** + +In `token-css-parity.spec.ts`, extend the Brand block: + +```typescript + // Brand (theme-invariant) + 'brand.accent': '--color-accent', + 'brand.accentLight': '--color-accent-light', + 'brand.signal': '--color-signal', + 'brand.signalStrong': '--color-signal-strong', + 'brand.scope': '--color-scope', + 'brand.alert': '--color-alert', + 'brand.ink': '--color-ink', + 'brand.angularRed': '--color-angular-red', +``` + +- [ ] **Step 2: Add the contract entries** + +In `ds-var-contract.spec.ts`, add to the name list, keeping it alphabetical alongside the existing `--ds-accent*` entries: + +```typescript + '--ds-alert', + '--ds-ink', + '--ds-scope', + '--ds-signal', + '--ds-signal-strong', +``` + +- [ ] **Step 3: Run and watch both fail** + +```bash +npx nx test design-tokens +``` + +Expected: FAIL — parity reports the new `brand.*` leaves have no CSS var, and the contract reports the five `--ds-*` names are missing from `tokens.css`. + +- [ ] **Step 4: Emit the Tailwind names** + +In `generate-theme-css.ts`, extend the `/* Brand */` block: + +```typescript + lines.push(` --color-accent-light: ${brand.accentLight};`); + lines.push(` --color-signal: ${brand.signal};`); + lines.push(` --color-signal-strong: ${brand.signalStrong};`); + lines.push(` --color-scope: ${brand.scope};`); + lines.push(` --color-alert: ${brand.alert};`); + lines.push(` --color-ink: ${brand.ink};`); + lines.push(` --color-angular-red: ${brand.angularRed};`); +``` + +- [ ] **Step 5: Emit the `--ds-*` names** + +In the same file, extend the brand lines in the `--ds-*` section: + +```typescript + lines.push(` --ds-signal: ${brand.signal};`); + lines.push(` --ds-signal-strong: ${brand.signalStrong};`); + lines.push(` --ds-scope: ${brand.scope};`); + lines.push(` --ds-alert: ${brand.alert};`); + lines.push(` --ds-ink: ${brand.ink};`); + lines.push(` --ds-angular-red: ${brand.angularRed};`); +``` + +- [ ] **Step 6: Add them to the runtime emitter** + +In `css-vars.ts`, extend the "Raw brand colors (invariant)" block: + +```typescript + '--ds-accent-light': brand.accentLight, + '--ds-signal': brand.signal, + '--ds-signal-strong': brand.signalStrong, + '--ds-scope': brand.scope, + '--ds-alert': brand.alert, + '--ds-ink': brand.ink, + '--ds-angular-red': brand.angularRed, +``` + +- [ ] **Step 7: Regenerate and test** + +```bash +npx nx run design-tokens:generate-theme-css && npx nx test design-tokens +``` + +Expected: PASS, all suites. + +- [ ] **Step 8: Commit** + +```bash +git add libs/design-tokens/ +git commit -m "feat(design-tokens): emit signal, signal-strong, scope, alert and ink" +``` + +--- + +## Task 4: Typography values + +**Files:** +- Modify: `libs/design-tokens/src/lib/typography.ts` +- Test: `libs/design-tokens/src/lib/css-vars.spec.ts` (the typography block at the end) + +- [ ] **Step 1: Update the typography assertion** + +Replace the final `it` block in `css-vars.spec.ts`: + +```typescript + it('typography tokens are identical across themes', () => { + expect(cssVars('light')['--ds-font-display']).toBe(cssVars('dark')['--ds-font-display']); + expect(cssVars('light')['--ds-font-sans']).toBe(cssVars('dark')['--ds-font-sans']); + expect(cssVars('light')['--ds-font-diagram']).toBe(cssVars('dark')['--ds-font-diagram']); + }); + + it('keeps diagrams off the brand face', () => { + // Diagram geometry is pinned to Inter's metrics by + // apps/website/e2e/home-architecture.spec.ts. Archivo would change every + // glyph width and silently overflow the cards. + expect(cssVars('light')['--ds-font-diagram']).toContain('Inter'); + expect(cssVars('light')['--ds-font-sans']).not.toContain('Inter'); + }); +``` + +- [ ] **Step 2: Run and watch it fail** + +```bash +npx nx test design-tokens +``` + +Expected: FAIL — `--ds-font-display` and `--ds-font-diagram` are `undefined`. + +- [ ] **Step 3: Update `typography.ts`** + +Replace the doc comment's first three bullets and the three family constants: + +```typescript +/** + * - Display (Archivo Black): headlines and the wordmark. Single weight. + * - Sans (Archivo): body text, UI elements + * - Diagram (Inter): diagram text ONLY — see fontDiagram + * - Mono (JetBrains Mono): code, labels, metadata + */ +``` + +```typescript + /** Display face for headlines. Archivo Black ships one weight (400). */ + fontDisplay: '"Archivo Black", system-ui, sans-serif', + /** Text and UI face. */ + fontSans: 'Archivo, system-ui, sans-serif', + /** + * Diagram text, deliberately NOT the brand face. + * + * Diagrams are information, not brand surface, and their geometry is tuned + * to Inter's metrics: EnterpriseArchitecture.tsx pins every rectangle to an + * 8px grid and home-architecture.spec.ts measures each rendered text run + * against its card. Retyping diagrams would force a geometry rework larger + * than the retheme itself. + */ + fontDiagram: 'Inter, system-ui, sans-serif', +``` + +Then update `h1.family` and `h2.family` to `'var(--font-display)'`, and `h3` / `bodyLg` / `body` / `caption` `.family` to `'var(--font-sans)'`. + +- [ ] **Step 4: Update the emitters** + +In `css-vars.ts`, replace the Typography block: + +```typescript + // Typography (invariant) + '--ds-font-display': typography.fontDisplay, + '--ds-font-sans': typography.fontSans, + '--ds-font-diagram': typography.fontDiagram, + '--ds-font-mono': typography.fontMono, +``` + +In `generate-theme-css.ts`, replace the `/* Fonts */` block: + +```typescript + lines.push(` --font-display: ${typography.fontDisplay};`); + lines.push(` --font-sans: ${typography.fontSans};`); + lines.push(` --font-diagram: ${typography.fontDiagram};`); + lines.push(` --font-mono: ${typography.fontMono};`); +``` + +and the `--ds-*` typography block: + +```typescript + lines.push(` --ds-font-display: ${typography.fontDisplay};`); + lines.push(` --ds-font-sans: ${typography.fontSans};`); + lines.push(` --ds-font-diagram: ${typography.fontDiagram};`); + lines.push(` --ds-font-mono: ${typography.fontMono};`); +``` + +- [ ] **Step 5: Update the two remaining specs** + +In `token-css-parity.spec.ts`, replace the Font families block: + +```typescript + // Font families + 'typography.fontDisplay': '--font-display', + 'typography.fontSans': '--font-sans', + 'typography.fontDiagram': '--font-diagram', + 'typography.fontMono': '--font-mono', +``` + +In `ds-var-contract.spec.ts`, replace `'--ds-font-serif',` with: + +```typescript + '--ds-font-diagram', + '--ds-font-display', +``` + +- [ ] **Step 6: Regenerate and test** + +```bash +npx nx run design-tokens:generate-theme-css && npx nx test design-tokens +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add libs/design-tokens/ +git commit -m "feat(design-tokens): Archivo display and text faces, Inter retained for diagrams" +``` + +--- + +## Task 5: Rename the font vars in consumers, with the diagram carve-out + +The generator now emits `--font-display` / `--font-sans` / `--font-diagram`, but `apps/website` and `workspace-react` still reference `--font-garamond` and `--font-inter`, which no longer exist. **The site is visually broken between Task 4 and this task** — that is expected; do not stop to investigate it. + +The carve-out and the rename land in **one commit**. A blind replacement sends the diagram rules to Archivo and breaks the overflow e2e, possibly only at 390px. + +**Files:** +- Modify: `libs/workspace-react/src/styles/workspace.css:26` +- Modify: `apps/website/src/styles/*.css` (55 `--font-garamond`, 128 `--font-inter`) +- Modify (carve-out): `apps/website/src/styles/landing.css` `.arch-figure text` +- Modify (carve-out): `apps/website/src/styles/docs.css` `.tp-diagram-node[data-title="sans"] .tp-diagram-title` and `.tp-diagram-meta` + +- [ ] **Step 1: Do the mechanical rename** + +```bash +grep -rl -e '--font-garamond' -e '--font-inter' apps/website/src libs/workspace-react/src \ + | xargs sed -i '' -e 's/--font-garamond/--font-display/g' -e 's/--font-inter/--font-sans/g' +``` + +- [ ] **Step 2: Confirm no references survive** + +```bash +grep -rn -e '--font-garamond' -e '--font-inter' apps/website/src libs/workspace-react/src || echo "clean" +``` + +Expected: `clean` + +- [ ] **Step 3: Apply the diagram carve-out** + +In `apps/website/src/styles/landing.css`, find `.arch-figure text` (a single blanket rule covering every text node in the architecture figure) and set: + +```css +.arch-figure text { + font-family: var(--font-diagram); +} +``` + +In `apps/website/src/styles/docs.css`, set both diagram-kit sans rules: + +```css +.tp-diagram-node[data-title="sans"] .tp-diagram-title { + font-family: var(--font-diagram); +} +``` + +```css +.tp-diagram-meta { + font-family: var(--font-diagram); +} +``` + +Leave every diagram rule already on `var(--font-mono)` alone — mono was never Inter. + +- [ ] **Step 4: Verify the carve-out is exactly three declarations** + +```bash +grep -rn "var(--font-diagram)" apps/website/src/styles/ +``` + +Expected: exactly 3 lines — one in `landing.css`, two in `docs.css`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/styles libs/workspace-react/src/styles +git commit -m "refactor(website): --font-display/--font-sans, with diagrams carved out onto --font-diagram" +``` + +--- + +## Task 6: Load the new fonts + +**Files:** +- Modify: `apps/website/src/app/layout.tsx:2,23-37,69` + +- [ ] **Step 1: Replace the font imports and declarations** + +```typescript +import { Archivo, Archivo_Black, Inter, JetBrains_Mono } from 'next/font/google'; +``` + +```typescript +const display = Archivo_Black({ + subsets: ['latin'], + weight: '400', + variable: '--font-display', +}); + +const sans = Archivo({ + subsets: ['latin'], + variable: '--font-sans', +}); + +/** + * Inter is retained for diagrams only. Their geometry is pinned to Inter's + * metrics by apps/website/e2e/home-architecture.spec.ts — see the token + * comment on typography.fontDiagram. + */ +const diagram = Inter({ + subsets: ['latin'], + variable: '--font-diagram', +}); + +const mono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', +}); +``` + +- [ ] **Step 2: Update the `` class list** + +```tsx + +``` + +- [ ] **Step 3: Confirm EB Garamond is gone from the app** + +```bash +grep -rn "Garamond" apps/website/src/app/layout.tsx || echo "clean" +``` + +Expected: `clean`. (`Garamond` still appears under `src/app/card/` and `scripts/generate-whitepaper.ts` — those are out of scope by design, see spec §7.) + +- [ ] **Step 4: Run the website unit tests** + +```bash +npx nx test website +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/app/layout.tsx +git commit -m "feat(website): load Archivo Black and Archivo, drop EB Garamond" +``` + +--- + +## Task 7: The signal surface variant + +**Files:** +- Modify: `apps/website/src/components/ui/Section.tsx:4` +- Modify: `apps/website/src/styles/ui.css` (after the `[data-surface="dark"]` scope) +- Test: `apps/website/src/components/ui/Section.spec.tsx` + +- [ ] **Step 1: Write the failing test** + +Add to `Section.spec.tsx`: + +```tsx + it('renders data-surface="signal" when asked', () => { + const { container } = render(
x
); + const el = container.querySelector('[data-ui="section"]'); + expect(el?.getAttribute('data-surface')).toBe('signal'); + }); +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +npx nx test website +``` + +Expected: FAIL — TypeScript rejects `"signal"` as not assignable to `Surface`. + +- [ ] **Step 3: Extend the union** + +In `Section.tsx`: + +```typescript +type Surface = 'canvas' | 'tinted' | 'white' | 'dark' | 'signal'; +``` + +- [ ] **Step 4: Add the CSS scope** + +In `ui.css`, directly after the `[data-surface="dark"]` rule and its `::before` seam: + +```css +/* Signal section scope (the ATC hero block). + * Aviation yellow is a FILL: this scope flips the ink to near-black rather + * than tinting the yellow, because #FFAF00 carries text at 1.84:1 on white + * and 10.73:1 under #0A0A0A. Same re-scoping mechanism as the dark surface + * above. */ +[data-ui="section"][data-surface="signal"] { + --color-canvas: var(--color-signal); + --color-surface: var(--color-signal); + --color-surface-tinted: var(--color-signal-strong); + --color-border: rgba(10, 10, 10, 0.18); + --color-border-strong: rgba(10, 10, 10, 0.32); + --color-text-primary: var(--color-ink); + --color-text-secondary: rgba(10, 10, 10, 0.78); + --color-text-muted: rgba(10, 10, 10, 0.6); + --color-text-inverted: var(--color-signal); + --color-accent: var(--color-ink); + --color-accent-hover: #000000; + --color-accent-surface: rgba(10, 10, 10, 0.08); + --color-accent-border: rgba(10, 10, 10, 0.25); + --shadow-focus: 0 0 0 3px rgba(10, 10, 10, 0.45); + + background: var(--color-signal); + color: var(--color-text-primary); +} +``` + +- [ ] **Step 5: Run the test** + +```bash +npx nx test website +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/components/ui/Section.tsx apps/website/src/components/ui/Section.spec.tsx apps/website/src/styles/ui.css +git commit -m "feat(website): signal surface scope for the ATC hero block" +``` + +--- + +## Task 8: The yellow hero and its data strip + +**Files:** +- Modify: `apps/website/src/components/landing/Hero.tsx:31,63` +- Modify: `apps/website/src/styles/landing.css` (`.hero-heading`, `.marker-highlight`, `.hero-trust`) + +- [ ] **Step 1: Put the hero on the signal surface** + +In `Hero.tsx`, change the opening `Section`: + +```tsx +
+``` + +- [ ] **Step 2: Turn the trust line into the frequency strip** + +Replace the `

` line: + +```tsx +

{HERO_TRUST_LINE}

+``` + +- [ ] **Step 3: Restyle the heading for the display face** + +In `landing.css`, replace the `.hero-heading` rule's family, weight and tracking: + +```css +.hero-heading { + font-family: var(--font-display); + font-size: var(--text-h1); + line-height: var(--text-h1--line-height); + font-weight: 400; /* Archivo Black ships a single weight */ + color: var(--color-text-primary); + margin: 0; + margin-bottom: 24px; + letter-spacing: -0.032em; +} +``` + +- [ ] **Step 4: Move the marker highlight off hardcoded navy** + +`.marker-highlight` hardcodes `rgba(0, 64, 144, …)`, which no token change reaches. On the yellow block a yellow highlight is invisible, so it becomes a solid ink underline: + +```css +.marker-highlight { + color: var(--color-text-primary); + font-weight: 700; + background: none; + box-shadow: inset 0 -3px 0 var(--color-accent); + border-radius: 0; + padding: 1px 2px 3px; + margin: -1px -2px -3px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +``` + +- [ ] **Step 5: Style the strip** + +Append to `landing.css`: + +```css +/* The frequency strip — the ATC player's bottom bar. Full-bleed scope navy + * closing the yellow block, set in mono because the instrument-panel cue is + * doing as much work here as the colour is. */ +.hero-strip { + margin: 32px calc(50% - 50vw) 0; + width: 100vw; + padding: 10px var(--spacing-container-x); + background: var(--color-scope); + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; +} +``` + +- [ ] **Step 6: Run the unit tests** + +```bash +npx nx test website +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/website/src/components/landing/Hero.tsx apps/website/src/styles/landing.css +git commit -m "feat(website): yellow hero block closed by the scope-navy frequency strip" +``` + +--- + +## Task 9: Nav and the dark band + +**Files:** +- Modify: `apps/website/src/styles/chrome.css` (logo mark, nav CTA) +- Modify: `apps/website/src/styles/ui.css` (`[data-surface="dark"]` scope) + +- [ ] **Step 1: Retint the dark band onto scope navy** + +In `ui.css`, inside `[data-ui="section"][data-surface="dark"]`, replace the six accent lines and the `background`: + +```css + --color-accent: var(--color-signal); + --color-accent-hover: #ffc233; + --color-accent-glow: rgba(255, 175, 0, 0.25); + --color-accent-border: rgba(255, 175, 0, 0.22); + --color-accent-border-hover: rgba(255, 175, 0, 0.4); + --color-accent-surface: rgba(255, 175, 0, 0.1); + + background: linear-gradient(180deg, #1b2e4d 0%, #15253e 100%); +``` + +Then change the `::before` seam gradient from `rgba(100, 195, 253, 0.55)` to `rgba(255, 175, 0, 0.55)` at both stops. + +- [ ] **Step 1b: Correct the comment above that rule** + +The rule's existing comment says its values mirror `dark.ts` and to update it +when `dark.ts` changes. That is now only half true — the accents still mirror, +but the surfaces deliberately do not. Leaving the comment would send the next +person to make the library dark theme navy, which would put a colour seam in +embedded chat. Replace it: + +```css +/* Dark section scope (homepage proof band + FinalCTA dark variant). + * The ACCENTS mirror libs/design-tokens/src/lib/dark.ts. The SURFACES + * deliberately do not: this band is website-only and takes the ATC scope + * navy, while dark.ts stays neutral because @threadplane/chat and the cockpit + * consume it and a navy ground there would seam against embedded chat. + * Treatment B (spec): vertical gradient canvas, 1px accent seam at the + * light→dark boundary. */ + +- [ ] **Step 2: Give the nav its yellow mark** + +The nav keeps a white ground — yellow reads as a band, not as chrome. + +`PlaneMark` is an inline SVG whose path is `fill="currentColor"`, and +`ui.css:328` currently sets `[data-ui="logo-mark-icon"] { color: inherit; }`. +So the mark takes the signal through `color`, not `background` or `fill`. +Replace that rule in `apps/website/src/styles/ui.css`: + +```css +[data-ui="logo-mark-icon"] { + display: block; + flex: none; + /* PlaneMark's path is fill="currentColor", so the signal arrives via color. + The wordmark beside it keeps --color-text-primary. */ + color: var(--color-signal); +} +``` + +On the yellow hero block that would be yellow-on-yellow, so pin it back inside +the signal scope. Append to the `[data-ui="section"][data-surface="signal"]` +rule added in Task 7: + +```css +[data-ui="section"][data-surface="signal"] [data-ui="logo-mark-icon"] { + color: var(--color-ink); +} +``` + +- [ ] **Step 3: Run the unit tests** + +```bash +npx nx test website +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/styles/chrome.css apps/website/src/styles/ui.css +git commit -m "feat(website): yellow nav mark and a scope-navy dark band" +``` + +--- + +## Task 10: Docs shell + +Yellow is reduced to rules. Docs stay a document. + +**Files:** +- Modify: `apps/website/src/styles/docs.css` (the hardcoded `#004090` at ~line 1697, the active sidebar item, the callout rule) + +- [ ] **Step 1: Find the hardcoded navy** + +```bash +grep -n "004090" apps/website/src/styles/docs.css +``` + +- [ ] **Step 2: Replace it with the token** + +Change `color: #004090;` to: + +```css + color: var(--color-accent); +``` + +- [ ] **Step 3: Turn the active-item fill into a signal rule** + +The active rule already exists at `docs.css:917` as +`[data-docs-navlink][data-active]` — every sidebar link carries both +attributes. It currently fills with `--color-accent-surface`, which is now a +yellow tint; a rule reads better than a wash and matches the surface +discipline in spec §6. Replace it: + +```css +[data-docs-navlink][data-active] { + color: var(--color-text-primary); + background: none; + border-left: 3px solid var(--color-signal); + padding-left: 9px; + margin-left: -12px; + font-weight: 700; +} +``` + +- [ ] **Step 4: Run the unit tests** + +```bash +npx nx test website +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/styles/docs.css +git commit -m "feat(website): docs shell takes the signal as a rule, not a fill" +``` + +--- + +## Task 11: Verification + +No code. This is where the two load-bearing e2e specs get their say. + +- [ ] **Step 1: Full unit suites** + +```bash +npx nx test design-tokens && npx nx test website +``` + +Expected: PASS both. + +- [ ] **Step 2: The diagram carve-out gate** + +```bash +npx nx e2e website -- --grep "architecture" +``` + +Expected: PASS **unchanged**. If it fails, the rename swept the diagram rules into Archivo — restore `var(--font-diagram)` on the three declarations from Task 5. Do **not** adjust the diagram geometry; that bakes Archivo's metrics into the data module and makes the problem permanent. + +- [ ] **Step 3: The hero demo gate** + +```bash +npx nx e2e website -- --grep "hero" +``` + +Expected: PASS. If the demo iframe never loads, the yellow block's padding pushed the stage past `HeroDemo.tsx:69`'s `threshold: 0.25`. Fix by scrolling the stage into view in the test — not by shrinking the hero copy. + +- [ ] **Step 4: Full e2e** + +```bash +npx nx e2e website +``` + +Expected: PASS. + +- [ ] **Step 5: Look at it** + +Start the dev server through the Browser pane preview tools (never `npm run dev` via Bash), then check `/` and a docs page at desktop width and at 390px. Confirm by reading the rendered page, not by assuming: +- the hero yellow ends at the strip and does not bleed into the section below +- the nav CTA is black-on-yellow, not white-on-yellow +- docs body text is Archivo and diagram text is still Inter +- the focus ring is visible when tabbing to the hero's primary button + +- [ ] **Step 6: Contrast check from the rendered page** + +In the Browser pane console, sample the computed values rather than trusting the plan's table: + +```javascript +const el = document.querySelector('.hero-heading'); +getComputedStyle(el).color + ' on ' + getComputedStyle(el.closest('[data-surface]')).backgroundColor +``` + +Expected: `rgb(10, 10, 10)` on `rgb(255, 175, 0)`. + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A +git commit -m "fix(website): ATC theme verification pass" +``` + +--- + +## Out of scope — do not fix these + +Per spec §7, these keep navy and EB Garamond and **will visibly diverge**. Leaving them is the plan working correctly: + +- `apps/website/src/app/card/` — the OG/social card pipeline, including `card/tokens.ts`'s own `#004090` and the bundled `EBGaramond-Bold.ttf` that `card.spec.ts` asserts by filename +- `apps/website/src/app/opengraph-image.tsx` and `blog/[slug]/opengraph-image.tsx` +- `apps/website/scripts/generate-whitepaper.ts` +- `apps/website/src/app/icon.svg` — the favicon stays navy +- The cockpit and example apps — they inherit the new `--ds-*` values but are not visually reviewed here +- Marketing pages other than home — they inherit tokens and fonts automatically; they get a look during Task 11 Step 5 but no hand-tuning From 2cad6bc66cc46d37c83ad66f7ab396d059be10ef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:32:05 -0700 Subject: [PATCH 04/24] =?UTF-8?q?feat(design-tokens):=20ATC=20palette=20?= =?UTF-8?q?=E2=80=94=20the=20accent=20role=20splits=20into=20navy=20ink=20?= =?UTF-8?q?and=20yellow=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- libs/design-tokens/src/lib/base.ts | 24 ++++++++++++++++----- libs/design-tokens/src/lib/css-vars.spec.ts | 15 ++++++++----- libs/design-tokens/src/lib/dark.ts | 14 ++++++------ libs/design-tokens/src/lib/light.ts | 18 ++++++++++------ libs/design-tokens/src/lib/shadows.ts | 7 ++++-- libs/design-tokens/src/lib/theme.css | 18 ++++++++-------- libs/design-tokens/src/lib/tokens-dark.css | 16 +++++++------- libs/design-tokens/src/lib/tokens.css | 18 ++++++++-------- 8 files changed, 79 insertions(+), 51 deletions(-) diff --git a/libs/design-tokens/src/lib/base.ts b/libs/design-tokens/src/lib/base.ts index c06c71af8..05b024c37 100644 --- a/libs/design-tokens/src/lib/base.ts +++ b/libs/design-tokens/src/lib/base.ts @@ -14,11 +14,25 @@ export const baseTokens = Object.freeze({ radius, shadows, brand: Object.freeze({ - /** LangGraph navy — used as the light-theme semantic accent */ - accent: '#004090', - /** Bright sky blue — used as the dark-theme semantic accent */ - accentLight: '#64C3FD', - /** Angular brand red */ + /** Scope navy — the ATC radar panel. The light-theme interactive ink. */ + accent: '#15253E', + /** + * Aviation yellow. Fill only: 1.84:1 on white, so it is never a text or + * icon colour on a light surface. Doubles as the dark-theme accent, where + * it reaches 8.33:1 — dark.ts derives its accent from this name. + */ + accentLight: '#FFAF00', + /** Aviation yellow, by its intended name. Same value as accentLight. */ + signal: '#FFAF00', + /** Frequency-strip amber. Full-bleed data strips only. */ + signalStrong: '#FFB700', + /** Scope navy — dark grounds and data strips. */ + scope: '#15253E', + /** LIVE red-orange. 3.68:1, so status fills and large bold text only. */ + alert: '#FF3200', + /** Near-black. Text on yellow, and the light-theme text primary. */ + ink: '#0A0A0A', + /** Angular brand red — trademark colour, not ours to retheme */ angularRed: '#DD0031', /** Render library green */ renderGreen: '#1a7a40', diff --git a/libs/design-tokens/src/lib/css-vars.spec.ts b/libs/design-tokens/src/lib/css-vars.spec.ts index 7725c21b6..e30e7e859 100644 --- a/libs/design-tokens/src/lib/css-vars.spec.ts +++ b/libs/design-tokens/src/lib/css-vars.spec.ts @@ -9,12 +9,17 @@ describe('cssVars(theme)', () => { expect(vars['--ds-canvas']).toBe('rgb(255, 255, 255)'); }); - it('uses navy accent', () => { - expect(vars['--ds-accent']).toBe('#004090'); + it('uses scope-navy accent as the interactive ink', () => { + expect(vars['--ds-accent']).toBe('#15253E'); }); - it('uses dark text on light surfaces', () => { - expect(vars['--ds-text-primary']).toBe('rgb(28, 28, 28)'); + it('uses near-black text on light surfaces', () => { + expect(vars['--ds-text-primary']).toBe('#0A0A0A'); + }); + + it('exposes aviation yellow as a fill-only signal, not as the ink', () => { + expect(vars['--ds-accent-light']).toBe('#FFAF00'); + expect(vars['--ds-accent']).not.toBe(vars['--ds-accent-light']); }); }); @@ -26,7 +31,7 @@ describe('cssVars(theme)', () => { }); it('uses bright-blue accent', () => { - expect(vars['--ds-accent']).toBe('#64C3FD'); + expect(vars['--ds-accent']).toBe('#FFAF00'); }); it('uses near-white text on dark surfaces', () => { diff --git a/libs/design-tokens/src/lib/dark.ts b/libs/design-tokens/src/lib/dark.ts index be4213194..ba5669509 100644 --- a/libs/design-tokens/src/lib/dark.ts +++ b/libs/design-tokens/src/lib/dark.ts @@ -24,13 +24,15 @@ export const darkOverrides = Object.freeze({ bg: 'rgb(17, 17, 17)', sidebarBg: 'rgba(28, 28, 28, 0.65)', - // Semantic accent maps to the bright-blue brand color (readable on dark surfaces) + // Semantic accent maps to aviation yellow (8.33:1 on the dark ground). + // `accent` derives from the brand token; the tints below must be re-derived + // with it or they silently stay blue. accent: baseTokens.brand.accentLight, - accentHover: '#8dd4ff', - accentGlow: 'rgba(100, 195, 253, 0.25)', - accentBorder: 'rgba(100, 195, 253, 0.2)', - accentBorderHover: 'rgba(100, 195, 253, 0.35)', - accentSurface: 'rgba(100, 195, 253, 0.08)', + accentHover: '#ffc233', + accentGlow: 'rgba(255, 175, 0, 0.25)', + accentBorder: 'rgba(255, 175, 0, 0.22)', + accentBorderHover: 'rgba(255, 175, 0, 0.4)', + accentSurface: 'rgba(255, 175, 0, 0.1)', } as const); export type DarkOverrides = typeof darkOverrides; diff --git a/libs/design-tokens/src/lib/light.ts b/libs/design-tokens/src/lib/light.ts index b8eec8a8e..086a0b4ad 100644 --- a/libs/design-tokens/src/lib/light.ts +++ b/libs/design-tokens/src/lib/light.ts @@ -17,7 +17,7 @@ export const lightOverrides = Object.freeze({ borderStrong: 'rgb(200, 200, 200)', // Text - textPrimary: 'rgb(28, 28, 28)', + textPrimary: '#0A0A0A', textSecondary: 'rgb(70, 70, 70)', textMuted: 'rgb(115, 115, 115)', textInverted: 'rgb(255, 255, 255)', @@ -26,13 +26,17 @@ export const lightOverrides = Object.freeze({ bg: 'rgb(255, 255, 255)', sidebarBg: 'rgba(255, 255, 255, 0.45)', - // Semantic accent maps to the navy brand color (unchanged — cockpit identity) + // Semantic accent is the interactive INK (links, focus, borders) and stays + // dark: aviation yellow is 1.84:1 on white and cannot carry text. The tints + // below are backgrounds, so they carry the yellow instead — which is how + // [data-ui="pill"][data-variant="accent"] already composes: yellow surface, + // navy ink on top. accent: baseTokens.brand.accent, - accentHover: '#003070', - accentGlow: 'rgba(0, 64, 144, 0.2)', - accentBorder: 'rgba(0, 64, 144, 0.15)', - accentBorderHover: 'rgba(0, 64, 144, 0.3)', - accentSurface: 'rgba(0, 64, 144, 0.06)', + accentHover: '#0E1B2E', + accentGlow: 'rgba(255, 175, 0, 0.28)', + accentBorder: 'rgba(255, 175, 0, 0.35)', + accentBorderHover: 'rgba(255, 175, 0, 0.55)', + accentSurface: 'rgba(255, 175, 0, 0.10)', } as const); export type LightOverrides = typeof lightOverrides; diff --git a/libs/design-tokens/src/lib/shadows.ts b/libs/design-tokens/src/lib/shadows.ts index 3f095e4a9..251bf66a4 100644 --- a/libs/design-tokens/src/lib/shadows.ts +++ b/libs/design-tokens/src/lib/shadows.ts @@ -11,8 +11,11 @@ export const shadows = Object.freeze({ md: '0 4px 6px -1px rgba(0, 0, 0, 0.10), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', /** Strong — floating elements, hero collage */ lg: '0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', - /** Keyboard focus ring (accent-tinted; unchanged) */ - focus: '0 0 0 3px rgba(0, 64, 144, 0.25)', + /** + * Keyboard focus ring. Deliberately scope navy and NOT the signal yellow: + * a yellow ring on a white page is 1.84:1 and effectively invisible. + */ + focus: '0 0 0 3px rgba(21, 37, 62, 0.30)', } as const); export type Shadows = typeof shadows; diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index 43f9f2680..7f4c51ae9 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -23,7 +23,7 @@ --color-border-strong: rgb(200, 200, 200); /* Text */ - --color-text-primary: rgb(28, 28, 28); + --color-text-primary: #0A0A0A; --color-text-secondary: rgb(70, 70, 70); --color-text-muted: rgb(115, 115, 115); --color-text-inverted: rgb(255, 255, 255); @@ -33,15 +33,15 @@ --color-sidebar-bg: rgba(255, 255, 255, 0.45); /* Accent family */ - --color-accent: #004090; - --color-accent-hover: #003070; - --color-accent-glow: rgba(0, 64, 144, 0.2); - --color-accent-border: rgba(0, 64, 144, 0.15); - --color-accent-border-hover: rgba(0, 64, 144, 0.3); - --color-accent-surface: rgba(0, 64, 144, 0.06); + --color-accent: #15253E; + --color-accent-hover: #0E1B2E; + --color-accent-glow: rgba(255, 175, 0, 0.28); + --color-accent-border: rgba(255, 175, 0, 0.35); + --color-accent-border-hover: rgba(255, 175, 0, 0.55); + --color-accent-surface: rgba(255, 175, 0, 0.10); /* Brand */ - --color-accent-light: #64C3FD; + --color-accent-light: #FFAF00; --color-angular-red: #DD0031; --color-render-green: #1a7a40; --color-chat-purple: #5a00c8; @@ -81,7 +81,7 @@ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --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); + --shadow-focus: 0 0 0 3px rgba(21, 37, 62, 0.30); /* Space scale */ --spacing-section-y: clamp(64px, 8vw, 120px); diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css index 1d0323f47..192351be2 100644 --- a/libs/design-tokens/src/lib/tokens-dark.css +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -20,13 +20,13 @@ :root { /* Colors */ --ds-bg: rgb(17, 17, 17); - --ds-accent: #64C3FD; - --ds-accent-hover: #8dd4ff; - --ds-accent-light: #64C3FD; - --ds-accent-glow: rgba(100, 195, 253, 0.25); - --ds-accent-border: rgba(100, 195, 253, 0.2); - --ds-accent-border-hover: rgba(100, 195, 253, 0.35); - --ds-accent-surface: rgba(100, 195, 253, 0.08); + --ds-accent: #FFAF00; + --ds-accent-hover: #ffc233; + --ds-accent-light: #FFAF00; + --ds-accent-glow: rgba(255, 175, 0, 0.25); + --ds-accent-border: rgba(255, 175, 0, 0.22); + --ds-accent-border-hover: rgba(255, 175, 0, 0.4); + --ds-accent-surface: rgba(255, 175, 0, 0.1); --ds-text-primary: rgb(245, 245, 245); --ds-text-secondary: rgb(200, 200, 200); --ds-text-muted: rgb(160, 160, 160); @@ -72,7 +72,7 @@ --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); + --ds-shadow-focus: 0 0 0 3px rgba(21, 37, 62, 0.30); /* Radius */ --ds-radius-sm: 6px; diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css index f2bf372a7..4e85174c9 100644 --- a/libs/design-tokens/src/lib/tokens.css +++ b/libs/design-tokens/src/lib/tokens.css @@ -20,14 +20,14 @@ :root { /* Colors */ --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: rgb(28, 28, 28); + --ds-accent: #15253E; + --ds-accent-hover: #0E1B2E; + --ds-accent-light: #FFAF00; + --ds-accent-glow: rgba(255, 175, 0, 0.28); + --ds-accent-border: rgba(255, 175, 0, 0.35); + --ds-accent-border-hover: rgba(255, 175, 0, 0.55); + --ds-accent-surface: rgba(255, 175, 0, 0.10); + --ds-text-primary: #0A0A0A; --ds-text-secondary: rgb(70, 70, 70); --ds-text-muted: rgb(115, 115, 115); --ds-text-inverted: rgb(255, 255, 255); @@ -72,7 +72,7 @@ --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); + --ds-shadow-focus: 0 0 0 3px rgba(21, 37, 62, 0.30); /* Radius */ --ds-radius-sm: 6px; From 05bee1eb7ff23453768a138cf29de1aa0066c379 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:39:53 -0700 Subject: [PATCH 05/24] docs: card COLOURS are in scope; only its bundled font is not A guard that compares values at test time cannot be satisfied by declaring the divergence intentional in prose. card.spec.ts reads theme.css and asserts card/tokens.ts still matches it, so the earlier scope boundary just left nx test website red for the rest of the arc. Colours are hex literals and cost nothing to move; the font genuinely needs a bundled TTF. Splitting those. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-07-atc-theme-spike.md | 2 +- .../2026-09-07-atc-theme-spike-design.md | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md index 5908d4476..fb40a9c56 100644 --- a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -1019,7 +1019,7 @@ git commit -m "fix(website): ATC theme verification pass" Per spec §7, these keep navy and EB Garamond and **will visibly diverge**. Leaving them is the plan working correctly: -- `apps/website/src/app/card/` — the OG/social card pipeline, including `card/tokens.ts`'s own `#004090` and the bundled `EBGaramond-Bold.ttf` that `card.spec.ts` asserts by filename +- `apps/website/src/app/card/` **fonts only** — the bundled `EBGaramond-Bold.ttf` that `card.spec.ts` asserts by filename. The card's *colours* are NOT out of scope: `card.spec.ts` compares `card/tokens.ts` against `theme.css` at test time, so they move with the retheme (handled in Task 2) - `apps/website/src/app/opengraph-image.tsx` and `blog/[slug]/opengraph-image.tsx` - `apps/website/scripts/generate-whitepaper.ts` - `apps/website/src/app/icon.svg` — the favicon stays navy diff --git a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md index dfd8f1c91..372bf0d36 100644 --- a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md +++ b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md @@ -244,11 +244,22 @@ library approach — but is not hand-tuned. Those pages get a smoke pass only. Named here so the divergence reads as a decision rather than a defect: -- **The OG / social card pipeline** (`apps/website/src/app/card/`, +- **The OG / social card pipeline's TYPE** (`apps/website/src/app/card/`, `opengraph-image.tsx`, `blog/[slug]/opengraph-image.tsx`, `og-font.ts`). It - bundles its own `EBGaramond-Bold.ttf` and carries its own `#004090` in - `card/tokens.ts`, and `card.spec.ts` asserts the bundled filenames. It keeps - navy and Garamond, so a shared link will not match the site. + bundles its own `EBGaramond-Bold.ttf`, and `card.spec.ts` asserts the bundled + filenames, so retyping it means shipping an Archivo Black TTF. The card keeps + Garamond. + + **Its COLOURS are in scope, and this is a correction to an earlier draft of + this spec.** `card/tokens.ts` is a hand-copied snapshot of the light tokens, + and `card.spec.ts` actively compares it against `theme.css` — its docstring + says a snapshot with nothing checking it "would keep rendering, in last + season's colours". Declaring the divergence intentional in a markdown file + does not satisfy a guard that compares values at test time; it just leaves + `nx test website` red for the rest of the arc, which trains everyone to + ignore it. The colours cost nothing to move (they are hex literals, not + font files), so `ink`, `accent`, `accentSurface` and `accentBorder` follow + the retheme and the guard keeps working. - **`apps/website/scripts/generate-whitepaper.ts`** — same reason. - **`apps/website/src/app/icon.svg`** — the favicon stays navy. - **Cockpit and example apps** — they inherit the new values but are not From 1335adb666e25ee1d8609d60da4e2f6e9a7d6a59 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:43:01 -0700 Subject: [PATCH 06/24] fix(design-tokens): card colours follow the retheme; guard the dark accent tints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Satori social card hand-copies the light palette as hex literals, because Satori cannot read CSS variables, and card.spec.ts reads theme.css at test time and asserts the copy still matches. So the retheme left two guards red: ink and accent had moved in the tokens and not in the card. A spec that compares values at test time cannot be satisfied by declaring the divergence intentional in prose — the only way to make it green is to move the card, so the card's ink, accent, accent-surface and accent-border now follow the ATC palette. Only the card's bundled font stays out of scope. Also adds the guard the retheme was missing: darkOverrides.accent derives from brand.accentLight and flips on a repoint, while the four accent tints beside it are hardcoded rgba() and do not. The new dark-theme test derives the expected RGB triple from the token rather than pinning a second literal, so it moves with the palette; it was mutation-checked by reverting accentGlow to the old blue and confirming it goes red. The rest is honesty upkeep on comments the retheme falsified: a dark test named for a blue accent it no longer asserts, an accent-family comment, and the generator docstring that still called the cockpit fallback divergence "minor drift" when those fallbacks are now a different hue entirely. base.ts hoists AVIATION_YELLOW and SCOPE_NAVY so signal cannot drift from accentLight, nor scope from accent. No emitted value changes except #ffc233 -> #FFC233. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/card/tokens.ts | 8 ++++---- .../scripts/generate-theme-css.ts | 7 +++++-- libs/design-tokens/src/lib/base.ts | 16 ++++++++++++---- libs/design-tokens/src/lib/css-vars.spec.ts | 18 ++++++++++++++++-- libs/design-tokens/src/lib/css-vars.ts | 2 +- libs/design-tokens/src/lib/dark.ts | 4 ++-- libs/design-tokens/src/lib/light.ts | 6 +++--- libs/design-tokens/src/lib/tokens-dark.css | 2 +- 8 files changed, 44 insertions(+), 19 deletions(-) diff --git a/apps/website/src/app/card/tokens.ts b/apps/website/src/app/card/tokens.ts index 8f1d53b1c..c27563d5f 100644 --- a/apps/website/src/app/card/tokens.ts +++ b/apps/website/src/app/card/tokens.ts @@ -19,16 +19,16 @@ export const CARD = { /** --color-surface-dim, the user bubble. */ dim: '#f5f5f5', /** --color-text-primary / secondary / muted */ - ink: '#1c1c1c', + ink: '#0a0a0a', inkSecondary: '#464646', inkMuted: '#737373', /** --color-border / --color-border-strong */ border: '#e5e5e5', borderStrong: '#c8c8c8', /** --color-accent and its surface/border tints. */ - accent: '#004090', - accentSurface: 'rgba(0, 64, 144, 0.06)', - accentBorder: 'rgba(0, 64, 144, 0.30)', + accent: '#15253e', + accentSurface: 'rgba(255, 175, 0, 0.10)', + accentBorder: 'rgba(255, 175, 0, 0.35)', /** BrowserFrame traffic lights (ui.css). */ trafficRed: '#FF5F57', trafficAmber: '#FEBC2E', diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index f47aa73a6..1f2d21d7c 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -287,8 +287,11 @@ export function generateTokensCss(): string { /** * Dark twin of tokens.css, from darkOverrides. Exists because the cockpit * example apps were designed dark: their var(--ds-*, fallback) fallbacks are - * hand-copies of darkOverrides (verified value-by-value, 2026-08-30, with - * minor drift - e.g. accentBorder 0.25 vs the token's 0.2). Wiring THIS file + * hand-copies of darkOverrides (verified value-by-value, 2026-08-30). As of + * 2026-09-07 those fallbacks are still the pre-ATC blue - e.g. + * var(--ds-accent, #64c3fd) against a darkOverrides.accent of #FFAF00 - so + * they have diverged from the tokens by HUE, not by a rounding nit, and are + * effectively decorative until the cockpit is reviewed. Wiring THIS file * in makes those fallbacks dead text; wiring the light tokens.css would flip * deliberately-dark apps to light. */ diff --git a/libs/design-tokens/src/lib/base.ts b/libs/design-tokens/src/lib/base.ts index 05b024c37..f8eeaa42a 100644 --- a/libs/design-tokens/src/lib/base.ts +++ b/libs/design-tokens/src/lib/base.ts @@ -3,6 +3,14 @@ import { space } from './space.ts'; import { radius } from './radius.ts'; import { shadows } from './shadows.ts'; +/** + * The two ATC hues that each carry two token names. Hoisted so a repoint moves + * both names at once — `signal` cannot drift from `accentLight`, nor `scope` + * from `accent`. + */ +const AVIATION_YELLOW = '#FFAF00'; +const SCOPE_NAVY = '#15253E'; + /** * Theme-invariant tokens. Same values in light and dark. * Includes typography, spacing, radii, shadows, and brand colors that @@ -15,19 +23,19 @@ export const baseTokens = Object.freeze({ shadows, brand: Object.freeze({ /** Scope navy — the ATC radar panel. The light-theme interactive ink. */ - accent: '#15253E', + accent: SCOPE_NAVY, /** * Aviation yellow. Fill only: 1.84:1 on white, so it is never a text or * icon colour on a light surface. Doubles as the dark-theme accent, where * it reaches 8.33:1 — dark.ts derives its accent from this name. */ - accentLight: '#FFAF00', + accentLight: AVIATION_YELLOW, /** Aviation yellow, by its intended name. Same value as accentLight. */ - signal: '#FFAF00', + signal: AVIATION_YELLOW, /** Frequency-strip amber. Full-bleed data strips only. */ signalStrong: '#FFB700', /** Scope navy — dark grounds and data strips. */ - scope: '#15253E', + scope: SCOPE_NAVY, /** LIVE red-orange. 3.68:1, so status fills and large bold text only. */ alert: '#FF3200', /** Near-black. Text on yellow, and the light-theme text primary. */ diff --git a/libs/design-tokens/src/lib/css-vars.spec.ts b/libs/design-tokens/src/lib/css-vars.spec.ts index e30e7e859..eb427cc43 100644 --- a/libs/design-tokens/src/lib/css-vars.spec.ts +++ b/libs/design-tokens/src/lib/css-vars.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { cssVars } from './css-vars'; +import { baseTokens } from './base'; describe('cssVars(theme)', () => { describe('light', () => { @@ -19,7 +20,7 @@ describe('cssVars(theme)', () => { it('exposes aviation yellow as a fill-only signal, not as the ink', () => { expect(vars['--ds-accent-light']).toBe('#FFAF00'); - expect(vars['--ds-accent']).not.toBe(vars['--ds-accent-light']); + expect(vars['--ds-accent']).not.toBe(baseTokens.brand.accentLight); }); }); @@ -30,13 +31,26 @@ describe('cssVars(theme)', () => { expect(vars['--ds-canvas']).toBe('rgb(17, 17, 17)'); }); - it('uses bright-blue accent', () => { + it('uses aviation yellow as the dark-theme accent', () => { expect(vars['--ds-accent']).toBe('#FFAF00'); }); it('uses near-white text on dark surfaces', () => { expect(vars['--ds-text-primary']).toBe('rgb(245, 245, 245)'); }); + + it('derives every accent tint from the accent hue', () => { + const hex = baseTokens.brand.accentLight.replace('#', ''); + const triple = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', '); + for (const key of [ + '--ds-accent-glow', + '--ds-accent-border', + '--ds-accent-border-hover', + '--ds-accent-surface', + ] as const) { + expect(vars[key]).toContain(triple); + } + }); }); it('both themes expose the same custom-property keys', () => { diff --git a/libs/design-tokens/src/lib/css-vars.ts b/libs/design-tokens/src/lib/css-vars.ts index 5391b7269..6083a6d2a 100644 --- a/libs/design-tokens/src/lib/css-vars.ts +++ b/libs/design-tokens/src/lib/css-vars.ts @@ -37,7 +37,7 @@ export function cssVars(theme: Theme) { '--ds-bg': t.bg, '--ds-sidebar-bg': t.sidebarBg, - // Accent family (theme-variant — semantic accent points to navy in light, bright-blue in dark) + // Accent family (theme-variant — the interactive ink: scope navy in light, aviation yellow in dark) '--ds-accent': t.accent, '--ds-accent-hover': t.accentHover, '--ds-accent-glow': t.accentGlow, diff --git a/libs/design-tokens/src/lib/dark.ts b/libs/design-tokens/src/lib/dark.ts index ba5669509..10cd678f7 100644 --- a/libs/design-tokens/src/lib/dark.ts +++ b/libs/design-tokens/src/lib/dark.ts @@ -26,9 +26,9 @@ export const darkOverrides = Object.freeze({ // Semantic accent maps to aviation yellow (8.33:1 on the dark ground). // `accent` derives from the brand token; the tints below must be re-derived - // with it or they silently stay blue. + // with it or they silently keep the previous hue. accent: baseTokens.brand.accentLight, - accentHover: '#ffc233', + accentHover: '#FFC233', accentGlow: 'rgba(255, 175, 0, 0.25)', accentBorder: 'rgba(255, 175, 0, 0.22)', accentBorderHover: 'rgba(255, 175, 0, 0.4)', diff --git a/libs/design-tokens/src/lib/light.ts b/libs/design-tokens/src/lib/light.ts index 086a0b4ad..998aa535f 100644 --- a/libs/design-tokens/src/lib/light.ts +++ b/libs/design-tokens/src/lib/light.ts @@ -28,9 +28,9 @@ export const lightOverrides = Object.freeze({ // Semantic accent is the interactive INK (links, focus, borders) and stays // dark: aviation yellow is 1.84:1 on white and cannot carry text. The tints - // below are backgrounds, so they carry the yellow instead — which is how - // [data-ui="pill"][data-variant="accent"] already composes: yellow surface, - // navy ink on top. + // below are backgrounds, so they carry the yellow instead — which is the + // shape [data-ui="pill"][data-variant="accent"] already has: accent-surface + // behind, accent ink on top. accent: baseTokens.brand.accent, accentHover: '#0E1B2E', accentGlow: 'rgba(255, 175, 0, 0.28)', diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css index 192351be2..2b3ddea84 100644 --- a/libs/design-tokens/src/lib/tokens-dark.css +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -21,7 +21,7 @@ /* Colors */ --ds-bg: rgb(17, 17, 17); --ds-accent: #FFAF00; - --ds-accent-hover: #ffc233; + --ds-accent-hover: #FFC233; --ds-accent-light: #FFAF00; --ds-accent-glow: rgba(255, 175, 0, 0.25); --ds-accent-border: rgba(255, 175, 0, 0.22); From dc464d9c2404cfc94716e30c14887b71d7ad37cd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:47:49 -0700 Subject: [PATCH 07/24] docs: add a sweep task for the 21 hardcoded brand colours the plan missed Task 2's review found the plan named only 3 of 24 hardcoded old-brand sites in apps/website/src. The rest would have survived the retheme as blue islands. Includes the one site that must NOT take the yellow: ChatLandingCodeShowcase's --chat-user-bg sits directly above --chat-user-color: #ffffff, so white on aviation yellow would be 1.84:1. It takes scope navy instead. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-07-atc-theme-spike.md | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md index fb40a9c56..731375087 100644 --- a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -951,7 +951,94 @@ git commit -m "feat(website): docs shell takes the signal as a rule, not a fill" --- -## Task 11: Verification +## Task 11: Sweep the remaining hardcoded brand colours + +Added after Task 2's review found that the plan named only 3 of the 24 +hardcoded old-brand sites in `apps/website/src`. The rest would have survived +the retheme as blue islands. + +**Files:** +- Modify: `apps/website/src/styles/landing.css` (lines ~631, 945, 1027, 1046) +- Modify: `apps/website/src/styles/docs.css` (lines ~334, 1623, 1696) +- Modify: `apps/website/src/styles/ui.css` (comments at ~252, 258) +- Modify: `apps/website/src/components/landing/chat-landing/ChatLandingCodeShowcase.tsx:25` +- Modify: `apps/website/src/styles/style-contracts.spec.ts:239` (comment prose only) + +- [ ] **Step 1: List what is left** + +```bash +grep -rn "004090\|0, 64, 144\|64c3fd\|64C3FD\|100, 195, 253" apps/website/src --include="*.css" --include="*.tsx" --include="*.ts" +``` + +Tasks 8, 9 and 10 should already have cleared `landing.css:49-50`, +`ui.css:176-199` and `docs.css:1697`. Everything still listed is this task. + +- [ ] **Step 2: Replace the light-surface tints** + +These are all backgrounds, so they take the yellow. Replace the colour only, +keeping each rule's existing alpha: + +- `docs.css` `--callout-tone-surface: rgba(0, 64, 144, 0.06)` → `rgba(255, 175, 0, 0.10)` +- `docs.css` `background: rgba(0, 64, 144, 0.1)` → `rgba(255, 175, 0, 0.14)` +- `docs.css` `background: rgba(0, 64, 144, 0.08)` → `rgba(255, 175, 0, 0.12)` +- `landing.css` `background: rgba(0, 64, 144, 0.04)` → `rgba(255, 175, 0, 0.08)` + +The yellow alphas are raised slightly because `#FFAF00` is a much lighter +colour than `#004090`; at the original alpha the tint is invisible on white. + +- [ ] **Step 3: Replace the dark-surface gradients** + +`landing.css` lines ~631, ~1027 and ~1046 are decorative gradients sitting on +dark surfaces, currently `rgba(100, 195, 253, …)`. Swap the triple to +`255, 175, 0`, keeping every alpha exactly as-is. + +- [ ] **Step 4: The one that must NOT become yellow** + +`ChatLandingCodeShowcase.tsx:25` is `--chat-user-bg: #004090`, and line 26 is +`--chat-user-color: #ffffff`. White on aviation yellow is **1.84:1**. This one +takes the scope navy so the pairing keeps working: + +``` + --chat-user-bg: #15253E; +``` + +(It is a string inside a displayed code snippet, so it teaches the palette to +readers rather than styling anything — which is exactly why it should not +teach an inaccessible pairing.) + +- [ ] **Step 5: Correct the two comments that now describe the old palette** + +`ui.css` ~252 and ~258 explain the focus ring in terms of `#004090` and +`#64c3fd`. Rewrite them for the current palette: the primary button's fill is +now the signal yellow, the ring is scope navy on light, and the dark scope +re-points the accent to yellow. `style-contracts.spec.ts:239`'s `why:` string +makes the same stale claim — update the prose. Do not change the assertion. + +- [ ] **Step 6: Confirm the sweep is complete** + +```bash +grep -rn "004090\|0, 64, 144\|64c3fd\|64C3FD\|100, 195, 253" apps/website/src --include="*.css" --include="*.tsx" --include="*.ts" +``` + +Expected: no results. `apps/website/src/app/icon.svg` still contains `#004090` +and that is correct — the favicon is out of scope per spec §7. + +- [ ] **Step 7: Test and commit** + +```bash +npx nx test website +``` + +Expected: PASS. + +```bash +git add apps/website/src +git commit -m "fix(website): sweep the last hardcoded navy out of the light and dark surfaces" +``` + +--- + +## Task 12: Verification No code. This is where the two load-bearing e2e specs get their say. From 0411e298753708505984d3fe4b10c2671e6a095f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:50:53 -0700 Subject: [PATCH 08/24] feat(design-tokens): emit signal, signal-strong, scope, alert and ink Co-Authored-By: Claude Opus 5 --- libs/design-tokens/scripts/generate-theme-css.ts | 10 ++++++++++ libs/design-tokens/src/lib/css-vars.ts | 5 +++++ libs/design-tokens/src/lib/ds-var-contract.spec.ts | 5 +++++ libs/design-tokens/src/lib/theme.css | 5 +++++ libs/design-tokens/src/lib/token-css-parity.spec.ts | 5 +++++ libs/design-tokens/src/lib/tokens-dark.css | 5 +++++ libs/design-tokens/src/lib/tokens.css | 5 +++++ 7 files changed, 40 insertions(+) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index 1f2d21d7c..023648b1b 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -80,6 +80,11 @@ function buildThemeBlock(): string { lines.push(''); lines.push(' /* Brand */'); lines.push(` --color-accent-light: ${brand.accentLight};`); + lines.push(` --color-signal: ${brand.signal};`); + lines.push(` --color-signal-strong: ${brand.signalStrong};`); + lines.push(` --color-scope: ${brand.scope};`); + lines.push(` --color-alert: ${brand.alert};`); + lines.push(` --color-ink: ${brand.ink};`); lines.push(` --color-angular-red: ${brand.angularRed};`); lines.push(` --color-render-green: ${brand.renderGreen};`); lines.push(` --color-chat-purple: ${brand.chatPurple};`); @@ -203,6 +208,11 @@ function buildTokensBlock(theme: ThemeOverrides): string { lines.push(` --ds-text-muted: ${theme.textMuted};`); lines.push(` --ds-text-inverted: ${theme.textInverted};`); lines.push(` --ds-sidebar-bg: ${theme.sidebarBg};`); + lines.push(` --ds-signal: ${brand.signal};`); + lines.push(` --ds-signal-strong: ${brand.signalStrong};`); + lines.push(` --ds-scope: ${brand.scope};`); + lines.push(` --ds-alert: ${brand.alert};`); + lines.push(` --ds-ink: ${brand.ink};`); lines.push(` --ds-angular-red: ${brand.angularRed};`); lines.push(` --ds-render-green: ${brand.renderGreen};`); lines.push(` --ds-chat-purple: ${brand.chatPurple};`); diff --git a/libs/design-tokens/src/lib/css-vars.ts b/libs/design-tokens/src/lib/css-vars.ts index 6083a6d2a..7fa46f059 100644 --- a/libs/design-tokens/src/lib/css-vars.ts +++ b/libs/design-tokens/src/lib/css-vars.ts @@ -47,6 +47,11 @@ export function cssVars(theme: Theme) { // Raw brand colors (invariant) '--ds-accent-light': brand.accentLight, + '--ds-signal': brand.signal, + '--ds-signal-strong': brand.signalStrong, + '--ds-scope': brand.scope, + '--ds-alert': brand.alert, + '--ds-ink': brand.ink, '--ds-angular-red': brand.angularRed, '--ds-render-green': brand.renderGreen, '--ds-chat-purple': brand.chatPurple, 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 e3cac8c7c..f22ff7ab0 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -22,19 +22,24 @@ const CONSUMER_REFERENCED = [ '--ds-accent-glow', '--ds-accent-hover', '--ds-accent-surface', + '--ds-alert', '--ds-border', '--ds-border-strong', '--ds-canvas', '--ds-font-mono', '--ds-font-sans', '--ds-font-serif', + '--ds-ink', '--ds-radius-lg', '--ds-radius-md', '--ds-radius-sm', '--ds-radius-xl', '--ds-render-green', + '--ds-scope', '--ds-shadow-lg', '--ds-shadow-md', + '--ds-signal', + '--ds-signal-strong', '--ds-surface', '--ds-surface-dim', '--ds-surface-tinted', diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index 7f4c51ae9..046c46692 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -42,6 +42,11 @@ /* Brand */ --color-accent-light: #FFAF00; + --color-signal: #FFAF00; + --color-signal-strong: #FFB700; + --color-scope: #15253E; + --color-alert: #FF3200; + --color-ink: #0A0A0A; --color-angular-red: #DD0031; --color-render-green: #1a7a40; --color-chat-purple: #5a00c8; 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 2228813cb..52ded4d77 100644 --- a/libs/design-tokens/src/lib/token-css-parity.spec.ts +++ b/libs/design-tokens/src/lib/token-css-parity.spec.ts @@ -18,6 +18,11 @@ const CSS_VAR_BY_PATH: Record = { // Brand (theme-invariant) 'brand.accent': '--color-accent', 'brand.accentLight': '--color-accent-light', + 'brand.signal': '--color-signal', + 'brand.signalStrong': '--color-signal-strong', + 'brand.scope': '--color-scope', + 'brand.alert': '--color-alert', + 'brand.ink': '--color-ink', 'brand.angularRed': '--color-angular-red', 'brand.renderGreen': '--color-render-green', 'brand.chatPurple': '--color-chat-purple', diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css index 2b3ddea84..557ced8b1 100644 --- a/libs/design-tokens/src/lib/tokens-dark.css +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -32,6 +32,11 @@ --ds-text-muted: rgb(160, 160, 160); --ds-text-inverted: rgb(17, 17, 17); --ds-sidebar-bg: rgba(28, 28, 28, 0.65); + --ds-signal: #FFAF00; + --ds-signal-strong: #FFB700; + --ds-scope: #15253E; + --ds-alert: #FF3200; + --ds-ink: #0A0A0A; --ds-angular-red: #DD0031; --ds-render-green: #1a7a40; --ds-chat-purple: #5a00c8; diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css index 4e85174c9..caaf20e3c 100644 --- a/libs/design-tokens/src/lib/tokens.css +++ b/libs/design-tokens/src/lib/tokens.css @@ -32,6 +32,11 @@ --ds-text-muted: rgb(115, 115, 115); --ds-text-inverted: rgb(255, 255, 255); --ds-sidebar-bg: rgba(255, 255, 255, 0.45); + --ds-signal: #FFAF00; + --ds-signal-strong: #FFB700; + --ds-scope: #15253E; + --ds-alert: #FF3200; + --ds-ink: #0A0A0A; --ds-angular-red: #DD0031; --ds-render-green: #1a7a40; --ds-chat-purple: #5a00c8; From d269b5263ee58ace9818254b4ab1acaad8e623c5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 20:58:38 -0700 Subject: [PATCH 09/24] test(design-tokens): guard cssVars brand emission; explain the deliberate aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer proved by mutation that deleting all five brand names (--ds-signal, --ds-signal-strong, --ds-scope, --ds-alert, --ds-ink) from css-vars.ts left the full design-tokens suite green: the existing ds-var-sources-agree.spec.ts only checks that tokens.css is a superset of cssVars(), which is correct for the type scale but wrong for brand colors, which have no reason to exist in one emitter and not the other. - Add a scoped reverse check to ds-var-sources-agree.spec.ts asserting cssVars('light') emits all five brand names, without making the whole comparison bidirectional (which would break on the legitimate type-scale superset). - Fix the ds-var-contract.spec.ts docstring, which claimed the list is derived purely from a grep of consumer usage — false for the five new brand names (zero references outside libs/design-tokens/ today) and a trap that would lead someone to "clean up" and delete the guard. - Document the deliberate --color-signal/accent-light and --color-scope/accent duplication in theme.css, and add a Brand sub-header to the --ds-* block in tokens.css / tokens-dark.css so the generated output explains itself to a website/cockpit author. Co-Authored-By: Claude Opus 5 --- .../scripts/generate-theme-css.ts | 3 ++ .../src/lib/ds-var-contract.spec.ts | 29 ++++++++++++++----- .../src/lib/ds-var-sources-agree.spec.ts | 26 +++++++++++++++++ libs/design-tokens/src/lib/theme.css | 1 + libs/design-tokens/src/lib/tokens-dark.css | 1 + libs/design-tokens/src/lib/tokens.css | 1 + 6 files changed, 53 insertions(+), 8 deletions(-) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index 023648b1b..24d02cc30 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -80,6 +80,7 @@ function buildThemeBlock(): string { lines.push(''); lines.push(' /* Brand */'); lines.push(` --color-accent-light: ${brand.accentLight};`); + lines.push(' /* signal aliases accent-light, scope aliases accent — deliberate: dark.ts derives its accent from accentLight, so both names must exist. Prefer signal/scope in new work. */'); lines.push(` --color-signal: ${brand.signal};`); lines.push(` --color-signal-strong: ${brand.signalStrong};`); lines.push(` --color-scope: ${brand.scope};`); @@ -208,6 +209,8 @@ function buildTokensBlock(theme: ThemeOverrides): string { lines.push(` --ds-text-muted: ${theme.textMuted};`); lines.push(` --ds-text-inverted: ${theme.textInverted};`); lines.push(` --ds-sidebar-bg: ${theme.sidebarBg};`); + + lines.push(' /* Brand (invariant — identical in tokens.css and tokens-dark.css) */'); lines.push(` --ds-signal: ${brand.signal};`); lines.push(` --ds-signal-strong: ${brand.signalStrong};`); lines.push(` --ds-scope: ${brand.scope};`); 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 f22ff7ab0..cc8e4bbe7 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -5,16 +5,29 @@ import { describe, it, expect } from 'vitest'; const TOKENS_CSS = resolve(__dirname, 'tokens.css'); /** - * The `--ds-*` names that cockpit and example apps actually reference today. + * The `--ds-*` names this contract protects. Two categories, both guarded + * the same way: * - * 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. + * 1. Names 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. + * 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. + * + * 2. Names reserved ahead of their consumers: `--ds-signal`, + * `--ds-signal-strong`, `--ds-scope`, `--ds-alert`, `--ds-ink` are the + * aviation-yellow retheme's brand colors. A repo-wide grep finds ZERO + * references to them outside libs/design-tokens/ as of 2026-09-07 — that + * is expected, not a mistake, because cockpit/example adoption has not + * landed yet. Do NOT remove them because the grep above comes up empty; + * they must stay guarded so the names are ready when a consumer needs + * them. */ const CONSUMER_REFERENCED = [ '--ds-accent', 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 index f9d304660..1e06a642a 100644 --- a/libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-sources-agree.spec.ts @@ -43,4 +43,30 @@ describe('--ds-* emitters agree', () => { .map((k) => `${k}: css=${fromCss[k]} fn=${String(fromFn[k])}`); expect(mismatches).toEqual([]); }); + + /** + * Brand colors are the one case where the "tokens.css may be a superset" + * rule above does NOT apply. They are theme-invariant identity markers + * (base.ts's `brand` block) with no type-scale-style reason to exist in + * tokens.css but not cssVars() — unlike the type scale, dropping one from + * cssVars() is never legitimate. This list is scoped to just the five + * aviation-yellow brand names (not the full CONSUMER_REFERENCED list in + * ds-var-contract.spec.ts, which also covers theme-variant and type-scale + * names that are allowed to be tokens.css-only). + * + * A reviewer proved this hole by mutation: deleting these five entries + * from css-vars.ts left the full suite green before this test existed. + */ + const BRAND_NAMES = [ + '--ds-signal', + '--ds-signal-strong', + '--ds-scope', + '--ds-alert', + '--ds-ink', + ] as const; + + it('cssVars(light) emits every brand color name', () => { + const missing = BRAND_NAMES.filter((n) => !(n in fromFn)); + expect(missing).toEqual([]); + }); }); diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index 046c46692..9602ed50a 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -42,6 +42,7 @@ /* Brand */ --color-accent-light: #FFAF00; + /* signal aliases accent-light, scope aliases accent — deliberate: dark.ts derives its accent from accentLight, so both names must exist. Prefer signal/scope in new work. */ --color-signal: #FFAF00; --color-signal-strong: #FFB700; --color-scope: #15253E; diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css index 557ced8b1..e882f1f26 100644 --- a/libs/design-tokens/src/lib/tokens-dark.css +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -32,6 +32,7 @@ --ds-text-muted: rgb(160, 160, 160); --ds-text-inverted: rgb(17, 17, 17); --ds-sidebar-bg: rgba(28, 28, 28, 0.65); + /* Brand (invariant — identical in tokens.css and tokens-dark.css) */ --ds-signal: #FFAF00; --ds-signal-strong: #FFB700; --ds-scope: #15253E; diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css index caaf20e3c..751dd5998 100644 --- a/libs/design-tokens/src/lib/tokens.css +++ b/libs/design-tokens/src/lib/tokens.css @@ -32,6 +32,7 @@ --ds-text-muted: rgb(115, 115, 115); --ds-text-inverted: rgb(255, 255, 255); --ds-sidebar-bg: rgba(255, 255, 255, 0.45); + /* Brand (invariant — identical in tokens.css and tokens-dark.css) */ --ds-signal: #FFAF00; --ds-signal-strong: #FFB700; --ds-scope: #15253E; From 63334e3cffc1a8528c3e1241843966f6ccf3a6a2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:04:00 -0700 Subject: [PATCH 10/24] feat(design-tokens): Archivo display and text faces, Inter retained for diagrams Renames fontSerif (EB Garamond) to fontDisplay (Archivo Black) and repoints fontSans at Archivo instead of Inter. Adds a new fontDiagram token that keeps Inter: EnterpriseArchitecture.tsx pins every rectangle to an 8px grid and home-architecture.spec.ts measures each rendered text run against its card after document.fonts.ready, so retyping diagram text to Archivo would silently overflow the cards and force a geometry rework larger than the retheme itself. Diagram surfaces read as information, not brand, and deliberately stay off the brand face. Regenerates theme.css/tokens.css/tokens-dark.css from the new typography values and updates the token<->CSS parity and --ds-* var contract specs accordingly. apps/website and libs/workspace-react still reference the retired --font-garamond/--font-inter names; that rename is a separate follow-up task and is expected to leave the website visually broken until it lands. Co-Authored-By: Claude Opus 5 --- .../scripts/generate-theme-css.ts | 10 +++-- libs/design-tokens/src/lib/css-vars.spec.ts | 11 +++++- libs/design-tokens/src/lib/css-vars.ts | 3 +- .../src/lib/ds-var-contract.spec.ts | 12 ++++-- libs/design-tokens/src/lib/theme.css | 5 ++- .../src/lib/token-css-parity.spec.ts | 7 ++-- libs/design-tokens/src/lib/tokens-dark.css | 5 ++- libs/design-tokens/src/lib/tokens.css | 5 ++- libs/design-tokens/src/lib/typography.ts | 37 ++++++++++++------- 9 files changed, 63 insertions(+), 32 deletions(-) diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index 24d02cc30..4f7177c74 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -93,8 +93,9 @@ function buildThemeBlock(): string { // Fonts lines.push(''); lines.push(' /* Fonts */'); - lines.push(` --font-garamond: ${typography.fontSerif};`); - lines.push(` --font-inter: ${typography.fontSans};`); + lines.push(` --font-display: ${typography.fontDisplay};`); + lines.push(` --font-sans: ${typography.fontSans};`); + lines.push(` --font-diagram: ${typography.fontDiagram};`); lines.push(` --font-mono: ${typography.fontMono};`); // Type scale — Tailwind v4 composite text tokens. @@ -105,7 +106,7 @@ function buildThemeBlock(): string { // 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 + // `var(--font-display)` 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. @@ -231,8 +232,9 @@ function buildTokensBlock(theme: ThemeOverrides): string { lines.push(''); lines.push(' /* Typography */'); - lines.push(` --ds-font-serif: ${typography.fontSerif};`); + lines.push(` --ds-font-display: ${typography.fontDisplay};`); lines.push(` --ds-font-sans: ${typography.fontSans};`); + lines.push(` --ds-font-diagram: ${typography.fontDiagram};`); lines.push(` --ds-font-mono: ${typography.fontMono};`); lines.push(''); diff --git a/libs/design-tokens/src/lib/css-vars.spec.ts b/libs/design-tokens/src/lib/css-vars.spec.ts index eb427cc43..a7a11642e 100644 --- a/libs/design-tokens/src/lib/css-vars.spec.ts +++ b/libs/design-tokens/src/lib/css-vars.spec.ts @@ -66,7 +66,16 @@ describe('cssVars(theme)', () => { }); it('typography tokens are identical across themes', () => { - expect(cssVars('light')['--ds-font-serif']).toBe(cssVars('dark')['--ds-font-serif']); + expect(cssVars('light')['--ds-font-display']).toBe(cssVars('dark')['--ds-font-display']); expect(cssVars('light')['--ds-font-sans']).toBe(cssVars('dark')['--ds-font-sans']); + expect(cssVars('light')['--ds-font-diagram']).toBe(cssVars('dark')['--ds-font-diagram']); + }); + + it('keeps diagrams off the brand face', () => { + // Diagram geometry is pinned to Inter's metrics by + // apps/website/e2e/home-architecture.spec.ts. Archivo would change every + // glyph width and silently overflow the cards. + expect(cssVars('light')['--ds-font-diagram']).toContain('Inter'); + expect(cssVars('light')['--ds-font-sans']).not.toContain('Inter'); }); }); diff --git a/libs/design-tokens/src/lib/css-vars.ts b/libs/design-tokens/src/lib/css-vars.ts index 7fa46f059..52736e44e 100644 --- a/libs/design-tokens/src/lib/css-vars.ts +++ b/libs/design-tokens/src/lib/css-vars.ts @@ -57,8 +57,9 @@ export function cssVars(theme: Theme) { '--ds-chat-purple': brand.chatPurple, // Typography (invariant) - '--ds-font-serif': typography.fontSerif, + '--ds-font-display': typography.fontDisplay, '--ds-font-sans': typography.fontSans, + '--ds-font-diagram': typography.fontDiagram, '--ds-font-mono': typography.fontMono, // Shadows (invariant) 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 cc8e4bbe7..9d772593d 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -22,9 +22,12 @@ const TOKENS_CSS = resolve(__dirname, 'tokens.css'); * * 2. Names reserved ahead of their consumers: `--ds-signal`, * `--ds-signal-strong`, `--ds-scope`, `--ds-alert`, `--ds-ink` are the - * aviation-yellow retheme's brand colors. A repo-wide grep finds ZERO - * references to them outside libs/design-tokens/ as of 2026-09-07 — that - * is expected, not a mistake, because cockpit/example adoption has not + * aviation-yellow retheme's brand colors, and `--ds-font-display` / + * `--ds-font-diagram` are the retheme's font faces (replacing the + * consumer-referenced `--ds-font-serif`, which the retheme retires — see + * workspace.css in Task 5). A repo-wide grep finds ZERO references to any + * of these outside libs/design-tokens/ as of 2026-09-07 — that is + * expected, not a mistake, because cockpit/example adoption has not * landed yet. Do NOT remove them because the grep above comes up empty; * they must stay guarded so the names are ready when a consumer needs * them. @@ -39,9 +42,10 @@ const CONSUMER_REFERENCED = [ '--ds-border', '--ds-border-strong', '--ds-canvas', + '--ds-font-diagram', + '--ds-font-display', '--ds-font-mono', '--ds-font-sans', - '--ds-font-serif', '--ds-ink', '--ds-radius-lg', '--ds-radius-md', diff --git a/libs/design-tokens/src/lib/theme.css b/libs/design-tokens/src/lib/theme.css index 9602ed50a..d38191fe9 100644 --- a/libs/design-tokens/src/lib/theme.css +++ b/libs/design-tokens/src/lib/theme.css @@ -53,8 +53,9 @@ --color-chat-purple: #5a00c8; /* Fonts */ - --font-garamond: "EB Garamond", Georgia, serif; - --font-inter: Inter, system-ui, sans-serif; + --font-display: "Archivo Black", system-ui, sans-serif; + --font-sans: Archivo, system-ui, sans-serif; + --font-diagram: Inter, system-ui, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, monospace; /* Type scale */ 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 52ded4d77..19bfd5641 100644 --- a/libs/design-tokens/src/lib/token-css-parity.spec.ts +++ b/libs/design-tokens/src/lib/token-css-parity.spec.ts @@ -28,8 +28,9 @@ const CSS_VAR_BY_PATH: Record = { 'brand.chatPurple': '--color-chat-purple', // Font families - 'typography.fontSerif': '--font-garamond', - 'typography.fontSans': '--font-inter', + 'typography.fontDisplay': '--font-display', + 'typography.fontSans': '--font-sans', + 'typography.fontDiagram': '--font-diagram', 'typography.fontMono': '--font-mono', // Type scale — size @@ -105,7 +106,7 @@ const CSS_VAR_BY_PATH: Record = { 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', + why: 'value is already `var(--font-display)` — a var about a var buys nothing', }, { prefix: 'typography.h2.family', why: 'see h1.family' }, { prefix: 'typography.h3.family', why: 'see h1.family' }, diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css index e882f1f26..633e3405c 100644 --- a/libs/design-tokens/src/lib/tokens-dark.css +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -51,8 +51,9 @@ --ds-border-strong: rgb(60, 60, 60); /* Typography */ - --ds-font-serif: "EB Garamond", Georgia, serif; - --ds-font-sans: Inter, system-ui, sans-serif; + --ds-font-display: "Archivo Black", system-ui, sans-serif; + --ds-font-sans: Archivo, system-ui, sans-serif; + --ds-font-diagram: Inter, system-ui, sans-serif; --ds-font-mono: "JetBrains Mono", ui-monospace, monospace; /* Typography — type scale */ diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css index 751dd5998..8be025b79 100644 --- a/libs/design-tokens/src/lib/tokens.css +++ b/libs/design-tokens/src/lib/tokens.css @@ -51,8 +51,9 @@ --ds-border-strong: rgb(200, 200, 200); /* Typography */ - --ds-font-serif: "EB Garamond", Georgia, serif; - --ds-font-sans: Inter, system-ui, sans-serif; + --ds-font-display: "Archivo Black", system-ui, sans-serif; + --ds-font-sans: Archivo, system-ui, sans-serif; + --ds-font-diagram: Inter, system-ui, sans-serif; --ds-font-mono: "JetBrains Mono", ui-monospace, monospace; /* Typography — type scale */ diff --git a/libs/design-tokens/src/lib/typography.ts b/libs/design-tokens/src/lib/typography.ts index a015b4a2c..9f71580e2 100644 --- a/libs/design-tokens/src/lib/typography.ts +++ b/libs/design-tokens/src/lib/typography.ts @@ -1,9 +1,10 @@ /** * Typography tokens — font families and type scale used across the design system. * - * - Serif (EB Garamond): Headlines, elegant emphasis - * - Sans (Inter): Body text, UI elements - * - Mono (JetBrains Mono): Code, labels, metadata + * - Display (Archivo Black): headlines and the wordmark. Single weight. + * - Sans (Archivo): body text, UI elements + * - Diagram (Inter): diagram text ONLY — see fontDiagram + * - Mono (JetBrains Mono): code, labels, metadata * * The h1/h2/h3/eyebrow/bodyLg/body/caption objects are the type scale * used by the marketing-site UI primitives. Each entry includes @@ -11,10 +12,20 @@ * `letterSpacing`, `transform`. */ export const typography = { - /** Serif font for headings */ - fontSerif: '"EB Garamond", Georgia, serif', - /** Sans-serif font for body text */ - fontSans: 'Inter, system-ui, sans-serif', + /** Display face for headlines. Archivo Black ships one weight (400). */ + fontDisplay: '"Archivo Black", system-ui, sans-serif', + /** Text and UI face. */ + fontSans: 'Archivo, system-ui, sans-serif', + /** + * Diagram text, deliberately NOT the brand face. + * + * Diagrams are information, not brand surface, and their geometry is tuned + * to Inter's metrics: EnterpriseArchitecture.tsx pins every rectangle to an + * 8px grid and home-architecture.spec.ts measures each rendered text run + * against its card. Retyping diagrams would force a geometry rework larger + * than the retheme itself. + */ + fontDiagram: 'Inter, system-ui, sans-serif', /** Monospace font for code and labels. ui-monospace before the generic * keyword: consumers that don't load JetBrains Mono (the cockpit example * apps) get the platform mono (SF Mono) instead of Courier. */ @@ -23,17 +34,17 @@ export const typography = { h1: { size: 'clamp(48px, 6vw, 72px)', line: 1.08, - family: 'var(--font-garamond)', + family: 'var(--font-display)', }, h2: { size: 'clamp(36px, 4.5vw, 56px)', line: 1.12, - family: 'var(--font-garamond)', + family: 'var(--font-display)', }, h3: { size: '28px', line: 1.25, - family: 'var(--font-inter)', + family: 'var(--font-sans)', weight: 600, }, eyebrow: { @@ -47,17 +58,17 @@ export const typography = { bodyLg: { size: '20px', line: 1.6, - family: 'var(--font-inter)', + family: 'var(--font-sans)', }, body: { size: '16px', line: 1.6, - family: 'var(--font-inter)', + family: 'var(--font-sans)', }, caption: { size: '14px', line: 1.5, - family: 'var(--font-inter)', + family: 'var(--font-sans)', }, } as const; From 26b5e3cd378d161db0001df7e1b2b9829a6ea59d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:09:19 -0700 Subject: [PATCH 11/24] docs: record the next/font constraint that makes --font-diagram fragile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit theme.css emits --font-diagram as a raw 'Inter, ...' stack, which next/font's hashed family never matches. If layout.tsx stops supplying the var, diagrams silently fall back to system-ui and every glyph width shifts — defeating the carve-out the token exists for. Only the architecture e2e would notice. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-07-atc-theme-spike.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md index 731375087..2a9aa4a6b 100644 --- a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -538,6 +538,31 @@ In `apps/website/src/styles/docs.css`, set both diagram-kit sans rules: Leave every diagram rule already on `var(--font-mono)` alone — mono was never Inter. +- [ ] **Step 3b: Record the next/font constraint in `ui.css`** + +`ui.css`'s FONTS note explains that website font vars are supplied by +`next/font` on ``, that those ``-level values WIN over `theme.css`, +and that raw literals like `Inter, system-ui` never match because next/font +registers its family under a hashed name. + +That makes `--font-diagram` fragile in a way nothing else records: the value +`theme.css` emits for it IS a raw `Inter, system-ui, sans-serif` stack, so if +`layout.tsx` ever stops defining `--font-diagram` via `next/font`, the +diagrams silently fall back to `system-ui`, every glyph width changes, and the +geometry this token exists to protect breaks. Between this task and Task 6 +that is exactly the state the site is in. + +Append to the FONTS comment block in `apps/website/src/styles/ui.css`: + +``` + * --font-diagram is diagram-only and MUST keep being supplied by next/font in + * layout.tsx. theme.css emits it as a raw `Inter, ...` stack, which next/font's + * hashed family never matches — so if the loader is removed, diagrams silently + * fall back to system-ui, every glyph width shifts, and + * e2e/home-architecture.spec.ts (which measures text runs against their cards) + * is the only thing that will notice. +``` + - [ ] **Step 4: Verify the carve-out is exactly three declarations** ```bash From c13d42d1335d756a69eff9bf8221574e032892a2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:12:27 -0700 Subject: [PATCH 12/24] refactor(website): --font-display/--font-sans, with diagrams carved out onto --font-diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design-tokens generator no longer emits --font-garamond or --font-inter, so every consumer reference in apps/website and libs/workspace-react moves to the new names. layout.tsx's next/font `variable` fields move with them; the faces themselves are swapped in a later task. The diagram carve-out lands here rather than as a follow-up because --font-inter was a single variable doing two jobs: ~100 body-text rules and the diagram text. Renaming it alone would retype the architecture figure and the docs diagram kit from Inter to Archivo, changing every glyph width. EnterpriseArchitecture.tsx pins its rectangles to an 8px grid and e2e/home-architecture.spec.ts measures each rendered text run against its card, so that would land as a red e2e — possibly only at the 390px viewport. Splitting the two commits would mean deliberately committing a broken geometry, so the three diagram declarations move to --font-diagram (Inter, retained for exactly this reason) in the same change: .arch-figure text (landing.css) .tp-diagram-node[data-title="sans"] .tp-diagram-title (docs.css) .tp-diagram-meta (docs.css) Diagram rules already on --font-mono are untouched; mono was never Inter. ui.css's FONTS note gains the constraint that makes --font-diagram fragile: theme.css emits it as a raw `Inter, ...` stack, which next/font's hashed family never matches, so the var has to keep being supplied by next/font in layout.tsx or diagrams fall back to system-ui silently. nx test website and nx test workspace-react pass, but these are jsdom suites that do not resolve CSS custom properties — they cannot detect a wrong font var. The architecture e2e is the real check. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/global.css | 2 +- apps/website/src/app/layout.tsx | 4 +- .../components/docs/DocsControlPlane.spec.tsx | 2 +- apps/website/src/styles/chrome.css | 16 +-- apps/website/src/styles/docs.css | 66 +++++----- apps/website/src/styles/forms.css | 12 +- apps/website/src/styles/landing.css | 100 +++++++-------- apps/website/src/styles/marketing.css | 42 +++--- apps/website/src/styles/pages.css | 120 +++++++++--------- apps/website/src/styles/ui.css | 25 ++-- .../lib/components/api-mode/api-mode.spec.tsx | 2 +- .../cockpit-control-plane.spec.tsx | 2 +- libs/workspace-react/src/styles/workspace.css | 8 +- 13 files changed, 204 insertions(+), 197 deletions(-) diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 316c2f6c6..aad2c265b 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -34,7 +34,7 @@ html { body { background-color: var(--color-bg); color: var(--color-text-primary); - font-family: var(--font-inter); + font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; /* overflow-x: CLIP, never hidden. `hidden` makes a scroll container diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index 08dfe1992..a24c2ace6 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -24,12 +24,12 @@ const garamond = EB_Garamond({ subsets: ['latin'], weight: ['400', '700', '800'], style: ['normal', 'italic'], - variable: '--font-garamond', + variable: '--font-display', }); const inter = Inter({ subsets: ['latin'], - variable: '--font-inter', + variable: '--font-sans', }); const mono = JetBrains_Mono({ diff --git a/apps/website/src/components/docs/DocsControlPlane.spec.tsx b/apps/website/src/components/docs/DocsControlPlane.spec.tsx index 7de3d6298..149f7a06b 100644 --- a/apps/website/src/components/docs/DocsControlPlane.spec.tsx +++ b/apps/website/src/components/docs/DocsControlPlane.spec.tsx @@ -50,7 +50,7 @@ describe('DocsControlPlane', () => { '[data-docs-control-plane-context] [data-control-plane-section-heading]', ]) { const declarations = declarationsFor(docsCss, selector); - expect(declarations).toMatch(/font-family:\s*var\(--font-inter\)/); + expect(declarations).toMatch(/font-family:\s*var\(--font-sans\)/); expect(declarations).toMatch(/font-size:\s*12px/); expect(declarations).toMatch(/font-weight:\s*600/); expect(declarations).toMatch(/letter-spacing:\s*normal/); diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index 964fe8a64..65634c333 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -205,7 +205,7 @@ padding: 10px 0; font-size: 15px; font-weight: 500; - font-family: var(--font-inter); + font-family: var(--font-sans); background: transparent; color: var(--color-text-muted); border: none; @@ -240,7 +240,7 @@ line-height: 24px; min-height: 44px; text-decoration: none; - font-family: var(--font-inter); + font-family: var(--font-sans); color: var(--color-text-secondary); background: transparent; } @@ -286,7 +286,7 @@ color: var(--color-accent); background: var(--color-accent-surface); text-decoration: none; - font-family: var(--font-inter); + font-family: var(--font-sans); font-weight: 600; } .nav-mobile-section-toggle { @@ -301,7 +301,7 @@ padding: 12px 14px; min-height: 48px; border-radius: 8px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; line-height: 24px; color: var(--color-text-primary); @@ -323,7 +323,7 @@ min-height: 48px; color: var(--color-text-secondary); text-decoration: none; - font-family: var(--font-inter); + font-family: var(--font-sans); } .nav-mobile-github-link { display: flex; @@ -334,7 +334,7 @@ min-height: 48px; color: var(--color-text-secondary); text-decoration: none; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; } .nav-mobile-cta-wrap { @@ -421,7 +421,7 @@ margin-bottom: 8px; } .toast-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 1.05rem; font-weight: 700; color: var(--color-text-primary); @@ -461,7 +461,7 @@ font-size: 12px; color: var(--color-text-muted); text-decoration: underline; - font-family: var(--font-inter); + font-family: var(--font-sans); } /* Mobile docs-search entry (polish arc PR 3) — button reset to match the diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index 728ef3ab5..6052b9651 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -163,9 +163,9 @@ body:has([data-website-workspace-host]) { .docs-prose { overflow-wrap: break-word; word-break: break-word; max-width: 100%; } .docs-prose > * { max-width: 100%; } -.docs-prose h1 { font-size: 1.875rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; font-family: var(--font-garamond); } -.docs-prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 1rem; font-family: var(--font-garamond); } -.docs-prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; font-family: var(--font-garamond); } +.docs-prose h1 { font-size: 1.875rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; font-family: var(--font-display); } +.docs-prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 1rem; font-family: var(--font-display); } +.docs-prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; font-family: var(--font-display); } .docs-prose p { line-height: 1.75; margin-bottom: 1.25rem; } .docs-prose ul, .docs-prose ol { margin-bottom: 1.25rem; @@ -366,14 +366,14 @@ body:has([data-website-workspace-host]) { flex-shrink: 0; } .mdx-callout-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 600; color: var(--callout-tone-text); } .mdx-callout-body { padding: 12px 14px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.6; color: var(--color-text-secondary); @@ -406,7 +406,7 @@ body:has([data-website-workspace-host]) { padding: 7px 14px; border-radius: var(--radius-md); border: 1px solid var(--callout-tone-text, var(--color-accent)); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 600; line-height: 1.2; @@ -521,14 +521,14 @@ body:has([data-website-workspace-host]) { padding-bottom: 8px; } .mdx-step-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 17px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; } .mdx-step-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; line-height: 1.6; color: var(--color-text-secondary); @@ -570,7 +570,7 @@ body:has([data-website-workspace-host]) { margin-bottom: 6px; } .mdx-card-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); @@ -582,7 +582,7 @@ body:has([data-website-workspace-host]) { margin-top: 2px; } .mdx-card-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 0.85rem; line-height: 1.5; color: var(--color-text-secondary); @@ -759,7 +759,7 @@ body:has([data-website-workspace-host]) { margin-bottom: 8px; } .mdx-chip-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 600; color: var(--color-text-primary); @@ -830,7 +830,7 @@ body:has([data-website-workspace-host]) { .tp-diagram-node[data-meta="mono"] .tp-diagram-meta { font-family: var(--font-mono); } .tp-diagram-dot { fill: var(--color-border); } .tp-diagram-caption { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-muted); text-align: center; @@ -863,13 +863,13 @@ body:has([data-website-workspace-host]) { fill: var(--color-text-primary); } .tp-diagram-node[data-title="sans"] .tp-diagram-title { - font-family: var(--font-inter); + font-family: var(--font-diagram); font-size: 11px; font-weight: 600; fill: var(--color-text-secondary); } .tp-diagram-meta { - font-family: var(--font-inter); + font-family: var(--font-diagram); font-size: 10.5px; fill: var(--color-text-muted); } @@ -1047,7 +1047,7 @@ body:has([data-website-workspace-host]) { min-width: 0; } .docs-control-plane-search-label { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 0.8rem; } .docs-control-plane-search-kbd { @@ -1105,7 +1105,7 @@ body:has([data-website-workspace-host]) { width: 100%; border: none; outline: none; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 0.95rem; color: var(--color-text-primary); background: transparent; @@ -1151,7 +1151,7 @@ body:has([data-website-workspace-host]) { /* Content hits: a section heading, its page, and why it matched. */ .docs-search-group-label { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; @@ -1162,7 +1162,7 @@ body:has([data-website-workspace-host]) { .docs-search-result-snippet { flex-basis: 100%; width: 100%; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; line-height: 1.5; color: var(--color-text-secondary); @@ -1233,7 +1233,7 @@ body:has([data-website-workspace-host]) { * links inside each li, and when only the links carried 13px the first two * separators inherited body's 16px/24px and floated 3px high. */ .website-workspace-host [data-workspace-trail] ol[data-workspace-trail-list] { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; line-height: 1.5; color: var(--color-text-muted); @@ -1288,7 +1288,7 @@ body:has([data-website-workspace-host]) { width: 100%; text-align: left; padding: 8px 12px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-primary); background: transparent; @@ -1730,7 +1730,7 @@ body:has([data-website-workspace-host]) { margin-bottom: 8px; } .docs-prevnext-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; font-weight: 600; color: var(--color-accent); @@ -1860,7 +1860,7 @@ body:has([data-website-workspace-host]) { background: var(--color-accent-surface); } .docs-control-plane [data-control-plane-rail-label] { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 10px; line-height: 1; font-weight: 600; @@ -1902,7 +1902,7 @@ body:has([data-website-workspace-host]) { color: var(--color-text-muted); background: transparent; cursor: pointer; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 600; letter-spacing: normal; @@ -1925,7 +1925,7 @@ body:has([data-website-workspace-host]) { margin: 0; padding: 8px; color: var(--color-text-muted); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; line-height: 1.2; font-weight: 600; @@ -1946,7 +1946,7 @@ body:has([data-website-workspace-host]) { justify-content: space-between; } .docs-sidebar-lib-trigger-label { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; } /* Library-neutral pages: the picker is an invitation, not a statement. */ @@ -1972,7 +1972,7 @@ body:has([data-website-workspace-host]) { display: block; padding: 7px 9px 4px; color: var(--color-text-muted); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 10.5px; font-weight: 700; letter-spacing: 0.07em; @@ -1994,7 +1994,7 @@ body:has([data-website-workspace-host]) { text-align: left; text-decoration: none; } -.docs-sidebar-lib-item-title { font-family: var(--font-inter); } +.docs-sidebar-lib-item-title { font-family: var(--font-sans); } .docs-sidebar-top-links, .docs-sidebar-section-links { display: flex; flex-direction: column; } .docs-sidebar-top-links { margin: 0 8px 8px; } @@ -2003,7 +2003,7 @@ body:has([data-website-workspace-host]) { margin: 1px 0; padding: 7px 9px; border-radius: 7px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; line-height: 1.35; text-decoration: none; @@ -2019,7 +2019,7 @@ body:has([data-website-workspace-host]) { align-items: center; justify-content: space-between; color: var(--color-text-muted); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 600; } @@ -2067,7 +2067,7 @@ body:has([data-website-workspace-host]) { color: var(--color-surface); background: var(--color-text-primary); box-shadow: var(--shadow-sm); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 11px; font-weight: 500; line-height: 1; @@ -2113,7 +2113,7 @@ body:has([data-website-workspace-host]) { /* Docs table of contents: complete rounded active/hover surfaces. */ .docs-toc:focus { outline: none; } .docs-toc-label { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 600; } @@ -2162,7 +2162,7 @@ body:has([data-website-workspace-host]) { color: var(--color-surface); background: var(--color-text-primary); box-shadow: var(--shadow-sm); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 11px; font-weight: 500; line-height: 1; @@ -2220,7 +2220,7 @@ body:has([data-website-workspace-host]) { display: flex; align-items: center; color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; text-decoration: none; } diff --git a/apps/website/src/styles/forms.css b/apps/website/src/styles/forms.css index de81ec21b..96f5ec4e2 100644 --- a/apps/website/src/styles/forms.css +++ b/apps/website/src/styles/forms.css @@ -22,7 +22,7 @@ gap: 6px; } [data-ui="field-label"] { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 500; color: var(--color-text-primary); @@ -33,7 +33,7 @@ } [data-ui="field-help"] { margin: 0; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; color: var(--color-text-muted); } @@ -42,7 +42,7 @@ align-items: center; gap: 6px; margin: 0; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; color: var(--color-status-error); } @@ -66,7 +66,7 @@ box-sizing: border-box; height: var(--form-control-height); padding: 0 14px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); color: var(--color-text-primary); background: var(--color-surface); @@ -141,7 +141,7 @@ select[data-ui="form-control"] { } [data-ui="form-disclosure"] { margin: 0; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; line-height: 1.45; color: var(--color-text-muted); @@ -180,7 +180,7 @@ select[data-ui="form-control"] { align-items: flex-start; padding: 14px 16px; border-radius: var(--form-control-radius); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-primary); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index c10ab8bea..15f5f63cd 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -14,7 +14,7 @@ margin-bottom: 16px; } .hero-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -28,7 +28,7 @@ display: block; } .hero-subhead { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -98,7 +98,7 @@ } .hero-trust { margin: 12px 0 0; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-muted); } @@ -126,7 +126,7 @@ margin-bottom: 16px; } .feature-block-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -136,7 +136,7 @@ letter-spacing: -0.015em; } .feature-block-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -144,7 +144,7 @@ margin-bottom: 24px; } .feature-block-cta { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; font-weight: 600; color: var(--color-accent); @@ -179,7 +179,7 @@ padding: 9px 0; } .feature-block-row-claim { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.45; color: var(--color-text-primary); @@ -231,7 +231,7 @@ margin-bottom: 16px; } .wp-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -254,7 +254,7 @@ padding: 9px 0; } .wp-row-claim { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.45; color: var(--color-text-primary); @@ -280,7 +280,7 @@ margin-top: 4px; font-size: 13px; color: var(--color-text-muted); - font-family: var(--font-inter); + font-family: var(--font-sans); } .wp-already a { color: var(--color-accent); @@ -316,7 +316,7 @@ margin-bottom: 14px; } .wp-cover-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 28px; line-height: 1.15; font-weight: 700; @@ -325,7 +325,7 @@ font-style: italic; } .wp-cover-desc { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; line-height: 1.5; color: var(--color-text-secondary); @@ -368,7 +368,7 @@ background: var(--color-border); } .pilot-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -378,7 +378,7 @@ letter-spacing: -0.015em; } .pilot-subhead { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -404,7 +404,7 @@ padding: 9px 0; } .pilot-row-claim { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.45; color: var(--color-text-primary); @@ -432,7 +432,7 @@ border-bottom: none; } .pilot-step-num { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 44px; font-weight: 700; line-height: 0.9; @@ -440,13 +440,13 @@ color: var(--color-border); } .pilot-step-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15.5px; font-weight: 600; color: var(--color-text-primary); } .pilot-step-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; line-height: 1.5; color: var(--color-text-secondary); @@ -477,7 +477,7 @@ margin: 6px 0 0; } .teams-report-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 26px; font-weight: 700; line-height: 1.1; @@ -485,7 +485,7 @@ color: var(--color-text-primary); } .teams-report-desc { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; line-height: 1.5; color: var(--color-text-secondary); @@ -522,7 +522,7 @@ margin-bottom: 0; } .recent-articles-heading { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; font-style: italic; font-weight: 400; @@ -553,7 +553,7 @@ text-align: center; } .final-cta-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -564,7 +564,7 @@ font-style: italic; } .final-cta-subtext { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -579,7 +579,7 @@ margin-bottom: 16px; } .final-cta-caption { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-caption); color: var(--color-text-muted); margin: 0; @@ -593,7 +593,7 @@ position: absolute; right: -30px; bottom: -70px; - font-family: var(--font-garamond); + font-family: var(--font-display); font-style: italic; font-weight: 800; font-size: 210px; @@ -680,7 +680,7 @@ margin-bottom: 16px; } .home-faq-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -713,7 +713,7 @@ gap: 4px; } .backends-grid-name { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--color-text-primary); @@ -767,7 +767,7 @@ gap: 5px; } .demo-modal__tab { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 600; padding: 6px 12px; @@ -807,12 +807,12 @@ background: var(--color-surface); } .demo-modal__hint { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; color: var(--color-text-muted); } .demo-modal__open-link { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 600; color: var(--color-accent); @@ -900,7 +900,7 @@ margin-bottom: 14px; } .render-show-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); font-weight: 800; line-height: 1.1; @@ -948,7 +948,7 @@ margin-bottom: 14px; } .lg-show-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); font-weight: 800; line-height: 1.1; @@ -996,7 +996,7 @@ margin-bottom: 14px; } .chat-show-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); font-weight: 800; line-height: 1.1; @@ -1042,7 +1042,7 @@ position: absolute; right: -40px; top: -78px; - font-family: var(--font-garamond); + font-family: var(--font-display); font-style: italic; font-weight: 800; font-size: clamp(150px, 21vw, 300px); @@ -1148,7 +1148,7 @@ ); } .proof-strip-value { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 58px; font-weight: 700; line-height: 0.9; @@ -1168,7 +1168,7 @@ display: block; } .proof-strip-caption { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; line-height: 1.5; color: var(--color-text-secondary); @@ -1212,7 +1212,7 @@ min-width: 0; } .reliability-receipt-claim { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; font-weight: 600; line-height: 1.4; @@ -1220,7 +1220,7 @@ margin: 0; } .reliability-receipt-detail { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; line-height: 1.5; color: var(--color-text-secondary); @@ -1268,7 +1268,7 @@ object-fit: contain; } .reliability-works-with-name { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 500; color: var(--color-text-secondary); @@ -1513,7 +1513,7 @@ color: #fff; } .stage-claim { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 30px; line-height: 1.08; font-weight: 500; @@ -1685,7 +1685,7 @@ gap: 12px; } .stack-diagram-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1713,7 +1713,7 @@ min-width: 1024px; } .arch-figure text { - font-family: var(--font-inter); + font-family: var(--font-diagram); font-size: 13.5px; fill: var(--color-text-primary); } @@ -1819,7 +1819,7 @@ padding: 0; display: grid; gap: 6px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-secondary); } @@ -1881,7 +1881,7 @@ } .arch-stack-label { text-align: left; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 11px; letter-spacing: 0.14em; font-weight: 700; @@ -1913,7 +1913,7 @@ height: 20px; } .arch-stack-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; font-weight: 600; margin: 0; @@ -1929,7 +1929,7 @@ margin: 8px 0 0; padding: 0; list-style: none; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-secondary); display: grid; @@ -1942,7 +1942,7 @@ margin: 10px 0 0; padding: 0; list-style: none; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 500; } @@ -2002,14 +2002,14 @@ max-width: 420px; } .home-concept-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 1rem; font-weight: 600; color: var(--color-text-primary); margin: 0; } .home-concept-sentence { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 0.9rem; line-height: 1.55; color: var(--color-text-secondary); @@ -2139,7 +2139,7 @@ live control looking double-exposed. */ background: rgb(248 248 248); color: #111; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; font-weight: 600; text-decoration: none; diff --git a/apps/website/src/styles/marketing.css b/apps/website/src/styles/marketing.css index 5ba0cfaa2..9b7961955 100644 --- a/apps/website/src/styles/marketing.css +++ b/apps/website/src/styles/marketing.css @@ -45,7 +45,7 @@ transform: translateY(-50%); background: var(--color-accent); color: #fff; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; @@ -70,7 +70,7 @@ color: var(--color-accent); } .pricing-plan-journey { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; font-weight: 700; color: var(--color-text-muted); @@ -78,7 +78,7 @@ } .pricing-plan-name { margin: 0 0 18px; - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 34px; line-height: 1; color: var(--color-text-primary); @@ -93,7 +93,7 @@ flex-wrap: wrap; } .pricing-plan-price-amount { - font-family: var(--font-garamond); + font-family: var(--font-display); font-weight: 700; font-size: 30px; color: var(--color-text-primary); @@ -116,7 +116,7 @@ margin: 6px 0 20px; min-height: 88px; color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; line-height: 1.55; } @@ -133,7 +133,7 @@ display: flex; align-items: flex-start; gap: 9px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; line-height: 1.45; color: var(--color-text-secondary); @@ -173,7 +173,7 @@ .pricing-section-heading { margin: 0 0 14px; color: var(--color-text-primary); - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: clamp(30px, 4vw, 44px); line-height: 1.08; letter-spacing: -0.02em; @@ -181,7 +181,7 @@ .pricing-section-body { margin: 0; color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; line-height: 1.65; } @@ -206,7 +206,7 @@ justify-content: center; gap: 8px; color: var(--color-text-primary); - font-family: var(--font-inter); + font-family: var(--font-sans); line-height: 1.4; } .pricing-boundary-block[data-accent] { @@ -231,7 +231,7 @@ border-left: 3px solid var(--color-accent); background: var(--color-surface); color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; line-height: 1.6; } @@ -247,7 +247,7 @@ border-collapse: separate; border-spacing: 0; color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; line-height: 1.45; } @@ -340,14 +340,14 @@ margin-bottom: 4px; } .enterprise-band-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); color: var(--color-text-primary); margin: 0; } .enterprise-band-lede { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -362,7 +362,7 @@ margin-bottom: 16px; } .pricing-faq-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -397,7 +397,7 @@ color: var(--color-text-muted); } .post-card-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-h3); line-height: var(--text-h3--line-height); font-weight: var(--text-h3--font-weight); @@ -439,7 +439,7 @@ margin-bottom: 32px; } .featured-post-card-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -447,7 +447,7 @@ margin: 0; } .featured-post-card-description { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -545,7 +545,7 @@ margin: 0 auto; } .sol-demo-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -555,7 +555,7 @@ letter-spacing: -0.015em; } .sol-demo-caption { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -563,7 +563,7 @@ max-width: 62ch; } .sol-demo-note { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); color: var(--color-text-muted); margin: 14px 0 0; @@ -578,7 +578,7 @@ margin: 0 auto; } .sol-code-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; diff --git a/apps/website/src/styles/pages.css b/apps/website/src/styles/pages.css index 72d4f36af..c945ac00a 100644 --- a/apps/website/src/styles/pages.css +++ b/apps/website/src/styles/pages.css @@ -30,7 +30,7 @@ margin-bottom: 16px; } .docs-index-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -40,7 +40,7 @@ letter-spacing: -0.02em; } .docs-index-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -133,7 +133,7 @@ flex: 0 0 auto; } .docs-index-card-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 18px; line-height: 1.3; font-weight: 600; @@ -154,7 +154,7 @@ margin-top: 2px; } .docs-index-card-blurb { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-secondary); @@ -166,7 +166,7 @@ } .docs-index-cta { /* raw font stack, not the `.family` var — see the font-exception note atop ui.css */ - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--color-accent); @@ -196,7 +196,7 @@ white-space: nowrap; } .docs-index-helper { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; color: var(--color-text-secondary); margin: 0; @@ -219,7 +219,7 @@ margin: 0 auto; } .docs-index-search-heading { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 22px; line-height: 1.3; font-weight: 600; @@ -228,7 +228,7 @@ margin-bottom: 12px; } .docs-index-search-button { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-secondary); @@ -289,7 +289,7 @@ margin-bottom: 16px; } .about-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -299,7 +299,7 @@ letter-spacing: -0.02em; } .about-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -311,7 +311,7 @@ margin-bottom: 0; } .about-h2 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); color: var(--color-text-primary); margin: 0; @@ -337,7 +337,7 @@ margin-bottom: 16px; } .sol-index-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -347,7 +347,7 @@ letter-spacing: -0.02em; } .sol-index-hero-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -371,7 +371,7 @@ margin-bottom: 12px; } .sol-index-h2 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -395,7 +395,7 @@ flex-direction: column; } .sol-index-card-title { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 22px; line-height: 1.25; font-weight: 600; @@ -405,7 +405,7 @@ white-space: pre-line; } .sol-index-card-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-secondary); @@ -420,7 +420,7 @@ margin-bottom: 16px; } .sol-index-cta { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--color-accent); @@ -445,7 +445,7 @@ background: var(--color-border); } .sol-page-h2 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -457,7 +457,7 @@ margin-bottom: 16px; } .sol-page-section-intro { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -478,7 +478,7 @@ margin: 0 auto; } .sol-page-card-h3 { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 18px; line-height: 1.3; font-weight: 600; @@ -487,7 +487,7 @@ margin-bottom: 10px; } .sol-page-card-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-secondary); @@ -503,14 +503,14 @@ margin-bottom: 12px; } .sol-page-arch-title { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--color-text-primary); margin: 0; } .sol-page-role { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-secondary); @@ -520,7 +520,7 @@ margin-bottom: 16px; } .sol-page-arch-cta { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--color-accent); @@ -529,7 +529,7 @@ text-decoration: none; } .sol-page-metric { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: 40px; font-weight: 700; line-height: 0.9; @@ -547,7 +547,7 @@ margin-bottom: 16px; } .sol-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -558,7 +558,7 @@ white-space: pre-line; } .sol-page-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -582,7 +582,7 @@ margin-bottom: 16px; } .pilot-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -592,7 +592,7 @@ letter-spacing: -0.02em; } .pilot-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -691,7 +691,7 @@ background: var(--color-border); } .pilot-h2 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -716,7 +716,7 @@ padding: 10px 0; } .pilot-outcome-claim { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.5; color: var(--color-text-primary); @@ -741,7 +741,7 @@ text-align: center; } .pilot-contact-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -755,7 +755,7 @@ text-align: center; } .render-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -765,7 +765,7 @@ letter-spacing: -0.02em; } .render-page-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -792,7 +792,7 @@ text-align: center; } .chat-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -802,7 +802,7 @@ letter-spacing: -0.02em; } .chat-page-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -829,7 +829,7 @@ text-align: center; } .ag-ui-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -839,7 +839,7 @@ letter-spacing: -0.02em; } .ag-ui-page-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -869,7 +869,7 @@ margin-bottom: 24px; } .ag-ui-page-langgraph-note { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-muted); margin: 0 auto; @@ -887,7 +887,7 @@ text-align: center; } .langgraph-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -897,7 +897,7 @@ letter-spacing: -0.02em; } .langgraph-page-hero-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -943,7 +943,7 @@ margin-bottom: 16px; } .blog-index-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -952,7 +952,7 @@ margin: 0 0 16px; } .blog-index-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -967,7 +967,7 @@ border-radius: 12px; } .blog-index-empty-text { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1011,7 +1011,7 @@ margin-bottom: 24px; } .blog-post-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -1020,7 +1020,7 @@ margin: 0 0 24px; } .blog-post-subtitle { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1045,7 +1045,7 @@ margin-bottom: 16px; } .error-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -1055,7 +1055,7 @@ letter-spacing: -0.02em; } .error-page-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1088,7 +1088,7 @@ margin-bottom: 16px; } .pricing-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-weight: 700; font-size: var(--text-h1); line-height: var(--text-h1--line-height); @@ -1100,7 +1100,7 @@ max-width: 760px; margin: 20px auto 0; color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: 1.65; } @@ -1116,7 +1116,7 @@ border-radius: var(--radius-lg); background: var(--color-surface-tinted); color: var(--color-text-secondary); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; line-height: 1.45; } @@ -1158,7 +1158,7 @@ /* Contact page — app/contact/page.tsx */ .contact-page-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -1177,7 +1177,7 @@ margin-bottom: 16px; } .contact-band-lede { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1185,7 +1185,7 @@ max-width: 48ch; } .contact-band-note { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body); line-height: var(--text-body--line-height); color: var(--color-text-primary); @@ -1210,7 +1210,7 @@ padding: 0 12px; border: 1px solid var(--color-border); border-radius: var(--radius-full); - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; color: var(--color-text-primary); text-decoration: none; @@ -1237,7 +1237,7 @@ margin-bottom: 16px; } .nf-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -1247,7 +1247,7 @@ letter-spacing: -0.02em; } .nf-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); @@ -1269,7 +1269,7 @@ margin-bottom: 16px; } .privacy-h1 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); font-weight: 700; @@ -1279,7 +1279,7 @@ letter-spacing: -0.02em; } .privacy-h2 { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); color: var(--color-text-primary); margin: 0; @@ -1287,7 +1287,7 @@ margin-bottom: 12px; } .privacy-body { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 80a71cac0..f411c14c0 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -96,8 +96,8 @@ } /* - * FONTS: every font-family in apps/website CSS is var(--font-inter), - * var(--font-mono), or var(--font-garamond) — never a raw stack. + * FONTS: every font-family in apps/website CSS is var(--font-sans), + * var(--font-mono), or var(--font-display) — never a raw stack. * * layout.tsx loads the fonts via next/font, which defines those three * vars on with self-hosted, size-adjusted stacks ("Inter", @@ -109,6 +109,13 @@ * more mixed stacks. theme.css also defines the same var names from * the design tokens; next/font's -level values win, which is now * the intended resolution, and they guarantee the vars always resolve. + * + * --font-diagram is diagram-only and MUST keep being supplied by next/font in + * layout.tsx. theme.css emits it as a raw `Inter, ...` stack, which next/font's + * hashed family never matches — so if the loader is removed, diagrams silently + * fall back to system-ui, every glyph width shifts, and + * e2e/home-architecture.spec.ts (which measures text runs against their cards) + * is the only thing that will notice. */ /* UI primitive — Pill */ [data-ui="pill"] { @@ -208,7 +215,7 @@ align-items: center; justify-content: center; gap: 8px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-weight: 600; line-height: 1; border-radius: var(--radius-md); @@ -287,7 +294,7 @@ gap: 16px; padding: 20px 0; cursor: pointer; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 17px; font-weight: 600; color: var(--color-text-primary); @@ -300,7 +307,7 @@ } [data-ui="faq-item"] > div { padding-bottom: 20px; - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 16px; line-height: 1.6; color: var(--color-text-secondary); @@ -311,7 +318,7 @@ display: inline-flex; align-items: center; gap: 8px; - font-family: var(--font-garamond); + font-family: var(--font-display); font-weight: 700; color: var(--color-text-primary); line-height: 1; @@ -449,7 +456,7 @@ margin-bottom: 12px; } [data-ui="tab-group-tab"] { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 13px; font-weight: 600; padding: 8px 14px; @@ -469,7 +476,7 @@ margin-bottom: 16px; } [data-ui="section-header"] .section-header-heading { - font-family: var(--font-garamond); + font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); font-weight: 700; @@ -478,7 +485,7 @@ margin: 0; } [data-ui="section-header"] .section-header-aside { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 15px; line-height: 1.6; font-style: italic; diff --git a/libs/workspace-react/src/lib/components/api-mode/api-mode.spec.tsx b/libs/workspace-react/src/lib/components/api-mode/api-mode.spec.tsx index 4c896e4b4..a77d1a160 100644 --- a/libs/workspace-react/src/lib/components/api-mode/api-mode.spec.tsx +++ b/libs/workspace-react/src/lib/components/api-mode/api-mode.spec.tsx @@ -118,7 +118,7 @@ describe('ApiMode', () => { 'utf8' ); expect(css).toMatch( - /\.workspace-prose \.workspace-api-heading\s*\{[\s\S]*font-family:\s*var\(--font-inter\),\s*var\(--ds-font-sans\)[\s\S]*letter-spacing:\s*normal/ + /\.workspace-prose \.workspace-api-heading\s*\{[\s\S]*font-family:\s*var\(--font-sans\),\s*var\(--ds-font-sans\)[\s\S]*letter-spacing:\s*normal/ ); }); diff --git a/libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.spec.tsx b/libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.spec.tsx index 96a1e2eca..c442d62a8 100644 --- a/libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.spec.tsx +++ b/libs/workspace-react/src/lib/components/control-plane/cockpit-control-plane.spec.tsx @@ -145,7 +145,7 @@ describe('CockpitControlPlane', () => { ]) { const declarations = declarationsFor(cockpitCss, selector); expect(declarations).toMatch( - /font-family:\s*var\(--font-inter,\s*var\(--ds-font-sans\)\)/ + /font-family:\s*var\(--font-sans,\s*var\(--ds-font-sans\)\)/ ); expect(declarations).toMatch(/font-size:\s*12px/); expect(declarations).toMatch(/font-weight:\s*600/); diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index 820ac886f..5e0cca1f6 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -23,7 +23,7 @@ .workspace-prose h1, .workspace-prose h2, .workspace-prose h3 { - font-family: var(--font-garamond), var(--ds-font-serif); + font-family: var(--font-display), var(--ds-font-serif); color: var(--ds-text-primary); letter-spacing: -0.01em; } @@ -43,7 +43,7 @@ margin: 1.5rem 0 0.5rem; } .workspace-prose .workspace-api-heading { - font-family: var(--font-inter), var(--ds-font-sans); + font-family: var(--font-sans), var(--ds-font-sans); letter-spacing: normal; text-transform: none; } @@ -471,7 +471,7 @@ color: var(--ds-text-muted); background: transparent; cursor: pointer; - font-family: var(--font-inter, var(--ds-font-sans)); + font-family: var(--font-sans, var(--ds-font-sans)); font-size: 12px; font-weight: 600; letter-spacing: normal; @@ -495,7 +495,7 @@ margin: 0; padding: 8px; color: var(--ds-text-muted); - font-family: var(--font-inter, var(--ds-font-sans)); + font-family: var(--font-sans, var(--ds-font-sans)); font-size: 12px; line-height: 1.2; font-weight: 600; From 548ca43a5820388bedfeff275a4a2f1283149cbb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:13:18 -0700 Subject: [PATCH 13/24] docs(design-tokens): note the next/font constraint on --font-diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two documentation fixes from the Task 4 review, both about the diagram carve-out that landed in the previous commit. The fontDiagram docblock explained why diagrams keep Inter but not what keeps them on it: on apps/website the var has to be supplied by next/font in layout.tsx, because the raw `Inter` literal theme.css emits never matches next/font's hashed family name. Cross-referenced to the FONTS note in apps/website/src/styles/ui.css, which now carries the same warning. The ds-var-contract docstring called `--ds-font-serif` "consumer-referenced" in a paragraph that otherwise says the greps for these names come up empty, which read as a contradiction. Reworded to what it actually is: a name this list previously guarded and the retheme retires. Note the review's stated premise was not quite right — `--ds-font-serif` is not reference-free. libs/workspace-react/src/styles/workspace.css still names it as the fallback behind var(--font-display), so the docstring now says so explicitly instead of dropping the fact, and scopes the zero-reference claim to the reserved names it was always about (verified: 0 hits each for --ds-signal, --ds-signal-strong, --ds-scope, --ds-alert, --ds-ink, --ds-font-display, --ds-font-diagram outside libs/design-tokens/). Co-Authored-By: Claude Opus 5 --- libs/design-tokens/src/lib/ds-var-contract.spec.ts | 13 +++++++------ libs/design-tokens/src/lib/typography.ts | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) 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 9d772593d..9edef5dc2 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -23,12 +23,13 @@ const TOKENS_CSS = resolve(__dirname, 'tokens.css'); * 2. Names reserved ahead of their consumers: `--ds-signal`, * `--ds-signal-strong`, `--ds-scope`, `--ds-alert`, `--ds-ink` are the * aviation-yellow retheme's brand colors, and `--ds-font-display` / - * `--ds-font-diagram` are the retheme's font faces (replacing the - * consumer-referenced `--ds-font-serif`, which the retheme retires — see - * workspace.css in Task 5). A repo-wide grep finds ZERO references to any - * of these outside libs/design-tokens/ as of 2026-09-07 — that is - * expected, not a mistake, because cockpit/example adoption has not - * landed yet. Do NOT remove them because the grep above comes up empty; + * `--ds-font-diagram` are the retheme's font faces, replacing + * `--ds-font-serif` — a name this list previously guarded and the retheme + * retires. Its one surviving use is the fallback in workspace.css, behind + * `var(--font-display)` (see Task 5). A repo-wide grep finds ZERO + * references to any of the reserved names above outside + * libs/design-tokens/ as of 2026-09-07 — that is expected, not a mistake, + * because cockpit/example adoption has not landed yet. Do NOT remove them because the grep above comes up empty; * they must stay guarded so the names are ready when a consumer needs * them. */ diff --git a/libs/design-tokens/src/lib/typography.ts b/libs/design-tokens/src/lib/typography.ts index 9f71580e2..1eea936ed 100644 --- a/libs/design-tokens/src/lib/typography.ts +++ b/libs/design-tokens/src/lib/typography.ts @@ -24,6 +24,12 @@ export const typography = { * 8px grid and home-architecture.spec.ts measures each rendered text run * against its card. Retyping diagrams would force a geometry rework larger * than the retheme itself. + * + * On apps/website this var must keep being supplied by next/font in + * layout.tsx: the raw `Inter` literal theme.css emits does not match + * next/font's hashed family name, so without the loader diagrams fall back + * to system-ui and every glyph width shifts. See the FONTS note in + * apps/website/src/styles/ui.css. */ fontDiagram: 'Inter, system-ui, sans-serif', /** Monospace font for code and labels. ui-monospace before the generic From 58f1daf1dcd93de0aa71ad0363bfd26fa9ff97a8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:15:33 -0700 Subject: [PATCH 14/24] fix(workspace-react): repair the dangling --ds-font-serif reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 retired --ds-font-serif from the token sources; Task 5's rename swept --font-garamond in the same declaration but left the --ds-* fallback pointing at a name that no longer exists. The failure mode is silent and worse than it looks: an undefined custom property inside a font-family list is invalid at computed-value time, so the whole declaration is dropped and .workspace-prose headings lose their font entirely. There is no partial fallback to the first entry in the list. ds-var-contract.spec.ts guarded only the other direction — that a name consumers use does not vanish. Adds the inverse check, which fails if any consumer references a --ds-* the generator does not define. Mutation-verified: restoring the bug turns it red with the exact name. --ds-surface-subtle is listed as a known pre-existing dangling reference (already broken at 218ac1c41) so it stays visible rather than being hidden by the new guard. Co-Authored-By: Claude Opus 5 --- .../src/lib/ds-var-contract.spec.ts | 65 ++++++++++++++++++- libs/workspace-react/src/styles/workspace.css | 2 +- 2 files changed, 65 insertions(+), 2 deletions(-) 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 9edef5dc2..c48d0bb00 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { resolve } from 'node:path'; import { describe, it, expect } from 'vitest'; @@ -83,3 +84,65 @@ describe('--ds-* consumer contract', () => { expect(missing).toEqual([]); }); }); + +/** + * No consumer may reference a `--ds-*` name the generator does not define. + * + * The list above guards the other direction — that a name we know consumers + * use does not vanish from tokens.css. It cannot catch the inverse, and the + * inverse is what actually shipped during the 2026-09-07 aviation retheme: + * `--ds-font-serif` was retired from the token sources while + * workspace.css:26 still said `var(--font-display), var(--ds-font-serif)`. + * + * That failure mode is silent and worse than it looks. An undefined custom + * property in a `font-family` list is invalid at computed-value time, so the + * whole declaration is dropped and the element loses its font entirely — + * there is no partial fallback to the first entry in the list. + */ +describe('no dangling --ds-* references', () => { + /** + * Pre-dates the retheme (already dangling at 218ac1c41) and is a real bug in + * its own right: `background: var(--ds-surface-subtle)` with no fallback + * resolves to nothing. Listed rather than hidden so it stays visible; remove + * this entry when workspace.css:209 is fixed or the token is added. + */ + const KNOWN_PRE_EXISTING = new Set(['--ds-surface-subtle']); + + const CONSUMER_ROOTS = ['libs/workspace-react', 'cockpit', 'examples', 'apps/website/src']; + + it('every --ds-* a consumer references is defined in tokens.css', () => { + const repoRoot = resolve(__dirname, '../../../..'); + const defined = new Set( + [...readFileSync(TOKENS_CSS, 'utf-8').matchAll(/^\s*(--ds-[a-z0-9-]+):/gm)].map((m) => m[1]), + ); + + const referenced = new Set(); + for (const root of CONSUMER_ROOTS) { + const dir = resolve(repoRoot, root); + if (!existsSync(dir)) continue; + // grep exits 1 when a directory simply has no matches, and execFileSync + // throws on any non-zero status — so a clean directory would look like a + // broken test rather than a passing one. + let found = ''; + try { + found = execFileSync( + 'grep', + ['-rhoE', '--exclude-dir=node_modules', 'var\\(--ds-[a-z0-9-]+', dir], + { encoding: 'utf-8' }, + ); + } catch (error) { + const status = (error as { status?: number }).status; + if (status !== 1) throw error; + } + for (const hit of found.split('\n')) { + if (hit) referenced.add(hit.replace('var(', '')); + } + } + + expect(referenced.size).toBeGreaterThan(5); + const dangling = [...referenced] + .filter((name) => !defined.has(name) && !KNOWN_PRE_EXISTING.has(name)) + .sort(); + expect(dangling).toEqual([]); + }); +}); diff --git a/libs/workspace-react/src/styles/workspace.css b/libs/workspace-react/src/styles/workspace.css index 5e0cca1f6..aff871fa4 100644 --- a/libs/workspace-react/src/styles/workspace.css +++ b/libs/workspace-react/src/styles/workspace.css @@ -23,7 +23,7 @@ .workspace-prose h1, .workspace-prose h2, .workspace-prose h3 { - font-family: var(--font-display), var(--ds-font-serif); + font-family: var(--font-display), var(--ds-font-display); color: var(--ds-text-primary); letter-spacing: -0.01em; } From 321c83ce27dda7a4e640f7301960c47b0ac073c0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:17:24 -0700 Subject: [PATCH 15/24] feat(website): load Archivo Black and Archivo, drop EB Garamond layout.tsx now loads Archivo_Black for --font-display and Archivo for --font-sans via next/font/google, matching the ATC-derived token library. EB_Garamond is gone. Inter stays loaded here too, feeding --font-diagram. It cannot be supplied by theme.css's raw `Inter, system-ui, sans-serif` literal: next/font registers the family under a hashed name, so that literal never matches it and would silently fall back to system-ui, changing every diagram glyph width. Diagram geometry in apps/website/e2e/home-architecture.spec.ts is pinned to Inter's metrics on an 8px grid, so Inter must keep coming from next/font. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/layout.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index a24c2ace6..a565bc776 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { EB_Garamond, Inter, JetBrains_Mono } from 'next/font/google'; +import { Archivo, Archivo_Black, Inter, JetBrains_Mono } from 'next/font/google'; import '@threadplane/design-tokens/tokens.css'; import './global.css'; import { Nav } from '../components/shared/Nav'; @@ -20,18 +20,29 @@ import { getFormPolicy } from '../lib/growth/form-policy'; import { WebsiteSignals } from '../components/shared/WebsiteSignals'; import { websiteContentCatalog } from '../lib/growth/website-content'; -const garamond = EB_Garamond({ +const display = Archivo_Black({ subsets: ['latin'], - weight: ['400', '700', '800'], - style: ['normal', 'italic'], + weight: '400', variable: '--font-display', }); -const inter = Inter({ +const sans = Archivo({ subsets: ['latin'], variable: '--font-sans', }); +/** + * Inter is retained for diagrams only, and MUST be loaded here rather than + * left to theme.css: next/font registers its family under a hashed name, so + * theme.css's raw `Inter, system-ui` stack never matches it and would silently + * fall back to system-ui. Diagram geometry is pinned to Inter's metrics — + * see the FONTS note in src/styles/ui.css and e2e/home-architecture.spec.ts. + */ +const diagram = Inter({ + subsets: ['latin'], + variable: '--font-diagram', +}); + const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', @@ -66,7 +77,7 @@ export default function RootLayout({ return ( {/* From be6f883198b6145474b3a19d0f33c76c61a3d28d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:20:22 -0700 Subject: [PATCH 16/24] feat(website): signal surface scope for the ATC hero block Co-Authored-By: Claude Opus 5 --- .../src/components/ui/Section.spec.tsx | 6 +++++ apps/website/src/components/ui/Section.tsx | 2 +- apps/website/src/styles/ui.css | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/website/src/components/ui/Section.spec.tsx b/apps/website/src/components/ui/Section.spec.tsx index 99d0a08af..f59c12ff5 100644 --- a/apps/website/src/components/ui/Section.spec.tsx +++ b/apps/website/src/components/ui/Section.spec.tsx @@ -9,6 +9,12 @@ describe('Section', () => { expect(el?.getAttribute('data-surface')).toBe('dark'); }); + it('renders data-surface="signal" when asked', () => { + const { container } = render(
x
); + const el = container.querySelector('[data-ui="section"]'); + expect(el?.getAttribute('data-surface')).toBe('signal'); + }); + it('defaults to canvas', () => { const { container } = render(
x
); expect( diff --git a/apps/website/src/components/ui/Section.tsx b/apps/website/src/components/ui/Section.tsx index 8456115ac..3958146b4 100644 --- a/apps/website/src/components/ui/Section.tsx +++ b/apps/website/src/components/ui/Section.tsx @@ -1,7 +1,7 @@ import type { ReactNode, HTMLAttributes } from 'react'; import { cn } from '../../lib/cn'; -type Surface = 'canvas' | 'tinted' | 'white' | 'dark'; +type Surface = 'canvas' | 'tinted' | 'white' | 'dark' | 'signal'; interface SectionProps extends Omit, 'id'> { children: ReactNode; diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index f411c14c0..0d86cc375 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -209,6 +209,31 @@ pointer-events: none; } +/* Signal section scope (the ATC hero block). + * Aviation yellow is a FILL: this scope flips the ink to near-black rather + * than tinting the yellow, because #FFAF00 carries text at 1.84:1 on white + * and 10.73:1 under #0A0A0A. Same re-scoping mechanism as the dark surface + * above. */ +[data-ui="section"][data-surface="signal"] { + --color-canvas: var(--color-signal); + --color-surface: var(--color-signal); + --color-surface-tinted: var(--color-signal-strong); + --color-border: rgba(10, 10, 10, 0.18); + --color-border-strong: rgba(10, 10, 10, 0.32); + --color-text-primary: var(--color-ink); + --color-text-secondary: rgba(10, 10, 10, 0.78); + --color-text-muted: rgba(10, 10, 10, 0.6); + --color-text-inverted: var(--color-signal); + --color-accent: var(--color-ink); + --color-accent-hover: #000000; + --color-accent-surface: rgba(10, 10, 10, 0.08); + --color-accent-border: rgba(10, 10, 10, 0.25); + --shadow-focus: 0 0 0 3px rgba(10, 10, 10, 0.45); + + background: var(--color-signal); + color: var(--color-text-primary); +} + /* UI primitive — Button */ [data-ui="button"] { display: inline-flex; From fa69fd2de5ac6d3886df425ef46aaa1427d3b435 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:22:32 -0700 Subject: [PATCH 17/24] feat(website): yellow hero block closed by the scope-navy frequency strip Co-Authored-By: Claude Opus 5 --- apps/website/src/components/landing/Hero.tsx | 4 +-- apps/website/src/styles/landing.css | 36 ++++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/website/src/components/landing/Hero.tsx b/apps/website/src/components/landing/Hero.tsx index 37636b0d1..296454e2a 100644 --- a/apps/website/src/components/landing/Hero.tsx +++ b/apps/website/src/components/landing/Hero.tsx @@ -27,7 +27,7 @@ export function Hero() { const closeInstall = useCallback(() => setInstallOpen(false), []); return ( -
+
{HERO_EYEBROW} @@ -59,7 +59,7 @@ export function Hero() {
-

{HERO_TRUST_LINE}

+

{HERO_TRUST_LINE}

diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 15f5f63cd..7792d4f9b 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -17,11 +17,11 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; + font-weight: 400; /* Archivo Black ships a single weight */ color: var(--color-text-primary); margin: 0; margin-bottom: 24px; - letter-spacing: -0.02em; + letter-spacing: -0.032em; } /* One line per thought; the space between the spans keeps textContent equal to HERO_H1. */ .hero-heading-line { @@ -43,15 +43,12 @@ * phrase wraps — which it does at every width below ~1200px). */ .marker-highlight { color: var(--color-text-primary); - font-weight: 600; - background: linear-gradient( - 100deg, - rgba(0, 64, 144, 0.14), - rgba(0, 64, 144, 0.08) 85% - ); - border-radius: 4px; - padding: 1px 5px 3px; - margin: -1px -5px -3px; + font-weight: 700; + background: none; + box-shadow: inset 0 -3px 0 var(--color-accent); + border-radius: 0; + padding: 1px 2px 3px; + margin: -1px -2px -3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; } @@ -2197,3 +2194,20 @@ font-weight: 600; text-decoration: none; } + +/* The frequency strip — the ATC player's bottom bar. Full-bleed scope navy + * closing the yellow block, set in mono because the instrument-panel cue is + * doing as much work here as the colour is. */ +.hero-strip { + margin: 32px calc(50% - 50vw) 0; + width: 100vw; + padding: 10px var(--spacing-container-x); + background: var(--color-scope); + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; +} From ba3ef9722c63500d686ec61c29523cfd4d426fe2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:24:22 -0700 Subject: [PATCH 18/24] fix(website): the marker highlight is a yellow wash everywhere but the hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Task 8 rule replaced the wash with an ink underline globally, but .marker-highlight is not hero-only — /langgraph, /chat, /ag-ui and /render all use it on white canvas, where they would have picked up an accent-coloured underline nobody asked for. A yellow wash is the better answer anyway: it is literally what a highlighter does, and it is the correct use of aviation yellow as a fill under near-black ink rather than as a text colour. The ink underline is now scoped to the signal block, where a yellow wash would be yellow on yellow. Also records in the plan that nx test website does not type-check — a step written as "watch the test fail" on a union change passes vacuously, because vitest skips types and React forwards unknown props to the DOM. Co-Authored-By: Claude Opus 5 --- apps/website/src/styles/landing.css | 25 ++++++++++++++++--- .../plans/2026-09-07-atc-theme-spike.md | 10 ++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 7792d4f9b..dea009c32 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -41,16 +41,33 @@ * with box-decoration-break: clone, so the sweep follows the text across line * wraps (an absolutely-positioned box renders one broken block when the * phrase wraps — which it does at every width below ~1200px). */ +/* Highlighter wash — aviation yellow doing the job yellow actually does on + * paper. Safe anywhere on a light surface because it is a FILL sitting under + * --color-text-primary ink, never a text colour itself. Used by the hero and + * by the four library pages (/langgraph, /chat, /ag-ui, /render). */ .marker-highlight { color: var(--color-text-primary); - font-weight: 700; + font-weight: 600; + background: linear-gradient( + 100deg, + rgba(255, 175, 0, 0.55), + rgba(255, 175, 0, 0.32) 85% + ); + border-radius: 4px; + padding: 1px 5px 3px; + margin: -1px -5px -3px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +/* Inside the signal block the wash would be yellow on yellow. The emphasis + * becomes a solid ink underline instead. */ +[data-ui="section"][data-surface="signal"] .marker-highlight { background: none; - box-shadow: inset 0 -3px 0 var(--color-accent); + font-weight: 700; border-radius: 0; + box-shadow: inset 0 -3px 0 var(--color-ink); padding: 1px 2px 3px; margin: -1px -2px -3px; - -webkit-box-decoration-break: clone; - box-decoration-break: clone; } .hero-cta-row { diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md index 2a9aa4a6b..446f10c5d 100644 --- a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -24,6 +24,16 @@ - `apps/website/e2e/home-architecture.spec.ts` waits for `document.fonts.ready` then measures every text run in the homepage architecture diagram against its card. It is the gate proving the `--font-diagram` carve-out held. If it goes red, restore `--font-diagram` on the three declarations in Task 5 — **do not** adjust the diagram geometry. - `apps/website/e2e/home-hero.spec.ts` exercises the hero demo iframe, which only loads when `HeroDemo.tsx:69`'s `IntersectionObserver` at `threshold: 0.25` fires. Adding vertical padding to the hero pushes that stage down the page. If it goes red, scroll the stage into view in the test — do not shrink the hero copy. +**`nx test website` does NOT type-check.** Vitest transpiles without checking +types, and React forwards an unknown prop string to the DOM happily — so a +test asserting a new union member passes BEFORE the union is extended. Any +step in this plan that says "watch it fail" on a type-level change is wrong as +written: get the real failure from `npx tsc --noEmit -p apps/website/tsconfig.json` +(add `--ignoreDeprecations 6.0`; that config has pre-existing unrelated errors, +so filter to the file you are working on), or from `npx nx build website`, +which type-checks because `next.config` sets no `ignoreBuildErrors`. Treat the +build as the real gate for anything type-level. + **Commands** (run from the worktree root, never `cd` elsewhere): - Regenerate CSS: `npx nx run design-tokens:generate-theme-css` - Token tests: `npx nx test design-tokens` From 5ee27f9ad528084e2b41a74912cc91351cde87f6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:28:41 -0700 Subject: [PATCH 19/24] feat(website): yellow primary button and nav mark, scope-navy dark band Co-Authored-By: Claude Opus 5 --- apps/website/src/styles/ui.css | 72 ++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 0d86cc375..35b7b2a92 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -165,10 +165,12 @@ } /* Dark section scope (homepage proof band + FinalCTA dark variant). - * Values mirror libs/design-tokens/src/lib/dark.ts — the website hardwires - * the light theme in TS, so dark sections re-scope the CSS variables instead. - * If dark.ts changes, update here. Treatment B (spec): the canvas is a subtle - * vertical gradient, and a 1px accent seam marks the light→dark boundary. */ + * The ACCENTS mirror libs/design-tokens/src/lib/dark.ts. The SURFACES + * deliberately do not: this band is website-only and takes the ATC scope + * navy, while dark.ts stays neutral because @threadplane/chat and the cockpit + * consume it and a navy ground there would seam against embedded chat. + * Treatment B (spec): vertical gradient canvas, 1px accent seam at the + * light→dark boundary. */ [data-ui="section"][data-surface="dark"] { --color-canvas: rgb(17, 17, 17); --color-surface: rgb(28, 28, 28); @@ -180,14 +182,14 @@ --color-text-secondary: rgb(200, 200, 200); --color-text-muted: rgb(160, 160, 160); --color-text-inverted: rgb(17, 17, 17); - --color-accent: #64c3fd; - --color-accent-hover: #8dd4ff; - --color-accent-glow: rgba(100, 195, 253, 0.25); - --color-accent-border: rgba(100, 195, 253, 0.2); - --color-accent-border-hover: rgba(100, 195, 253, 0.35); - --color-accent-surface: rgba(100, 195, 253, 0.08); + --color-accent: var(--color-signal); + --color-accent-hover: #FFC233; + --color-accent-glow: rgba(255, 175, 0, 0.25); + --color-accent-border: rgba(255, 175, 0, 0.22); + --color-accent-border-hover: rgba(255, 175, 0, 0.4); + --color-accent-surface: rgba(255, 175, 0, 0.1); - background: linear-gradient(180deg, #161616 0%, #0e0e0e 100%); + background: linear-gradient(180deg, #1b2e4d 0%, #15253e 100%); color: var(--color-text-primary); position: relative; } @@ -202,8 +204,8 @@ background: linear-gradient( 90deg, transparent, - rgba(100, 195, 253, 0.55) 30%, - rgba(100, 195, 253, 0.55) 70%, + rgba(255, 175, 0, 0.55) 30%, + rgba(255, 175, 0, 0.55) 70%, transparent ); pointer-events: none; @@ -260,11 +262,18 @@ font-size: 16px; } [data-ui="button"][data-variant="primary"] { - background: var(--color-accent); - color: var(--color-text-inverted); - border: 1px solid var(--color-accent); + background: var(--color-signal); + color: var(--color-ink); + border: 1px solid var(--color-signal); box-shadow: var(--shadow-sm); } +/* On the yellow hero block the fill inverts — a yellow button on a yellow + * ground has nothing to sit against. */ +[data-ui="section"][data-surface="signal"] [data-ui="button"][data-variant="primary"] { + background: var(--color-ink); + color: var(--color-signal); + border-color: var(--color-ink); +} [data-ui="button"][data-variant="secondary"] { background: var(--color-surface); color: var(--color-text-primary); @@ -280,16 +289,21 @@ /* Keyboard focus ring. * * Without this the UA default applies — `outline: auto 1px rgb(0, 95, 204)` in - * Chrome — which is a thin blue hairline drawn against the primary button's - * own `--color-accent` (#004090) fill. Effectively invisible, and the place it - * matters most is the one place focus lands programmatically: closing the - * install dialog returns focus to the "Install Threadplane" trigger, and the - * keyboard user could not see where they were. + * Chrome — a thin blue hairline drawn straight against the primary button's + * fill. Effectively invisible, and the place it matters most is the one place + * focus lands programmatically: closing the install dialog returns focus to + * the "Install Threadplane" trigger, and the keyboard user could not see where + * they were. * - * The offset ring is `--color-accent`, which the dark section scope above - * re-points to #64c3fd, so it stays high-contrast on both surfaces. The inner - * `--color-surface` halo separates ring from fill on `primary`, where fill and - * ring are otherwise the same colour family. Held by a style contract. */ + * The offset ring is `--color-accent`, which every section scope re-points, so + * it stays high-contrast on all three grounds: scope navy (#15253E) against + * the signal-yellow fill on white, aviation yellow inside the dark band, and + * ink inside the signal scope, where the primary button inverts to an ink + * fill. The inner `--color-surface` halo is what separates ring from fill in + * the two cases where both land in the same colour family — yellow ring on + * yellow fill in the dark band, ink ring on ink fill in the signal scope — + * because `--color-surface` re-points with the scope too (dark grey, then the + * signal yellow). Held by a style contract. */ [data-ui="button"]:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; @@ -360,7 +374,13 @@ [data-ui="logo-mark-icon"] { display: block; flex: none; - color: inherit; + /* PlaneMark's path is fill="currentColor", so the signal arrives via color. + The wordmark beside it keeps --color-text-primary. */ + color: var(--color-signal); +} +/* On the yellow hero block the mark would be yellow on yellow. */ +[data-ui="section"][data-surface="signal"] [data-ui="logo-mark-icon"] { + color: var(--color-ink); } [data-ui="logo-mark"][data-size="sm"] > [data-ui="logo-mark-icon"] { width: 15px; From 0afb4e603c278809fadf8df5d61ab175722c6a8a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:32:10 -0700 Subject: [PATCH 20/24] fix(website): sweep the last hardcoded navy out of the light and dark surfaces Co-Authored-By: Claude Opus 5 --- .../chat-landing/ChatLandingCodeShowcase.tsx | 2 +- apps/website/src/styles/docs.css | 8 ++++---- apps/website/src/styles/landing.css | 14 +++++++------- apps/website/src/styles/style-contracts.spec.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/website/src/components/landing/chat-landing/ChatLandingCodeShowcase.tsx b/apps/website/src/components/landing/chat-landing/ChatLandingCodeShowcase.tsx index 6762af2c4..bb2570776 100644 --- a/apps/website/src/components/landing/chat-landing/ChatLandingCodeShowcase.tsx +++ b/apps/website/src/components/landing/chat-landing/ChatLandingCodeShowcase.tsx @@ -22,7 +22,7 @@ export class MyChatPage { const SNIPPET_2 = `chat { --chat-bg: #f8f9fc; - --chat-user-bg: #004090; + --chat-user-bg: #15253E; --chat-user-color: #ffffff; --chat-assistant-bg: #f0f4ff; --chat-font-family: 'Inter', sans-serif; diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index 6052b9651..526f3f740 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -331,7 +331,7 @@ body:has([data-website-workspace-host]) { /* mdx — Callout (header band; tone via custom properties) */ [data-mdx="callout"] { --callout-tone-text: var(--color-accent); - --callout-tone-surface: rgba(0, 64, 144, 0.06); + --callout-tone-surface: rgba(255, 175, 0, 0.1); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); @@ -1620,7 +1620,7 @@ body:has([data-website-workspace-host]) { height: 22px; border-radius: 6px; flex-shrink: 0; - background: rgba(0, 64, 144, 0.1); + background: rgba(255, 175, 0, 0.14); display: flex; align-items: center; justify-content: center; @@ -1693,8 +1693,8 @@ body:has([data-website-workspace-host]) { color: #5e35b1; } .arch-flow-log-source[data-source="langgraph"] { - background: rgba(0, 64, 144, 0.08); - color: #004090; + background: rgba(255, 175, 0, 0.12); + color: var(--color-accent); } .arch-flow-log-source[data-source="signal"] { background: rgba(16, 185, 129, 0.08); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index dea009c32..8b2ff6b02 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -615,8 +615,8 @@ letter-spacing: -0.04em; background: linear-gradient( 160deg, - rgba(100, 195, 253, 0.14), - rgba(100, 195, 253, 0.03) 70% + rgba(255, 175, 0, 0.14), + rgba(255, 175, 0, 0.03) 70% ); -webkit-background-clip: text; background-clip: text; @@ -982,7 +982,7 @@ } .lg-show-card-head { padding: 10px 20px; - background: rgba(0, 64, 144, 0.04); + background: rgba(255, 175, 0, 0.08); border-bottom: 1px solid var(--color-border); } .lg-show-card-title { @@ -1064,8 +1064,8 @@ letter-spacing: -0.04em; background: linear-gradient( 160deg, - rgba(100, 195, 253, 0.11), - rgba(100, 195, 253, 0.02) 70% + rgba(255, 175, 0, 0.11), + rgba(255, 175, 0, 0.02) 70% ); -webkit-background-clip: text; background-clip: text; @@ -1083,8 +1083,8 @@ height: 560px; background: radial-gradient( closest-side, - rgba(100, 195, 253, 0.13), - rgba(100, 195, 253, 0.04) 55%, + rgba(255, 175, 0, 0.13), + rgba(255, 175, 0, 0.04) 55%, transparent 75% ); pointer-events: none; diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index 8fa06ec5f..0d5bfbb5b 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -236,7 +236,7 @@ const CONTRACTS: StyleContract[] = [ { file: 'ui.css', selector: '[data-ui="button"]:focus-visible', - why: "Without this the UA default `outline: auto 1px rgb(0, 95, 204)` draws a blue hairline on the primary button's own #004090 fill — invisible. Closing the install dialog returns focus to that button, so the keyboard user is left with no idea where they are. The ring uses --color-accent, which the dark section scope re-points, so it survives on both surfaces.", + why: "Without this the UA default `outline: auto 1px rgb(0, 95, 204)` draws a blue hairline straight on the primary button's own fill — invisible. Closing the install dialog returns focus to that button, so the keyboard user is left with no idea where they are. The ring uses --color-accent, which every section scope re-points, so it survives on all three grounds: scope navy (#15253E) against the signal-yellow fill on white, aviation yellow inside the dark band, and ink inside the signal scope, where the primary button inverts to an ink fill.", requires: { outline: /outline:\s*2px\s+solid\s+var\(--color-accent\)/, 'outline-offset': /outline-offset:\s*2px/, From 12be1fd7c71c7b10dfde54eabb3b7ce522637475 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:33:59 -0700 Subject: [PATCH 21/24] =?UTF-8?q?docs:=20withdraw=20the=20docs=20left-rule?= =?UTF-8?q?=20step=20=E2=80=94=20the=20tokens=20already=20did=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocsControlPlane.spec.tsx guards 'rounded sidebar states without a left marker' from #963. The step would have overridden that deliberate decision to achieve something the retheme had already achieved: --color-accent-surface is now a yellow tint and --color-accent is scope navy, so the active item is already a soft yellow pill with navy ink. A plan written days earlier does not get to overrule a design decision the codebase deliberately guards. Co-Authored-By: Claude Opus 5 --- .../specs/2026-09-07-atc-theme-spike-design.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md index 372bf0d36..2938e8ebb 100644 --- a/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md +++ b/docs/superpowers/specs/2026-09-07-atc-theme-spike-design.md @@ -231,10 +231,15 @@ rule or an underline colour. 2. **Homepage hero** — full-bleed `#FFAF00` block, Archivo Black headline in `#0A0A0A`, black primary button, terminated by a `#15253E` strip carrying the trust line in JetBrains Mono. Below the strip, white. -3. **Docs shell** — a document first. Yellow is the active sidebar item's 3px - left rule, the callout's left rule over a near-white tint, and keyword - colour inside scope-navy code blocks. Links are `#15253E` with a yellow - underline. +3. **Docs shell** — a document first. Yellow arrives through the tokens rather + than through new rules: the active sidebar item is already + `background: var(--color-accent-surface)` with `color: var(--color-accent)`, + which the retheme turns into a soft yellow pill with navy ink. Links are + `#15253E`. **Correction to an earlier draft:** this section previously + called for a 3px left rule on the active item. That is withdrawn — + `DocsControlPlane.spec.tsx` deliberately guards "rounded sidebar states + without a left marker" (#963), and the token change already produces the + intended treatment without contradicting it. Every other marketing page (pricing, blog, about, solutions, pilot-to-prod) inherits the new tokens and fonts automatically — that is the point of the From 0ade3b4e671ba5ba6ac83628a8337973760ba593 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 21:34:12 -0700 Subject: [PATCH 22/24] docs: withdraw the plan's docs left-rule step to match the spec correction Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-07-atc-theme-spike.md | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md index 446f10c5d..fd5454d72 100644 --- a/docs/superpowers/plans/2026-09-07-atc-theme-spike.md +++ b/docs/superpowers/plans/2026-09-07-atc-theme-spike.md @@ -950,24 +950,27 @@ Change `color: #004090;` to: color: var(--color-accent); ``` -- [ ] **Step 3: Turn the active-item fill into a signal rule** - -The active rule already exists at `docs.css:917` as -`[data-docs-navlink][data-active]` — every sidebar link carries both -attributes. It currently fills with `--color-accent-surface`, which is now a -yellow tint; a rule reads better than a wash and matches the surface -discipline in spec §6. Replace it: - -```css -[data-docs-navlink][data-active] { - color: var(--color-text-primary); - background: none; - border-left: 3px solid var(--color-signal); - padding-left: 9px; - margin-left: -12px; - font-weight: 700; -} -``` +- [x] **Step 3: WITHDRAWN — the tokens already did this, and a guard says so** + +This step originally replaced the active sidebar item's fill with a 3px +`--color-signal` left rule. It is withdrawn; do nothing here. + +`DocsControlPlane.spec.tsx` has a test named "uses complete rounded sidebar +states without a left marker" asserting `border-radius: 7px`, asserting the +active state uses `background: var(--color-accent-surface)`, and explicitly +asserting NO `border-left` on either the hover or the active state. That came +from #963 (unify docs and runtime control plane) — a deliberate decision. + +More to the point, the step was unnecessary. `--color-accent-surface` is now +`rgba(255, 175, 0, 0.10)` and `--color-accent` is scope navy, so the active +item ALREADY renders as a soft yellow rounded pill with navy ink — yellow as a +fill under dark ink, exactly the discipline spec §6 asks for. The retheme +reached this surface through the tokens, which is the point of doing the work +in the token library. + +Withdrawing rather than overriding: a plan written days earlier does not get to +overrule a design decision the codebase deliberately guards, especially to +achieve something already achieved. - [ ] **Step 4: Run the unit tests** From 46de5d8069d6c0c0a31e057e96252e286dfd45b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 22:03:30 -0700 Subject: [PATCH 23/24] =?UTF-8?q?fix(website):=20Archivo=20Black=20is=20si?= =?UTF-8?q?ngle-weight=20=E2=80=94=20stop=20synthesizing=20bold=20and=20it?= =?UTF-8?q?alic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--font-display` is Archivo Black, loaded by next/font at `weight: '400'`. Google Fonts ships it in exactly one weight and no italic, so every rule that asked for anything else was making the browser synthesize the face — smearing an already-black glyph, or shearing it into a fake oblique. jsdom cannot measure either, so nothing caught it. - Removed 48 `font-weight` declarations (500–800) from rules whose own block sets `font-family: var(--font-display)`: pages.css 21, landing.css 15, marketing.css 5, docs.css 4, ui.css 2, chrome.css 1. The face has one weight, so declaring it is noise. `.hero-heading` keeps its explicit `400` and the comment documenting the constraint at the most-read site. Rules on `--font-sans` and `--font-mono` are untouched: Archivo and JetBrains Mono are genuinely multi-weight. - Removed `font-style: italic` from the four display-face rules in landing.css (`.wp-cover-title`, `.final-cta-heading`, `.final-cta-mark`, `.proof-strip-watermark`). The faux oblique was most visible on the two that set it at 210px and up to 300px. The logo mark was aviation yellow on the white nav and footer — 1.84:1, below even the 3:1 floor for meaningful non-text graphics, and the exact thing this theme's own thesis forbids. It is now the ATC app icon in miniature: a signal-yellow squircle with an ink glyph, 10.73:1, inverting to an ink tile inside the signal scope so it does not vanish on the yellow block. The tile uses `box-sizing: border-box` rather than `content-box`. The logo sets the nav's content-row height at the two smaller breakpoints, so padding on a content-box icon inflates the whole nav — measured 58/66/81 to 64/72/81 against a `--nav-h` that e2e/nav-height.spec.ts pins to the rendered nav within 1px. Border-box insets the glyph instead, leaving every `--nav-h` offset untouched; all 8 nav-height e2e tests pass. The stale comment above the rule (the glyph "inherits the wordmark's color"; "the wordmark is Garamond") is rewritten. Also in libs/design-tokens: - The dangling-`--ds-*` guard only walked four hand-listed directories, missing libs/chat, libs/example-layouts, libs/design-tokens, apps/website/public and apps/website/e2e. Widened to the whole repo with node_modules/.next/dist/ coverage excluded. This surfaced 13 pre-existing dangling names, all from the committed generator artifact apps/website/public/whitepapers/chat-preview.html; they are listed in KNOWN_PRE_EXISTING and attributed in the comment so they stay visible rather than hidden. The guard also had to stop grepping its own docblocks, which quote retired names as prose. - `--ds-font-serif`'s comment claimed a surviving reference in workspace.css; a later commit repaired that, so it now has none. - Aviation yellow on the dark ground is 10.24:1 on `canvas` and 9.24:1 on `surface`, not 8.33:1 — that figure is the ratio against the website's scope navy, a different surface. Corrected, and the deliberate website/dark.ts surface divergence is now documented from both sides. Co-Authored-By: Claude Opus 5 --- apps/website/src/styles/chrome.css | 1 - apps/website/src/styles/docs.css | 7 +- apps/website/src/styles/landing.css | 19 ----- apps/website/src/styles/marketing.css | 5 -- apps/website/src/styles/pages.css | 21 ----- apps/website/src/styles/ui.css | 37 ++++++--- libs/design-tokens/src/lib/dark.ts | 10 ++- .../src/lib/ds-var-contract.spec.ts | 79 +++++++++++++++---- 8 files changed, 104 insertions(+), 75 deletions(-) diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index 65634c333..a5abb50ef 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -423,7 +423,6 @@ .toast-title { font-family: var(--font-display); font-size: 1.05rem; - font-weight: 700; color: var(--color-text-primary); line-height: 1.3; margin-bottom: 6px; diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index 526f3f740..459761dee 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -163,9 +163,9 @@ body:has([data-website-workspace-host]) { .docs-prose { overflow-wrap: break-word; word-break: break-word; max-width: 100%; } .docs-prose > * { max-width: 100%; } -.docs-prose h1 { font-size: 1.875rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; font-family: var(--font-display); } -.docs-prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 1rem; font-family: var(--font-display); } -.docs-prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; font-family: var(--font-display); } +.docs-prose h1 { font-size: 1.875rem; margin-top: 0; margin-bottom: 1rem; font-family: var(--font-display); } +.docs-prose h2 { font-size: 1.5rem; margin-top: 2.5rem; margin-bottom: 1rem; font-family: var(--font-display); } +.docs-prose h3 { font-size: 1.25rem; margin-top: 2rem; margin-bottom: 0.75rem; font-family: var(--font-display); } .docs-prose p { line-height: 1.75; margin-bottom: 1.25rem; } .docs-prose ul, .docs-prose ol { margin-bottom: 1.25rem; @@ -571,7 +571,6 @@ body:has([data-website-workspace-host]) { } .mdx-card-title { font-family: var(--font-display); - font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); margin-bottom: 4px; diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 8b2ff6b02..10db92639 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -143,7 +143,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 20px; @@ -248,7 +247,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 20px; @@ -333,10 +331,8 @@ font-family: var(--font-display); font-size: 28px; line-height: 1.15; - font-weight: 700; color: var(--color-text-primary); margin-bottom: 12px; - font-style: italic; } .wp-cover-desc { font-family: var(--font-sans); @@ -385,7 +381,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 20px; @@ -448,7 +443,6 @@ .pilot-step-num { font-family: var(--font-display); font-size: 44px; - font-weight: 700; line-height: 0.9; letter-spacing: -0.03em; color: var(--color-border); @@ -493,7 +487,6 @@ .teams-report-title { font-family: var(--font-display); font-size: 26px; - font-weight: 700; line-height: 1.1; margin: 8px 0 6px; color: var(--color-text-primary); @@ -570,12 +563,10 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; letter-spacing: -0.015em; - font-style: italic; } .final-cta-subtext { font-family: var(--font-sans); @@ -608,8 +599,6 @@ right: -30px; bottom: -70px; font-family: var(--font-display); - font-style: italic; - font-weight: 800; font-size: 210px; line-height: 1; letter-spacing: -0.04em; @@ -697,7 +686,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; letter-spacing: -0.015em; @@ -916,7 +904,6 @@ .render-show-heading { font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); - font-weight: 800; line-height: 1.1; color: var(--color-text-primary); } @@ -964,7 +951,6 @@ .lg-show-heading { font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); - font-weight: 800; line-height: 1.1; color: var(--color-text-primary); } @@ -1012,7 +998,6 @@ .chat-show-heading { font-family: var(--font-display); font-size: clamp(26px, 3.5vw, 42px); - font-weight: 800; line-height: 1.1; color: var(--color-text-primary); } @@ -1057,8 +1042,6 @@ right: -40px; top: -78px; font-family: var(--font-display); - font-style: italic; - font-weight: 800; font-size: clamp(150px, 21vw, 300px); line-height: 0.76; letter-spacing: -0.04em; @@ -1164,7 +1147,6 @@ .proof-strip-value { font-family: var(--font-display); font-size: 58px; - font-weight: 700; line-height: 0.9; letter-spacing: -0.028em; color: var(--color-text-primary); @@ -1530,7 +1512,6 @@ font-family: var(--font-display); font-size: 30px; line-height: 1.08; - font-weight: 500; color: var(--color-text-primary); margin: 0; } diff --git a/apps/website/src/styles/marketing.css b/apps/website/src/styles/marketing.css index 9b7961955..2d1c663d1 100644 --- a/apps/website/src/styles/marketing.css +++ b/apps/website/src/styles/marketing.css @@ -94,7 +94,6 @@ } .pricing-plan-price-amount { font-family: var(--font-display); - font-weight: 700; font-size: 30px; color: var(--color-text-primary); line-height: 1; @@ -365,7 +364,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; letter-spacing: -0.015em; @@ -442,7 +440,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; letter-spacing: -0.02em; margin: 0; } @@ -548,7 +545,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 12px; @@ -581,7 +577,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 12px; diff --git a/apps/website/src/styles/pages.css b/apps/website/src/styles/pages.css index c945ac00a..6721a84bd 100644 --- a/apps/website/src/styles/pages.css +++ b/apps/website/src/styles/pages.css @@ -33,7 +33,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; @@ -292,7 +291,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; @@ -340,7 +338,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -374,7 +371,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; letter-spacing: -0.015em; @@ -448,7 +444,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; letter-spacing: -0.015em; @@ -505,7 +500,6 @@ .sol-page-arch-title { font-family: var(--font-display); font-size: 22px; - font-weight: 700; color: var(--color-text-primary); margin: 0; } @@ -531,7 +525,6 @@ .sol-page-metric { font-family: var(--font-display); font-size: 40px; - font-weight: 700; line-height: 0.9; letter-spacing: -0.02em; color: var(--color-text-primary); @@ -550,7 +543,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -585,7 +577,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -694,7 +685,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; letter-spacing: -0.015em; @@ -758,7 +748,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -795,7 +784,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -832,7 +820,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -890,7 +877,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 24px; @@ -946,7 +932,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; letter-spacing: -0.02em; color: var(--color-text-primary); margin: 0 0 16px; @@ -1014,7 +999,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; letter-spacing: -0.02em; color: var(--color-text-primary); margin: 0 0 24px; @@ -1048,7 +1032,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; @@ -1089,7 +1072,6 @@ } .pricing-page-h1 { font-family: var(--font-display); - font-weight: 700; font-size: var(--text-h1); line-height: var(--text-h1--line-height); color: var(--color-text-primary); @@ -1161,7 +1143,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; @@ -1240,7 +1221,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; @@ -1272,7 +1252,6 @@ font-family: var(--font-display); font-size: var(--text-h1); line-height: var(--text-h1--line-height); - font-weight: 700; color: var(--color-text-primary); margin: 0; margin-bottom: 16px; diff --git a/apps/website/src/styles/ui.css b/apps/website/src/styles/ui.css index 35b7b2a92..985ab14e6 100644 --- a/apps/website/src/styles/ui.css +++ b/apps/website/src/styles/ui.css @@ -358,7 +358,6 @@ align-items: center; gap: 8px; font-family: var(--font-display); - font-weight: 700; color: var(--color-text-primary); line-height: 1; } @@ -368,19 +367,38 @@ [data-ui="logo-mark"][data-size="md"] { font-size: 16px; } -/* The glyph is an SVG filled with currentColor, so it inherits the wordmark's - * color. Sized in px rather than em: the wordmark is Garamond, whose em box - * would make the plane read smaller beside it than it measures. */ +/* The mark is the ATC app icon in miniature: a signal-yellow squircle with an + * ink glyph. A bare yellow glyph on the white nav would be 1.84:1 — yellow is + * a fill in this system, never an icon colour on a light ground. Inside the + * tile the same glyph is ink-on-yellow, 10.73:1. + * + * PlaneMark's path is fill="currentColor", so `color` paints the glyph and + * `background` the tile; the wordmark beside it keeps --color-text-primary and + * is sized in px rather than em so the tile does not track the wordmark's em + * box. + * + * `border-box`, not `content-box`, and the difference is load-bearing. The + * logo is what sets the nav's content-row height at the two smaller steps, so + * padding on a content-box icon grows the whole nav: measured 58/66/81 → + * 64/72/81, against a `--nav-h` of 58/66/81 that e2e/nav-height.spec.ts pins + * to the rendered nav within 1px. Border-box insets the glyph instead of + * inflating the box, so the tile stays exactly the 15/18px below and every + * offset built on --nav-h is untouched. Grow the tile only by raising those + * two sizes AND re-measuring --nav-h. */ [data-ui="logo-mark-icon"] { display: block; flex: none; - /* PlaneMark's path is fill="currentColor", so the signal arrives via color. - The wordmark beside it keeps --color-text-primary. */ - color: var(--color-signal); + color: var(--color-ink); + background: var(--color-signal); + border-radius: 5px; + padding: 3px; + box-sizing: border-box; } -/* On the yellow hero block the mark would be yellow on yellow. */ +/* On the yellow block the squircle inverts — a yellow tile on a yellow ground + * has nothing to sit against. */ [data-ui="section"][data-surface="signal"] [data-ui="logo-mark-icon"] { - color: var(--color-ink); + color: var(--color-signal); + background: var(--color-ink); } [data-ui="logo-mark"][data-size="sm"] > [data-ui="logo-mark-icon"] { width: 15px; @@ -524,7 +542,6 @@ font-family: var(--font-display); font-size: var(--text-h2); line-height: var(--text-h2--line-height); - font-weight: 700; letter-spacing: -0.015em; color: var(--color-text-primary); margin: 0; diff --git a/libs/design-tokens/src/lib/dark.ts b/libs/design-tokens/src/lib/dark.ts index 10cd678f7..e8738958a 100644 --- a/libs/design-tokens/src/lib/dark.ts +++ b/libs/design-tokens/src/lib/dark.ts @@ -4,6 +4,12 @@ import { baseTokens } from './base.ts'; * Theme-variant tokens resolved for the dark theme. * Neutral-dark palette aligned to @threadplane/chat lib's dark aesthetic so embedded * chat surfaces unify with cockpit chrome (no iframe color seam). + * + * The website's `[data-surface="dark"]` scope diverges from this on purpose: + * it mirrors these ACCENTS but swaps the SURFACES for the ATC scope navy, + * which is safe there because nothing embeds chat inside that band. See the + * docblock above that rule in apps/website/src/styles/ui.css. Keep the two in + * step on accents; expect them to differ on surfaces. */ export const darkOverrides = Object.freeze({ // Surfaces @@ -24,7 +30,9 @@ export const darkOverrides = Object.freeze({ bg: 'rgb(17, 17, 17)', sidebarBg: 'rgba(28, 28, 28, 0.65)', - // Semantic accent maps to aviation yellow (8.33:1 on the dark ground). + // Semantic accent maps to aviation yellow: 10.24:1 on `canvas` and 9.24:1 on + // `surface`, the two grounds it actually lands on here. (8.33:1 is the ratio + // against the website's scope navy #15253E — a different surface entirely.) // `accent` derives from the brand token; the tints below must be re-derived // with it or they silently keep the previous hue. accent: baseTokens.brand.accentLight, 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 c48d0bb00..d264694a3 100644 --- a/libs/design-tokens/src/lib/ds-var-contract.spec.ts +++ b/libs/design-tokens/src/lib/ds-var-contract.spec.ts @@ -26,13 +26,15 @@ const TOKENS_CSS = resolve(__dirname, 'tokens.css'); * aviation-yellow retheme's brand colors, and `--ds-font-display` / * `--ds-font-diagram` are the retheme's font faces, replacing * `--ds-font-serif` — a name this list previously guarded and the retheme - * retires. Its one surviving use is the fallback in workspace.css, behind - * `var(--font-display)` (see Task 5). A repo-wide grep finds ZERO - * references to any of the reserved names above outside - * libs/design-tokens/ as of 2026-09-07 — that is expected, not a mistake, - * because cockpit/example adoption has not landed yet. Do NOT remove them because the grep above comes up empty; - * they must stay guarded so the names are ready when a consumer needs - * them. + * retires. `--ds-font-serif` is now fully retired: the workspace.css + * fallback that briefly outlived it has since been repaired, so the name + * has zero references repo-wide. + * + * A repo-wide grep likewise finds ZERO references to any of the reserved + * names above outside libs/design-tokens/ as of 2026-09-07 — that is + * expected, not a mistake, because cockpit/example adoption has not landed + * yet. Do NOT remove them because the grep comes up empty; they must stay + * guarded so the names are ready when a consumer needs them. */ const CONSUMER_REFERENCED = [ '--ds-accent', @@ -101,14 +103,54 @@ describe('--ds-* consumer contract', () => { */ describe('no dangling --ds-* references', () => { /** - * Pre-dates the retheme (already dangling at 218ac1c41) and is a real bug in - * its own right: `background: var(--ds-surface-subtle)` with no fallback - * resolves to nothing. Listed rather than hidden so it stays visible; remove - * this entry when workspace.css:209 is fixed or the token is added. + * Dangling names this branch inherited rather than introduced. Listed rather + * than hidden, so they stay visible and countable. + * + * `--ds-surface-subtle` pre-dates the retheme (already dangling at 218ac1c41) + * and is a real bug in its own right: `background: var(--ds-surface-subtle)` + * with no fallback resolves to nothing. Remove this entry when + * workspace.css:209 is fixed or the token is added. + * + * The other thirteen all come from one committed static artifact, + * apps/website/public/whitepapers/chat-preview.html, emitted by the + * whitepaper generator against a `--ds-color-*` / `--ds-font-*-*` naming + * scheme this repo's tokens have never defined. They also pre-date the + * retheme; they were simply invisible until CONSUMER_ROOTS widened to the + * whole repo below. The fix belongs in the generator, not here. + */ + const KNOWN_PRE_EXISTING = new Set([ + '--ds-surface-subtle', + // The thirteen from whitepapers/chat-preview.html: + '--ds-color-brand-primary', + '--ds-color-feedback-error', + '--ds-color-feedback-success', + '--ds-color-surface-elevated', + '--ds-color-surface-elevated-dark', + '--ds-color-surface-sunken', + '--ds-color-text-muted', + '--ds-color-text-primary', + '--ds-color-text-primary-dark', + '--ds-font-family-body', + '--ds-font-size-md', + '--ds-line-height-normal', + '--ds-spacing-base', + ]); + + /** + * The whole repo, not a hand-kept list of directories. The previous roots + * missed libs/chat, libs/example-layouts, libs/design-tokens, + * apps/website/public and apps/website/e2e — so a dangling reference in any + * of them was invisible to the guard that exists to catch exactly that. */ - const KNOWN_PRE_EXISTING = new Set(['--ds-surface-subtle']); + const CONSUMER_ROOTS = ['libs', 'apps', 'cockpit', 'examples']; - const CONSUMER_ROOTS = ['libs/workspace-react', 'cockpit', 'examples', 'apps/website/src']; + /** + * This file is not a consumer. Its docblocks quote `var(--ds-font-serif)` and + * `var(--ds-surface-subtle)` as prose about names that are retired or broken, + * and once `libs` became a root the grep began reading its own commentary + * back as evidence — so documenting a retired name would re-break the guard. + */ + const SELF = 'ds-var-contract.spec.ts'; it('every --ds-* a consumer references is defined in tokens.css', () => { const repoRoot = resolve(__dirname, '../../../..'); @@ -127,7 +169,16 @@ describe('no dangling --ds-* references', () => { try { found = execFileSync( 'grep', - ['-rhoE', '--exclude-dir=node_modules', 'var\\(--ds-[a-z0-9-]+', dir], + [ + '-rhoE', + '--exclude-dir=node_modules', + '--exclude-dir=.next', + '--exclude-dir=dist', + '--exclude-dir=coverage', + `--exclude=${SELF}`, + 'var\\(--ds-[a-z0-9-]+', + dir, + ], { encoding: 'utf-8' }, ); } catch (error) { From a9b787f18bc590f4e586305ae82cfce178ab7cad Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 22:13:46 -0700 Subject: [PATCH 24/24] =?UTF-8?q?fix(website):=20rebase=20repairs=20?= =?UTF-8?q?=E2=80=94=20#1057's=20strip=20used=20the=20retired=20font=20var?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-source strip landed on main after this branch renamed the font custom properties, so it referenced --font-garamond and --font-inter, which no longer exist. An undefined custom property in a font-family declaration is invalid at computed-value time, so both rules would have shipped as silent system-font fallbacks that look almost right. .open-source-strip-line also puts an italic inside what would have been the display face, and Archivo Black has no italic. It is body-scale editorial copy rather than display type, so it moves to --font-sans, where the italic is real: Archivo now loads style ['normal','italic']. That also retires the faux italics the sans face had been synthesizing all along, since Inter was loaded normal-only too. Adds font-vars.spec.ts, which fails if any stylesheet references a --font-* var layout.tsx does not supply. This exact bug happened twice in one day — the rename, and then a PR that landed in between — and nothing caught it: jsdom does not resolve custom properties and the Next build does not type-check CSS. Mutation-verified. Co-Authored-By: Claude Opus 5 --- apps/website/src/app/layout.tsx | 4 ++ apps/website/src/styles/font-vars.spec.ts | 49 +++++++++++++++++++++++ apps/website/src/styles/landing.css | 6 ++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 apps/website/src/styles/font-vars.spec.ts diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index a565bc776..0a32b3ea3 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -28,6 +28,10 @@ const display = Archivo_Black({ const sans = Archivo({ subsets: ['latin'], + // Archivo ships a true italic. Loading it makes every italic on the text + // face real rather than an obliqued upright — Inter was normal-only here, + // so those sites had been faux-italic all along. + style: ['normal', 'italic'], variable: '--font-sans', }); diff --git a/apps/website/src/styles/font-vars.spec.ts b/apps/website/src/styles/font-vars.spec.ts new file mode 100644 index 000000000..09b368c94 --- /dev/null +++ b/apps/website/src/styles/font-vars.spec.ts @@ -0,0 +1,49 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it, expect } from 'vitest'; + +/** + * Every `--font-*` custom property this app references must be one the app + * actually supplies. + * + * `layout.tsx` defines exactly four, via `next/font` on ``. A reference + * to anything else resolves to nothing, and an undefined custom property in a + * `font-family` declaration is invalid at computed-value time — the whole + * declaration is dropped and the element silently falls back to the inherited + * or system face. Nothing else catches it: jsdom does not resolve custom + * properties, and the Next build does not type-check CSS. + * + * This guard exists because it happened twice in one day. The 2026-09-07 + * aviation retheme renamed `--font-garamond`/`--font-inter` to + * `--font-display`/`--font-sans`, and a PR that landed on main in between + * added two new rules still using the old names. They would have shipped as + * system-font fallbacks that look almost right. + */ +const SUPPLIED = new Set([ + '--font-display', // Archivo Black + '--font-sans', // Archivo + '--font-diagram', // Inter — diagram geometry is pinned to its metrics + '--font-mono', // JetBrains Mono +]); + +describe('font custom properties', () => { + const dir = __dirname; + const files = readdirSync(dir).filter((f) => f.endsWith('.css')); + + it('reads a non-trivial number of stylesheets', () => { + expect(files.length).toBeGreaterThan(3); + }); + + it('references only font vars that layout.tsx supplies', () => { + const offenders: string[] = []; + for (const file of files) { + const css = readFileSync(join(dir, file), 'utf-8'); + for (const match of css.matchAll(/var\((--font-[a-z0-9-]+)/g)) { + if (!SUPPLIED.has(match[1])) { + offenders.push(`${file}: ${match[1]}`); + } + } + } + expect([...new Set(offenders)].sort()).toEqual([]); + }); +}); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 10db92639..e188e6a6f 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -642,7 +642,9 @@ flex-wrap: wrap; } .open-source-strip-line { - font-family: var(--font-garamond); + /* Body-scale editorial copy, not display type: it carries an italic , + and Archivo Black has no italic. --font-sans (Archivo) does. */ + font-family: var(--font-sans); font-size: 23px; line-height: 1.35; font-weight: 400; @@ -660,7 +662,7 @@ flex: none; } .open-source-strip-licence { - font-family: var(--font-inter); + font-family: var(--font-sans); font-size: 12px; letter-spacing: 0.02em; color: var(--color-text-muted);