diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 8105a96d..dfb2c39a 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -67,7 +67,7 @@ --muted-foreground: 240 4% 44%; /* #6c6c75 */ /* Border / input outline — softer than slate-200, more neutral. */ - --border: 240 5% 91%; /* #e5e5ea */ + --border: 240 5% 91%; /* #e7e7e9 */ --input: 240 5% 91%; /* Primary CTA — Vercel-style warm near-black instead of navy. @@ -83,8 +83,14 @@ --accent-foreground: 240 6% 10%; /* Destructive — aligned with risk-critical hex so destructive - * buttons share the visual language of a Critical severity badge. */ - --destructive: 0 72% 51%; /* ~#dc2626 */ + * buttons share the visual language of a Critical severity badge. + * + * The lightness carries a decimal because 51 % renders #dc2828, and + * "aligned with --risk-critical" was then a claim the stylesheet did + * not keep — two reds two channels apart, which is exactly the + * "second, slightly different red" the status-surface section says + * this project does not want. 50.6 % is #dc2626 on the nose. */ + --destructive: 0 72% 50.6%; /* #dc2626 */ --destructive-foreground: 0 0% 98%; /* Focus ring — matches primary tone so the 2 px outline reads as diff --git a/apps/frontend/tests/unit/design/tokenDocParity.test.ts b/apps/frontend/tests/unit/design/tokenDocParity.test.ts new file mode 100644 index 00000000..7e535641 --- /dev/null +++ b/apps/frontend/tests/unit/design/tokenDocParity.test.ts @@ -0,0 +1,247 @@ +/** + * G0-8 — the design-system doc and the stylesheet must agree. + * + * `design-system.md` is named in CLAUDE.md as the single source of truth for + * the token catalogue, which means contributors read it instead of + * `index.css`. Nothing checked that the two said the same thing, and they + * did not: + * + * - G0-7 found `--risk-critical-foreground` and two siblings listed in the + * doc as though they had shipped in G0-1. They had never been declared. + * Every call site that "used" them was painting an inherited colour. + * - This test's first run found `--muted-foreground` documented as + * `#71717a` when the stylesheet had moved to `#6c6c75` (a W11 a11y fix + * the doc never heard about), `--border`/`--input` off by two channels, + * and `--destructive` claiming a hex that `0 72% 51%` does not produce. + * + * None of those are visible in review: a token table and a `:root` block are + * never in the same diff, and both look right in isolation. That is the same + * shape as CLAUDE.md hardening rule #2 — the same vocabulary in two places + * needs a parity assertion, or each side stays green while the pair drifts. + * + * Three directions are checked, and they are not redundant: + * + * doc -> css a documented token must exist and hold the documented + * value. Catches vocabulary that was only ever promised. + * css -> doc a token in a family this project owns must be documented. + * Catches colours shipped without an entry, which is how a + * contributor ends up inventing a second one for the same + * job. + * EN <-> KO the mirrors must carry identical token tables. A value + * corrected in one language and not the other is drift that + * reads as authoritative in both. + * + * Prose is deliberately out of scope — only the tables are parsed. Holding + * translated sentences to equality would make the gate argue about wording, + * and a gate that argues about wording gets switched off. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..", ".."); +const CSS_PATH = path.join( + REPO_ROOT, + "apps/frontend/src/index.css", +); +const DOC_EN = path.join( + REPO_ROOT, + "docs-site/docs/reference/design-system.md", +); +const DOC_KO = path.join( + REPO_ROOT, + "docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/design-system.md", +); + +/** + * Families this project owns and therefore must document. The shadcn base + * set is upstream's contract and the layout/motion tokens are dimensions + * rather than colours; both are checked in the doc -> css direction (if the + * doc names one, it must be right) but not required to appear. + * + * Same boundary `tokenConsumers.test.ts` draws, for the same reason: these + * are the vocabularies a contributor picks from when naming a new surface. + */ +const OWNED = [/^risk-/, /^status-/, /^brand(-|$)/, /^topbar(-|$)/]; + +/** `240 4% 44%` and `0 72% 50.6%` alike — HSL as shadcn stores it. */ +const HSL_TRIPLE = /^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/; + +function hslToHex(h: number, s: number, l: number): string { + const sat = s / 100; + const lig = l / 100; + const k = (n: number) => (n + h / 30) % 12; + const a = sat * Math.min(lig, 1 - lig); + const f = (n: number) => + lig - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + const channel = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0"); + return `#${channel(f(0))}${channel(f(8))}${channel(f(4))}`; +} + +interface Declaration { + /** As written in the stylesheet — `#dc2626` or `0 72% 50.6%`. */ + raw: string; + /** Resolved to hex, or null for non-colour values (px, ms, easing). */ + hex: string | null; + /** The `/* #xxxxxx *\/` note beside an HSL triple, if there is one. */ + commentHex: string | null; +} + +/** `:root` declarations, up to the (commented-out) `.dark` block. */ +function cssTokens(): Map { + const css = fs.readFileSync(CSS_PATH, "utf8"); + const start = css.indexOf(":root {"); + const end = css.indexOf(".dark {", start); + const block = css.slice(start, end === -1 ? undefined : end); + + const out = new Map(); + for (const line of block.split("\n")) { + const m = line.match( + /^\s*--([a-z0-9-]+):\s*([^;]+);(?:\s*\/\*\s*(#[0-9a-fA-F]{6}))?/, + ); + if (!m) continue; + const [, name, rawValue, comment] = m; + const raw = rawValue.trim(); + let hex: string | null = null; + if (/^#[0-9a-fA-F]{6}$/.test(raw)) hex = raw.toLowerCase(); + else { + const hsl = raw.match(HSL_TRIPLE); + if (hsl) hex = hslToHex(+hsl[1], +hsl[2], +hsl[3]); + } + out.set(name, { raw, hex, commentHex: comment?.toLowerCase() ?? null }); + } + return out; +} + +/** + * Token rows in a markdown table: `| `--token` | `#hex` | …`. + * + * The status family is spelled differently — one row per state, one column + * per suffix — so it gets its own pass below rather than a looser regex + * here. A looser regex would also swallow the contrast tables, whose first + * column is an expression (`--muted-foreground` on `--card`) and not a + * declaration. + */ +function docTokens(file: string): Map { + const out = new Map(); + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + const m = line.match(/^\|\s*`--([a-z0-9-]+)`\s*\|\s*`(#[0-9a-fA-F]{6})`/); + if (m) out.set(m[1], m[2].toLowerCase()); + } + return out; +} + +/** + * The status table: `| `success` | `#059669` | `#ecfdf5` | … |`, columns in + * suffix order, `—` where a solid is deliberately not declared. + * + * Anchored on its header row rather than on the state names, because the + * severity-accessibility section further down also opens rows with + * `| `info` |` and carries a hex of its own. Matching on the names alone + * mapped that row's `#52525b` onto `--status-info-subtle` — a parity gate + * reporting a drift that existed only in its own parser. + */ +const STATUS_HEADER = /^\|\s*Status\s*\|\s*Solid\s*\|\s*Subtle\s*\|\s*Border\s*\|\s*Foreground\s*\|/; +const STATUS_SUFFIXES = ["", "-subtle", "-border", "-foreground"] as const; + +function docStatusTokens(file: string): Map { + const out = new Map(); + const lines = fs.readFileSync(file, "utf8").split("\n"); + const header = lines.findIndex((l) => STATUS_HEADER.test(l)); + if (header === -1) return out; + + // Header, separator, then rows until the table ends. + for (const line of lines.slice(header + 2)) { + if (!line.startsWith("|")) break; + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + const state = cells[0]?.match(/^`([a-z]+)`$/)?.[1]; + if (!state) continue; + STATUS_SUFFIXES.forEach((suffix, i) => { + const hex = cells[i + 1]?.match(/^`(#[0-9a-fA-F]{6})`$/); + if (hex) out.set(`status-${state}${suffix}`, hex[1].toLowerCase()); + }); + } + return out; +} + +function allDocTokens(file: string): Map { + return new Map([...docTokens(file), ...docStatusTokens(file)]); +} + +describe("design-system doc / stylesheet parity", () => { + const css = cssTokens(); + const en = allDocTokens(DOC_EN); + const ko = allDocTokens(DOC_KO); + + it("parses both sides", () => { + // Without this the suite passes vacuously the day a heading changes and + // a regex stops matching — the failure mode every table-scraping test + // has, and the reason the numbers are asserted rather than assumed. + expect(css.size).toBeGreaterThan(50); + expect(en.size).toBeGreaterThan(30); + expect(ko.size).toBeGreaterThan(30); + }); + + it.each([...en.keys()].sort())( + "--%s is documented and declared with the same value", + (name) => { + const declared = css.get(name); + expect( + declared, + `--${name} appears in design-system.md but is not declared in ` + + `index.css. Declare it or drop the row: the doc is what people ` + + `read before writing a call site.`, + ).toBeDefined(); + + expect( + declared!.hex, + `--${name} is documented with a hex but its declaration ` + + `(${declared!.raw}) does not resolve to a colour.`, + ).not.toBeNull(); + + expect( + declared!.hex, + `--${name}: the doc says ${en.get(name)}, index.css renders ` + + `${declared!.hex} (from \`${declared!.raw}\`). Whichever is ` + + `wrong, they cannot both stand — the doc is the catalogue and ` + + `the stylesheet is what ships.`, + ).toBe(en.get(name)); + }, + ); + + it.each( + [...css.keys()].filter((name) => OWNED.some((rx) => rx.test(name))).sort(), + )("--%s is in a family this project owns, so it must be documented", (name) => { + expect( + en.has(name), + `--${name} is declared in index.css but no table in ` + + `design-system.md lists it. An undocumented token in an owned ` + + `family is how the next contributor ends up inventing a second ` + + `token for the same job.`, + ).toBe(true); + }); + + it("the Korean mirror carries the same token table", () => { + expect(Object.fromEntries([...ko].sort())).toEqual( + Object.fromEntries([...en].sort()), + ); + }); + + it.each([...css].filter(([, v]) => v.commentHex !== null))( + "--%s's hex comment matches the value it annotates", + (_name, decl) => { + expect( + decl.hex, + `the comment says ${decl.commentHex}, but \`${decl.raw}\` renders ` + + `${decl.hex}. The comment is what a reader trusts when scanning ` + + `the block, so a stale one is worse than none.`, + ).toBe(decl.commentHex); + }, + ); +}); diff --git a/docs-site/docs/reference/design-system.md b/docs-site/docs/reference/design-system.md index d3a815f0..0df363ee 100644 --- a/docs-site/docs/reference/design-system.md +++ b/docs-site/docs/reference/design-system.md @@ -60,12 +60,12 @@ All colour decisions reference the CSS custom properties declared in `index.css` | `--card` | `#ffffff` | `0 0% 100%` | Elevated surfaces — cards, popovers, drawer body, tooltip. | | `--foreground` | `#18181b` | `240 6% 10%` | Body text. Warm near-black, not navy. | | `--muted` | `#f4f4f5` | `240 5% 96%` | Subtle fills — table headers, sidebar tint, placeholder backgrounds, disabled inputs. | -| `--muted-foreground` | `#71717a` | `240 4% 46%` | Secondary text, captions, table column headers. | -| `--border` | `#e5e5ea` | `240 5% 91%` | Hairline borders. Decorative separator only — never the sole means of identifying a UI region. | -| `--input` | `#e5e5ea` | `240 5% 91%` | Input outline. | +| `--muted-foreground` | `#6c6c75` | `240 4% 44%` | Secondary text, captions, table column headers. | +| `--border` | `#e7e7e9` | `240 5% 91%` | Hairline borders. Decorative separator only — never the sole means of identifying a UI region. | +| `--input` | `#e7e7e9` | `240 5% 91%` | Input outline. | | `--primary` | `#18181b` | `240 6% 10%` | Primary CTA — "the important action on the page". | | `--primary-foreground` | `#fafafa` | `0 0% 98%` | Text on primary. | -| `--destructive` | `#dc2626` | `0 72% 51%` | Destructive CTA. Aligned with `--risk-critical` so destructive buttons share severity-badge visual language. | +| `--destructive` | `#dc2626` | `0 72% 50.6%` | Destructive CTA. Aligned with `--risk-critical` so destructive buttons share severity-badge visual language. The decimal is load-bearing — `51%` renders `#dc2828`, which is not that hex. | | `--destructive-foreground` | `#fafafa` | `0 0% 98%` | Text on destructive. | | `--ring` | `#18181b` | `240 6% 10%` | Focus ring. Matches primary so the outline reads as "the same action this is". | @@ -408,8 +408,8 @@ The portal targets **WCAG 2.1 Level AA**. Three policies make this concrete. |---|---|---| | `--foreground` on `--background` | 16.97:1 | Body text. AAA. | | `--foreground` on `--card` | 17.72:1 | Body text on card. AAA. | -| `--muted-foreground` on `--background` | 4.63:1 | Captions, secondary text. AA. | -| `--muted-foreground` on `--card` | 4.83:1 | Captions on card. AA. | +| `--muted-foreground` on `--background` | 4.98:1 | Captions, secondary text. AA. | +| `--muted-foreground` on `--card` | 5.20:1 | Captions on card. AA. | | `--primary-foreground` on `--primary` | 16.97:1 | Primary button label. AAA. | | `--destructive-foreground` on `--destructive` | 4.63:1 | Destructive button label. AA. | | `--ring` on `--background` | 16.97:1 | Focus ring. AAA. | diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/design-system.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/design-system.md index 7adc5edc..1efaebe4 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/design-system.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/design-system.md @@ -60,12 +60,12 @@ Severity 팔레트 (Critical / High / Medium / Low / Info) 는 **의도적으로 | `--card` | `#ffffff` | `0 0% 100%` | Elevated surface — 카드 · popover · 드로어 본문 · 툴팁. | | `--foreground` | `#18181b` | `240 6% 10%` | 본문 텍스트. Warm near-black (navy 아님). | | `--muted` | `#f4f4f5` | `240 5% 96%` | 미묘한 fill — 테이블 헤더 · 사이드바 tint · placeholder · disabled input. | -| `--muted-foreground` | `#71717a` | `240 4% 46%` | 보조 텍스트 · caption · 테이블 컬럼 헤더. | -| `--border` | `#e5e5ea` | `240 5% 91%` | Hairline border. 장식용 separator 만 — UI 영역 식별의 유일한 수단이 되지 않습니다. | -| `--input` | `#e5e5ea` | `240 5% 91%` | Input outline. | +| `--muted-foreground` | `#6c6c75` | `240 4% 44%` | 보조 텍스트 · caption · 테이블 컬럼 헤더. | +| `--border` | `#e7e7e9` | `240 5% 91%` | Hairline border. 장식용 separator 만 — UI 영역 식별의 유일한 수단이 되지 않습니다. | +| `--input` | `#e7e7e9` | `240 5% 91%` | Input outline. | | `--primary` | `#18181b` | `240 6% 10%` | Primary CTA — "이 페이지의 중요 액션". | | `--primary-foreground` | `#fafafa` | `0 0% 98%` | Primary 위 텍스트. | -| `--destructive` | `#dc2626` | `0 72% 51%` | Destructive CTA. `--risk-critical` 와 같아서 destructive 버튼이 severity 와 같은 시각 언어. | +| `--destructive` | `#dc2626` | `0 72% 50.6%` | Destructive CTA. `--risk-critical` 와 같아서 destructive 버튼이 severity 와 같은 시각 언어. 소수점이 의미를 갖습니다 — `51%` 는 `#dc2828` 로 렌더되어 그 hex 가 아닙니다. | | `--destructive-foreground` | `#fafafa` | `0 0% 98%` | Destructive 위 텍스트. | | `--ring` | `#18181b` | `240 6% 10%` | Focus ring. Primary 와 매칭 — outline 이 액션과 같은 색 패밀리로 읽힘. | @@ -410,8 +410,8 @@ W11-F polish phase 가 모든 인터랙티브 transition 의 타이밍 · easing |---|---|---| | `--foreground` on `--background` | 16.97:1 | 본문. AAA. | | `--foreground` on `--card` | 17.72:1 | 카드 위 본문. AAA. | -| `--muted-foreground` on `--background` | 4.63:1 | Caption · 보조 텍스트. AA. | -| `--muted-foreground` on `--card` | 4.83:1 | 카드 위 caption. AA. | +| `--muted-foreground` on `--background` | 4.98:1 | Caption · 보조 텍스트. AA. | +| `--muted-foreground` on `--card` | 5.20:1 | 카드 위 caption. AA. | | `--primary-foreground` on `--primary` | 16.97:1 | Primary 버튼 라벨. AAA. | | `--destructive-foreground` on `--destructive` | 4.63:1 | Destructive 버튼 라벨. AA. | | `--ring` on `--background` | 16.97:1 | Focus ring. AAA. |