Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
247 changes: 247 additions & 0 deletions apps/frontend/tests/unit/design/tokenDocParity.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Declaration> {
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<string, Declaration>();
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<string, string> {
const out = new Map<string, string>();
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<string, string> {
const out = new Map<string, string>();
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<string, string> {
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);
},
);
});
12 changes: 6 additions & 6 deletions docs-site/docs/reference/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |

Expand Down Expand Up @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 이 액션과 같은 색 패밀리로 읽힘. |

Expand Down Expand Up @@ -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. |
Expand Down
Loading