diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index 4d542ed434..71cbd74bed 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -125,6 +125,9 @@ absence of both before calling it a defect. `--name\s*:`, not bare `var(--name)`, or referenced-but-undefined vars read as defined) and the bundle's export list on every re-sync. A helper lives at `.design-sync/.cache/validate-conventions.mjs` (gitignored, cheap to rewrite). +- The "Opt-in v2 token layer" paragraph was aligned to TOKENS / html mount + (`ckb-v2` on ``, shared `--leading-prose`, hero companions only). The + next remote sync must not restore per-step `-lh`/`-tr`. - The driver reports the token port as `changed: []` with `sourceKeys` unchanged: `sourceKeys` track the authored preview + preview-affecting config, NOT component source. A component-source or token change surfaces instead as diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md index 0457bebec8..c52cf4f61d 100644 --- a/.design-sync/conventions.md +++ b/.design-sync/conventions.md @@ -66,12 +66,14 @@ glyphs). Label and caption **text** uses `--text-muted`. ## Opt-in v2 token layer `.ckb-v2` is an opt-in class that swaps in the v2 shell: white surfaces, a blue -`--command`, a crisper `--e1`…`--e4` ladder, a 7-step type scale with per-step -line-height and tracking, semantic spacing (`--gap-*`, `--pad-*`), density -(`--tap-min`, `--chip-height`, `--row-*`), icon sizes (`--icon-*`) and motion -durations. Add `ckb-v2` (plus `dark` for the dark ramp) to a subtree to adopt it; -without the class nothing changes. Components that reference v2-only tokens carry -a v1 fallback (`var(--pad-panel,1.5rem)`) so they render correctly either way. +`--command`, a crisper `--e1`…`--e4` ladder, a 7-step type scale (size steps with +shared `--leading-prose`; hero companions `--text-hero--line-height` / +`--text-hero-tr` only — no per-step leading or tracking), semantic spacing +(`--gap-*`, `--pad-*`), density (`--tap-min`, `--chip-height`, `--row-*`), icon +sizes (`--icon-*`) and motion durations. Production puts `ckb-v2` on `` +(`src/app/layout.tsx`); subtree opt-in is no longer the production model. +Components that reference v2-only tokens carry a v1 fallback +(`var(--pad-panel,1.5rem)`) so they render correctly either way. ## Class-string vocabulary (exported constants) diff --git a/.gitattributes b/.gitattributes index d7d53da656..7d9c6766be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ *.pdf binary *.png binary *.webp binary +*.woff2 binary # The historical Markdown ledgers are serial-only. New review records and issue # changes use immutable per-request files, so GitHub never needs a workstation- diff --git a/docs/design-system-contract.md b/docs/design-system-contract.md index 850ab04813..d69ff72c34 100644 --- a/docs/design-system-contract.md +++ b/docs/design-system-contract.md @@ -34,7 +34,7 @@ npm run check:icon-scale ### 2.2 Typography Scale - **Named Steps Only**: Font sizes must use the registered type steps in `@theme`: - - `text-3xs` (10px - absolute floor), `text-2xs` (11px), `text-xs` (12px), `text-sm-minus` (13px), `text-sm` (14px), `text-base-minus` (15px), `text-base` (16px), `text-lg-minus` (17px), `text-lg` (18px), `text-xl` (20px), `text-2xl-minus` (22px), `text-2xl` (24px). + - `text-3xs` (10px - absolute floor), `text-2xs` (11px), `text-xs` (12px), `text-sm` / `text-sm-minus` (13px; v2 `--text-sm` equals `@theme --text-sm-minus` at `0.8125rem`), `text-base-minus` (15px), `text-base` (16px), `text-lg-minus` (17px), `text-lg` (18px), `text-xl` (20px), `text-2xl-minus` (22px), `text-2xl` (24px). - **Arbitrary Size Prohibited**: `text-[12px]`, `text-[13px]`, etc. are blocked by `npm run check:type-scale --strict`. - **Declared Steps Usage**: Any type step declared in `@theme` must have production consumers (no dead or unselected type tokens). diff --git a/docs/design-system.md b/docs/design-system.md index 73e9116a2a..e12da5922a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -77,9 +77,12 @@ When you meet a pre-token hardcode (mockups being promoted, old branches), map i Named steps live in the `@theme` block of `globals.css` and are **size-only** (no baked line-height/tracking — set `leading-*`/`tracking-*` at the call site): -`text-3xs` 10px (floor) · `text-2xs` 11px · (`text-xs` 12 / `text-sm` 14 / `text-base` -16 from Tailwind) · `text-sm-minus` 13px · `text-base-minus` 15px · (`text-lg` 18 / `text-xl` -20 / `text-2xl` 24 from Tailwind) · `text-lg-minus` 17px · `text-2xl-minus` 22px. +`text-3xs` 10px (floor) · `text-2xs` 11px · (`text-xs` 12 / `text-base` 16 from Tailwind) · +`text-sm` / `text-sm-minus` 13px (v2 `--text-sm` equals `@theme --text-sm-minus` at +`0.8125rem`; Tailwind's default `text-sm` 14px is overridden app-wide because `ckb-v2` is +mounted on ``. Pinned in `tests/ckb-v2-token-contract.test.ts`; do not restore 14px +and do not alias the two files at each other) · `text-base-minus` 15px · (`text-lg` 18 / +`text-xl` 20 / `text-2xl` 24 from Tailwind) · `text-lg-minus` 17px · `text-2xl-minus` 22px. - **10px is the floor.** An 8px `text-4xs` step existed and is retired — indefensible at any density in a clinical product. Do not reintroduce a sub-10px step. diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index fc885beddb..0414812bcd 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -19,7 +19,7 @@ than CI, two of them by the second reader; review does not scale past two carefu | Check | What it enforces | Label | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | | `npm run check:design-system-contract` (token baseline + adoption + design-sync contracts) | Raw colour literals (ratchet against `design-system-contract-baseline.json`), literal `shadow-[…]` classes, the legacy tap class, interactive tap-size literals, therapy-compass `tc-btn` base-class use; border+ring edge conflicts, 1px shadow spreads, layout-property transitions and legacy shadow aliases (all ratcheted **per path**, so new use fails anywhere); status-coloured numerals and colour-only status indicators; raw padding, radius, gap and line-height literals in both classes and CSS declarations (ratcheted **per path**; named utilities and Tailwind arbitrary-property forms such as `[padding:22px]` both count; values computed with `var()`/`env()`/`calc()`/`clamp()`/`max()`, zero with any CSS unit identifier, and the CSS-wide keywords are exempt); declared-but-unselected type steps (**hard rule** with a gated exemption list); `dark:` colour overrides, legacy palette utilities and image-inversion filters (all pinned at **zero**, not ratcheted); deterministic adoption truth; local source/export/preview/design-sync parity. Mockups exempt. | implemented-blocking | -| `tests/ckb-v2-token-contract.test.ts` | v2 layer stays class-scoped (no `:root` leak) · `--border-lux` solid and no lighter than `--border`, both themes · dark surface ramp monotonic and separated · `--surface-subtle` aliases up · text/muted/heading ≥4.5:1 on the light shell, muted ≥4.5:1 dark · `--text-soft` pinned **below** 4.5:1 and ≥3:1 (both sides, so the tier cannot be "fixed" away) · command pair ≥4.5:1 both themes · `--shadow-inset` true inset · elevation ladder carries no baked 1px hairline · tap floor ≠ chip height ≠ compact row · reduced-motion zeroes durations · type-scale pins `--leading-prose` plus `--text-hero--line-height` / `--text-hero-tr` (no per-step `-lh`/`-tr` orphans) | implemented-blocking | +| `tests/ckb-v2-token-contract.test.ts` | v2 layer stays class-scoped (no `:root` leak) · `--border-lux` solid and no lighter than `--border`, both themes · dark surface ramp monotonic and separated · `--surface-subtle` aliases up · text/muted/heading ≥4.5:1 on the light shell, muted ≥4.5:1 dark · `--text-soft` pinned **below** 4.5:1 and ≥3:1 (both sides, so the tier cannot be "fixed" away) · command pair ≥4.5:1 both themes · `--shadow-inset` true inset · elevation ladder carries no baked 1px hairline · tap floor ≠ chip height ≠ compact row · reduced-motion zeroes durations · type-scale pins `--leading-prose` plus `--text-hero--line-height` / `--text-hero-tr` (no per-step `-lh`/`-tr` orphans) · v2 `--text-sm` === `@theme --text-sm-minus` === `0.8125rem` (literals; not aliased at each other) | implemented-blocking | | `tests/design-token-contract.test.ts` | Live-layer token relationships (companion to the v2 contract) | implemented-blocking | | `tests/source-badges-off-vocab.dom.test.tsx` | Enum resilience: off-vocabulary `clinical_validation_status` degrades to the neutral triad, logs once, never throws | implemented-blocking | | `tests/source-metadata-browser-safety.test.ts` | The `process is not defined` client-bundle crash stays fixed (server logger never ships to the browser) | implemented-blocking | @@ -223,14 +223,18 @@ point where the scale argument for (b) would start being true. ## 4 · Recorded verification evidence -Recorded at export (31 July 2026), **not re-run for this document set**: +The `npx tsc` / `npm run lint` / `npm run test` / `npm run verify:ui` bullets remain the +31 July 2026 snapshot and were **not re-run** for later document-set edits. The +`check:design-system-contract` bullet was **re-quoted** against the current +`scripts/design-system-contract-baseline.json` (raw colours 0) so it stays aligned with +the adoption pin. - `npx tsc --noEmit` — pass · `npm run lint` — pass - `npm run test` — **4 689 passed, 1 failed, 3 skipped**; the failure is `tests/installed-lock-parity.test.ts`, environmental (`brace-expansion` lock drift, issue `#149`, fixed by `npm ci`), pre-existing and unrelated - `npm run verify:ui` — **344 passed**, exit 0 -- `check:design-system-contract` — pass: 625 production files, raw colours 2, literal +- `check:design-system-contract` — pass: 625 production files, raw colours 0, literal shadows 0, legacy tap classes 0 The honest sentence includes the failure. Exit code 0 alone is never proof — quote the diff --git a/docs/design-system/SPEC.md b/docs/design-system/SPEC.md index 143e140a99..ef9c77870b 100644 --- a/docs/design-system/SPEC.md +++ b/docs/design-system/SPEC.md @@ -214,8 +214,11 @@ to `MarkText`. One wrong token, two defects. (PR 3; gate planned.) ### 4.5 Type -Seven steps, each with its own line-height **and** tracking (gated — contract test -`:194-204`). Negative tracking only from `--text-body` up; 12px floor. Step roles: +Seven **size** steps; xs–xl are size-only; shared leading is `--leading-prose`. Hero +keeps `--text-hero--line-height` and `--text-hero-tr` only. Do not reintroduce per-step +`-lh` / `-tr` / `--text-{step}--line-height` orphans (gated — contract test `pins shared +leading and hero companions, not per-step orphans`). Negative tracking only from +`--text-body` up; 12px floor. Step roles: `--text-xs` eyebrows/chips/captions · `--text-sm` metadata/dense cells/hints · `--text-body` UI body/row titles · `--text-md` **answer prose** · `--text-lg` card and panel titles · `--text-xl` page titles · `--text-hero` hero counts. diff --git a/mockups/README.md b/mockups/README.md index de61edc03e..a3f63e13b5 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -102,11 +102,14 @@ design decision rather than guessed at from the names. | `document-image-status` | Fixture backing a component test — keep. | | `accessible-table-browser-fixture` | Fixture backing a component test — keep. | -### Dictionary browse header — three rounds, no recorded winner +### Dictionary browse header — three rounds, keep all three `dictionary-browse-header`, `dictionary-browse-header-compact`, `dictionary-control-row` — -see the dated write-ups below; each attacks a different part of the same header and none is -recorded as the final pick. +see the dated write-ups below; each attacks a different part of the same header. Round two's +Version 01 is the one that actually shipped to `/dictionary/browse` (confirmed against the +shipping commit, corrected below — the write-up briefly recorded the wrong version as chosen). +None of the three routes supersedes another at the route level — round two and round three both +import code from round one's component file, so all three stay regardless. ### Search chrome & composer @@ -230,9 +233,13 @@ dropdown on phones** and moves **Abbreviations out of the header into the Filter | Version | Phone chrome | Trade-off | | ------------------------------ | ------------ | ------------------------------------------------------------------- | | 01 Title bar + letter dropdown | 2 rows | Title still costs a row the mode nav already implies | -| 02 Single fused row (rec.) | 1 row | An active filter chip costs the row its title and count at 390 px | +| 02 Single fused row | 1 row | An active filter chip costs the row its title and count at 390 px | | 03 Slim toolbar, title retired | 1 slim bar | Phone loses its visual page title; depends on the mode nav above it | +**Shipped: Version 01**, not the recommendation the study opened with — the commit that shipped this study to +`/dictionary/browse` records "Version 01 is the chosen direction" (PR #2143). `dictionary-control-row`'s later study +builds on that outcome. + Demoting a view switch into a sheet hides state, so each version surfaces an active **Abbreviations** chip beside the letter control. Without it the header would claim 96 terms while listing 24 abbreviations. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1cc0c98459..68e93cedea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -36,15 +36,20 @@ function supabaseOrigin() { } const geistSans = localFont({ - src: "../../node_modules/next/dist/next-devtools/server/font/geist-latin.woff2", + src: "../fonts/geist-latin.woff2", variable: "--font-geist-sans", display: "swap", + // next/font/local only emits font-weight when this is set. The vendored + // WOFF2s are variable (wght 100–900); without the range, @font-face has no + // font-weight and browsers faux-bold headings. + weight: "100 900", }); const geistMono = localFont({ - src: "../../node_modules/next/dist/next-devtools/server/font/geist-mono-latin.woff2", + src: "../fonts/geist-mono-latin.woff2", variable: "--font-geist-mono", display: "swap", + weight: "100 900", // The mono face is only used deep in the UI (tabular figures, `kbd`, code) and // never in initial/LCP text, so don't preload it on every route — it competes // for the critical-path connection. It still loads on-demand via `swap` when diff --git a/src/components/primitive-recipes/recipes.ts b/src/components/primitive-recipes/recipes.ts index 5dfa1cb88c..f93a92a57a 100644 --- a/src/components/primitive-recipes/recipes.ts +++ b/src/components/primitive-recipes/recipes.ts @@ -1,18 +1,6 @@ import type { MouseEvent } from "react"; -import { twMergeClinical } from "@/lib/tailwind-merge"; -/** - * Compose Tailwind classes, resolving conflicts last-wins. - * - * Falsy arguments are dropped exactly as before; what changed is that the result - * now goes through tailwind-merge, so a later class beats an earlier one instead - * of both being emitted and the generated stylesheet's order deciding. See - * `@/lib/tailwind-merge` for why the merge needs this repo's `@theme` scales - * declared to it, and what it silently deletes without them. - */ -export function cn(...classes: Array) { - return twMergeClinical(classes.filter(Boolean).join(" ")); -} +export { cn } from "@/lib/cn"; /** * The click handler for an `aria-disabled` placeholder — a control whose feature diff --git a/src/fonts/OFL.txt b/src/fonts/OFL.txt new file mode 100644 index 0000000000..60a71e14c0 --- /dev/null +++ b/src/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2024 Vercel, Inc. +Reserved Font Name: Geist + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/fonts/README.md b/src/fonts/README.md new file mode 100644 index 0000000000..81443ffcaa --- /dev/null +++ b/src/fonts/README.md @@ -0,0 +1,13 @@ +# Vendored Geist (latin) + +Latin-subset WOFF2 files used by `src/app/layout.tsx` through `next/font/local`. + +These files are the production source of truth. Do not load Geist from +`next/dist/next-devtools/server/font` — that path is a Next.js overlay +dev asset, not a production font contract. + +- `geist-latin.woff2` — body sans (preloaded) +- `geist-mono-latin.woff2` — mono (not preloaded) + +License: SIL Open Font License 1.1 (`OFL.txt`). Copyright 2024 Vercel, Inc. +Reserved Font Name: Geist. diff --git a/src/fonts/geist-latin.woff2 b/src/fonts/geist-latin.woff2 new file mode 100644 index 0000000000..445e0e55cb Binary files /dev/null and b/src/fonts/geist-latin.woff2 differ diff --git a/src/fonts/geist-mono-latin.woff2 b/src/fonts/geist-mono-latin.woff2 new file mode 100644 index 0000000000..68eeb7f4bd Binary files /dev/null and b/src/fonts/geist-mono-latin.woff2 differ diff --git a/src/lib/cn.ts b/src/lib/cn.ts new file mode 100644 index 0000000000..ee7d84a0e9 --- /dev/null +++ b/src/lib/cn.ts @@ -0,0 +1,14 @@ +import { twMergeClinical } from "@/lib/tailwind-merge"; + +/** + * Compose Tailwind classes, resolving conflicts last-wins. + * + * Falsy arguments are dropped exactly as before; what changed is that the result + * now goes through tailwind-merge, so a later class beats an earlier one instead + * of both being emitted and the generated stylesheet's order deciding. See + * `@/lib/tailwind-merge` for why the merge needs this repo's `@theme` scales + * declared to it, and what it silently deletes without them. + */ +export function cn(...classes: Array) { + return twMergeClinical(classes.filter(Boolean).join(" ")); +} diff --git a/tests/ckb-v2-token-contract.test.ts b/tests/ckb-v2-token-contract.test.ts index b2ad7873c2..7eb12e6c70 100644 --- a/tests/ckb-v2-token-contract.test.ts +++ b/tests/ckb-v2-token-contract.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { sourceFrom } from "./helpers/source-contract"; +import { sourceFrom, sourceSegment } from "./helpers/source-contract"; /** * Invariants of the scoped `.ckb-v2` token layer (`src/app/ckb-v2-tokens.css`). @@ -324,6 +324,23 @@ describe("ckb-v2 structure", () => { expect(structural.get("--text-hero--line-height")).toMatch(/^\d+(\.\d+)?$/); expect(structural.get("--text-hero-tr")).toMatch(/^-/); }); + + it("pins v2 --text-sm to @theme --text-sm-minus at 0.8125rem (PR 3c)", () => { + // Production `text-sm` is 13px because `.ckb-v2` is mounted on . + // The overlap with `--text-sm-minus` is the catalog. Do not "fix" it by + // restoring Tailwind's 14px, and do not alias the two files at each other: + // both layers keep the same literal so the documented source-of-truth + // order (v2 above the compat `@theme` block) stays intact. + const theme = declarations( + sourceSegment(globalsStylesheet, "\n@theme {", "\n}", { label: "globals.css `@theme`" }), + ); + const v2Sm = structural.get("--text-sm"); + const themeSmMinus = theme.get("--text-sm-minus"); + + expect(v2Sm).toBe("0.8125rem"); + expect(themeSmMinus).toBe("0.8125rem"); + expect(v2Sm).toBe(themeSmMinus); + }); }); describe("ckb-v2 category chip tones", () => { diff --git a/tests/css-delivery-contract.test.ts b/tests/css-delivery-contract.test.ts index 2fbbd7e102..46075c5813 100644 --- a/tests/css-delivery-contract.test.ts +++ b/tests/css-delivery-contract.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -27,8 +27,30 @@ describe("cold-load CSS and font delivery", () => { const geistSans = sourceSegment(rootLayout, "const geistSans", "const geistMono", { label: "geistSans font configuration", }); + const geistMono = sourceSegment(rootLayout, "const geistMono", "const baseMetadata", { + label: "geistMono font configuration", + }); + + expect(rootLayout).not.toContain("next-devtools"); + expect(rootLayout).not.toContain("node_modules/next/dist"); + expect(geistSans).toContain('src: "../fonts/geist-latin.woff2"'); expect(geistSans).toContain('display: "swap"'); + expect(geistSans).toContain('weight: "100 900"'); expect(geistSans).not.toContain("preload: false"); + + expect(geistMono).toContain('src: "../fonts/geist-mono-latin.woff2"'); + expect(geistMono).toContain('display: "swap"'); + expect(geistMono).toContain('weight: "100 900"'); + expect(geistMono).toContain("preload: false"); + + // AuthProvider must stay a static import. next/dynamic + ssr:false is illegal + // in this RSC root layout and would hide the session until hydration. + expect(rootLayout).toContain('import { AuthProvider } from "@/lib/supabase/client"'); + expect(rootLayout).not.toContain("ssr: false"); + expect(rootLayout).not.toMatch(/\bnext\/dynamic\b/); + + expect(existsSync(resolve(process.cwd(), "src/fonts/geist-latin.woff2"))).toBe(true); + expect(existsSync(resolve(process.cwd(), "src/fonts/geist-mono-latin.woff2"))).toBe(true); }); }); diff --git a/tests/design-system-adoption.test.ts b/tests/design-system-adoption.test.ts index 92b74996cc..25158e2798 100644 --- a/tests/design-system-adoption.test.ts +++ b/tests/design-system-adoption.test.ts @@ -1489,4 +1489,30 @@ describe("design-system adoption manifest", () => { expect(gates).toMatch(/Render `AnswerCard` without[\s\S]*implemented-blocking in `AnswerCard`/); expect(gates).toMatch(/Use a bare dash[\s\S]*implemented-partial — `AccessibleTable` composes `MissingValue`/); }); + + it("does not re-teach per-step type companions in SPEC, conventions, or GATES §4", () => { + const spec = read("docs/design-system/SPEC.md"); + const conventions = read(".design-sync/conventions.md"); + const gates = read("docs/design-system/GATES.md"); + const typeSection = spec.split("### 4.5 Type")[1]?.split("### 4.6")[0] ?? ""; + const evidenceSection = gates.split("## 4 · Recorded verification evidence")[1]?.split("###")[0] ?? ""; + + expect(spec).not.toContain("each with its own line-height"); + expect(spec).not.toContain(":194-204"); + expect(typeSection).toContain("--leading-prose"); + expect(typeSection).toContain("--text-hero--line-height"); + expect(conventions).not.toMatch(/per-step\s+line-height\s+and\s+tracking/); + expect(evidenceSection).toMatch(/raw colours 0\b/); + expect(evidenceSection).not.toMatch(/not re-run for this document set/); + + for (const doc of [typeSection, conventions]) { + for (const step of ["xs", "sm", "body", "md", "lg", "xl"] as const) { + expect(doc, `${step} -lh companion`).not.toContain(`--text-${step}-lh`); + expect(doc, `${step} -tr companion`).not.toContain(`--text-${step}-tr`); + expect(doc, `${step} --line-height companion`).not.toContain(`--text-${step}--line-height`); + } + expect(doc).toContain("--text-hero--line-height"); + expect(doc).toContain("--text-hero-tr"); + } + }); }); diff --git a/tests/tailwind-merge-config.test.ts b/tests/tailwind-merge-config.test.ts index 1c1784ef18..f619f22fba 100644 --- a/tests/tailwind-merge-config.test.ts +++ b/tests/tailwind-merge-config.test.ts @@ -171,3 +171,19 @@ describe("the config tracks globals.css", () => { expect(twMergeClinical("p-2 p-4")).toBe("p-4"); }); }); + +describe("cn module location", () => { + const recipesSource = readFileSync(join(process.cwd(), "src/components/primitive-recipes/recipes.ts"), "utf8"); + + it("lives in src/lib/cn.ts and is re-exported from the primitive recipes", async () => { + const libCnSource = readFileSync(join(process.cwd(), "src/lib/cn.ts"), "utf8"); + expect(libCnSource).toMatch(/export function cn\(/); + expect(libCnSource).toContain("twMergeClinical"); + expect(recipesSource).toMatch(/from ["\']@\/lib\/cn["\']/); + expect(recipesSource).toMatch(/export \{[\s\S]*?\bcn\b[\s\S]*?\}/); + expect(recipesSource).not.toMatch(/export function cn\(/); + + const lib = await import("@/lib/cn"); + expect(cn).toBe(lib.cn); + }); +});