diff --git a/apps/frontend/scripts/token-lint-baseline.json b/apps/frontend/scripts/token-lint-baseline.json index 696a1dd7..837e1e2a 100644 --- a/apps/frontend/scripts/token-lint-baseline.json +++ b/apps/frontend/scripts/token-lint-baseline.json @@ -1,6 +1,6 @@ { "src/components/DemoBanner.tsx": 4, - "src/components/ui/badge.tsx": 9, + "src/components/ui/badge.tsx": 4, "src/components/ui/toast.tsx": 3, "src/features/admin/backup/AdminBackupPage.tsx": 7, "src/features/admin/disk/AdminDiskPage.tsx": 3, @@ -27,9 +27,8 @@ "src/features/projects/components/SettingsTab.tsx": 4, "src/features/projects/components/VulnerabilityStatusBadge.tsx": 3, "src/features/scan/SbomConformancePanel.tsx": 2, - "src/features/scan/ScanProgress.tsx": 3, + "src/features/scan/ScanProgress.tsx": 2, "src/features/scan/ToolLogLine.tsx": 6, - "src/features/scans/ScanCancelButton.tsx": 1, "src/pages/auth/DemoCredentialsHint.tsx": 12, "src/pages/auth/LoginPage.tsx": 4, "src/pages/dev/BrandCandidates.tsx": 5 diff --git a/apps/frontend/src/components/ui/badge.tsx b/apps/frontend/src/components/ui/badge.tsx index c6496cb2..4e859c7d 100644 --- a/apps/frontend/src/components/ui/badge.tsx +++ b/apps/frontend/src/components/ui/badge.tsx @@ -36,31 +36,33 @@ const badgeVariants = cva( }, tone: { none: "", - // Risk tones — W11-H (a11y sweep). + // Risk tones — W11-H (a11y sweep), retinted in G0-7. // - // Background uses the `--risk-X` token at low alpha so the chip reads - // as a coloured tint while preserving brand semantics. The text colour - // is intentionally a darker shade of the SAME hue family from the - // Tailwind palette so the rendered text/tint contrast clears - // WCAG AA 4.5:1 — `text-risk-X` (the raw token) measured 2.5 ~ 4.5:1 - // on the blended tint and was failing AA. Measurements (alpha-blended - // against #ffffff card surface): + // Background uses the `--risk-X` token at the standard /10 tint so the + // chip reads as coloured while preserving brand semantics. The text is + // a darker shade of the SAME hue family, because `text-risk-X` (the + // severity hex itself) measures 2.5 ~ 4.5:1 on that tint and fails AA. // - // critical text-red-700 → 5.54:1 - // high text-orange-800 → 6.47:1 - // medium text-yellow-800 → 5.91:1 - // low text-blue-700 → 5.83:1 - // info text-slate-600 → 6.41:1 + // W11-H spelled those darker shades as raw palette classes + // (`text-red-700`) and wrote the measurements in this comment. G0-7 + // moved them into `--risk-X-foreground` tokens: the same hexes, but + // now named, reused by every other tinted surface, and measured by + // `statusTokenContrast.test.ts` instead of by a comment. The tones + // below also carry the medium/info tint down from /15 to the single + // /10 step — until G0-7 no risk tint painted at all, so the two + // densities were a distinction nobody had ever seen. // // The dot indicators (SeverityBadge / DependencyScopeBadge / chart // legends) still use `bg-risk-X` raw so the brand colour stays // recognisable — only the text shade is darkened. Token values in // `index.css` are NOT changed (W11 prohibition: "Severity 색 변경 0"). - critical: "border-transparent bg-risk-critical/10 text-red-700", - high: "border-transparent bg-risk-high/10 text-orange-800", - medium: "border-transparent bg-risk-medium/15 text-yellow-800", - low: "border-transparent bg-risk-low/10 text-blue-700", - info: "border-transparent bg-risk-info/15 text-slate-600", + critical: + "border-transparent bg-risk-critical/10 text-risk-critical-foreground", + high: "border-transparent bg-risk-high/10 text-risk-high-foreground", + medium: + "border-transparent bg-risk-medium/10 text-risk-medium-foreground", + low: "border-transparent bg-risk-low/10 text-risk-low-foreground", + info: "border-transparent bg-risk-info/10 text-risk-info-foreground", success: "border-transparent bg-emerald-100 text-emerald-700", }, }, diff --git a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx index 9fd849c9..8e07550b 100644 --- a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx +++ b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx @@ -34,14 +34,15 @@ const BUCKETS: Bucket[] = ["critical", "high", "medium", "low"]; /** Tint + dot per bucket. * - * The `risk-tint-*` classes are declared in `index.css` rather than written - * as `bg-risk-critical/10` here: Tailwind emits no rule for an opacity - * modifier on a `var()` colour, so that spelling paints nothing at all. */ + * W16 wrote these as `risk-tint-*` classes hand-declared in `index.css`, + * because `bg-risk-critical/10` emitted no rule and painted nothing. G0-7 + * fixed that in `tailwind.config.ts`, so the ordinary spelling is back and + * the bespoke classes are gone. */ const CELL_TONE: Record = { - critical: "risk-tint-critical", - high: "risk-tint-high", - medium: "risk-tint-medium", - low: "risk-tint-low", + critical: "border-risk-critical/40 bg-risk-critical/10", + high: "border-risk-high/40 bg-risk-high/10", + medium: "border-risk-medium/40 bg-risk-medium/10", + low: "border-risk-low/40 bg-risk-low/10", clean: "border-border bg-card", // Deliberately not a risk tint and deliberately not the clean surface: an // unmeasured project is its own state, and dashed edges say "no data" in a diff --git a/apps/frontend/src/features/projects/components/KevBadge.tsx b/apps/frontend/src/features/projects/components/KevBadge.tsx index 9239d1b0..0389289e 100644 --- a/apps/frontend/src/features/projects/components/KevBadge.tsx +++ b/apps/frontend/src/features/projects/components/KevBadge.tsx @@ -68,10 +68,10 @@ function dueVisuals(state: DueDateStatus["state"] | null): { return { badge: "bg-risk-critical text-white", dot: "bg-white" }; } if (state === "imminent") { - // Amber — mirrors the Badge `medium` tone pair (risk-medium tint + - // yellow-800 text, the AA-audited combination from W11-H). + // Amber — mirrors the Badge `medium` tone pair (risk-medium tint at the + // standard /10 step + its foreground token, the AA-audited combination). return { - badge: "bg-risk-medium/15 text-risk-medium-foreground", + badge: "bg-risk-medium/10 text-risk-medium-foreground", dot: "bg-risk-medium", }; } diff --git a/apps/frontend/src/features/projects/components/LicenseDrawer.tsx b/apps/frontend/src/features/projects/components/LicenseDrawer.tsx index 47803231..9677b957 100644 --- a/apps/frontend/src/features/projects/components/LicenseDrawer.tsx +++ b/apps/frontend/src/features/projects/components/LicenseDrawer.tsx @@ -259,7 +259,7 @@ function DrawerReviewFlagSection({ flag }: ReviewFlagSectionProps) { const { t } = useTranslation("project_detail"); return (
diff --git a/apps/frontend/src/features/projects/components/SandboxScanNotice.tsx b/apps/frontend/src/features/projects/components/SandboxScanNotice.tsx index 19ee9985..7b4d56e0 100644 --- a/apps/frontend/src/features/projects/components/SandboxScanNotice.tsx +++ b/apps/frontend/src/features/projects/components/SandboxScanNotice.tsx @@ -31,19 +31,24 @@ export function SandboxScanNotice({ onUploadSbom }: SandboxScanNoticeProps) {
+ {/* G0-7 — the secondary lines below are `text-foreground`, not the + muted grey they read as. The notice sits on the page ground + (#fafafa) where the muted token clears AA by 0.03, and the tint + this banner now actually paints takes it under. Size and weight + still carry the hierarchy. */}

{t("sandbox.title")}

-

+

{t("sandbox.live_limit", { max: DEMO_SANDBOX_SCAN_MAX_MB })}

-

+

{/* ICU-safe: the BomLens link is a placeholder slot, not a concatenated string, so translators keep the sentence intact. */} ), @@ -66,7 +71,7 @@ export function SandboxScanNotice({ onUploadSbom }: SandboxScanNoticeProps) { project + demo account, and uploads are visible to other visitors until the periodic reset. Warn before anyone uploads. */}

diff --git a/apps/frontend/src/features/projects/components/SourceFileViewer.tsx b/apps/frontend/src/features/projects/components/SourceFileViewer.tsx index 9afbf401..10575b81 100644 --- a/apps/frontend/src/features/projects/components/SourceFileViewer.tsx +++ b/apps/frontend/src/features/projects/components/SourceFileViewer.tsx @@ -161,8 +161,9 @@ export function SourceFileViewer({ {data.truncated ? (

@@ -266,14 +267,14 @@ function SourceLineRow({ line }: SourceLineRowProps) { aria-hidden className={cn( "w-12 shrink-0 select-none border-r pr-2 text-right tabular-nums text-muted-foreground", - highlighted && "border-risk-low/40 text-risk-low", + highlighted && "border-risk-low/40 text-risk-low-foreground", )} > {line.number} {highlighted ? ( diff --git a/apps/frontend/src/features/scan/SbomIngestDialog.tsx b/apps/frontend/src/features/scan/SbomIngestDialog.tsx index 2934b0b2..34ebfd32 100644 --- a/apps/frontend/src/features/scan/SbomIngestDialog.tsx +++ b/apps/frontend/src/features/scan/SbomIngestDialog.tsx @@ -216,7 +216,7 @@ export function SbomIngestDialog({ {/* M-3 — shared public sandbox warning, shown directly above the submit button so it is unmissable right before an upload. */}

diff --git a/apps/frontend/src/features/scan/ScanProgress.tsx b/apps/frontend/src/features/scan/ScanProgress.tsx index 8a9086b7..926b6c0a 100644 --- a/apps/frontend/src/features/scan/ScanProgress.tsx +++ b/apps/frontend/src/features/scan/ScanProgress.tsx @@ -222,9 +222,10 @@ export function ScanProgress({ {cachedFromDtDown ? ( @@ -241,7 +242,7 @@ export function ScanProgress({ = { scancode: "border-amber-500/30 bg-amber-500/10 text-amber-700", // W6-#43f: Trivy is the vulnerability scanner — colour the badge with the // risk-high token so it reads as "security tool" in the unified log stream. - trivy: "border-risk-high/30 bg-risk-high/10 text-risk-high", + // G0-7: the tint behind this badge paints for the first time, and the + // severity hex is not readable on it (3.05:1) — hence the foreground token. + trivy: "border-risk-high/40 bg-risk-high/10 text-risk-high-foreground", }; export interface ToolLogLineProps { @@ -42,12 +44,22 @@ export function ToolLogLine({ msg }: ToolLogLineProps) {

  • - + {msg.ts.slice(11, 19)} {isErr ? ( err @@ -69,7 +81,7 @@ export function ToolLogLine({ msg }: ToolLogLineProps) { {msg.line} diff --git a/apps/frontend/src/features/scans/ScanCancelButton.tsx b/apps/frontend/src/features/scans/ScanCancelButton.tsx index b8ec6559..d0a42321 100644 --- a/apps/frontend/src/features/scans/ScanCancelButton.tsx +++ b/apps/frontend/src/features/scans/ScanCancelButton.tsx @@ -84,7 +84,7 @@ export function ScanCancelButton({ {confirming ? (
    diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 43c5314e..8105a96d 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -134,6 +134,20 @@ * Risk severity (unchanged — domain meaning is fixed). * Used directly by recharts / lucide as raw hex, also exposed as * Tailwind `text-risk-critical` / `bg-risk-high/10` etc. + * + * The hex stays a hex here because charts read `var(--risk-critical)` + * straight. The opacity modifier (`/10`, `/20`, `/40`) is what + * `tailwind.config.ts` has to work for: it wraps each of these in a + * `color-mix()` carrying Tailwind's ``, because a bare + * `var()` gives Tailwind no channels to divide and it then emits no + * rule at all for the slashed class. See the note in that file — it + * is the whole reason `bg-risk-critical/10` painted nothing until + * G0-7. + * + * The alpha scale is three steps, and only three: + * /10 tinted surface — the default + * /20 the same surface emphasised (hover, or a chip nested on /10) + * /40 border on either * ----------------------------------------------------------------- */ --risk-critical: #dc2626; --risk-high: #ea580c; @@ -142,28 +156,38 @@ --risk-info: #71717a; /* ------------------------------------------------------------------- - * Risk tint foregrounds (G0-1). + * Risk tint foregrounds (G0-1, completed in G0-7). * * The severity hexes above are chart/dot fills. Text placed ON a risk - * tint (`bg-risk-medium/15` and friends) needs its own darker shade — - * without one, call sites reached for raw palette classes and each - * picked a different one (`text-yellow-800` here, `text-amber-900` - * there). These tokens close that gap: same hue family, AA-verified - * against the tint composited over white. + * tint needs its own darker shade — without one, call sites reached + * for raw palette classes and each picked a different one + * (`text-yellow-800` here, `text-amber-900` there). These tokens close + * that gap: same hue family, AA-verified against the tint composited + * over the page canvas. * - * Only the shades something actually paints with are declared — - * `tokenConsumers.test.ts` fails on a token nobody uses, because an - * unused colour is vocabulary whose contrast has never been checked - * against a real surface. Add the others back when a call site needs - * them. + * All five shades are declared now because all five have a call site: + * G0-7 made the tints paint for the first time, and the severity hex + * itself is not readable on its own tint (`text-risk-high` on + * `bg-risk-high/10` measures 3.05:1). Every place that used to write + * the severity hex as text over a tint now writes the foreground. * - * Contrast on `bg-risk-X/15` over #ffffff (asserted in + * Contrast on `bg-risk-X/20` over --background (the densest tint on + * the darker of the two surfaces — worst case; asserted in * tests/unit/design/statusTokenContrast.test.ts): - * medium 5.90 · low 5.44 + * critical 4.55 · high 5.51 · medium 5.43 · low 4.86 · info 5.80 + * + * The three shades added here are not new values. `design-system.md` had + * been listing all five as though they were declared since G0-1 — these + * are the hexes it named, moved out of the doc and into the stylesheet, + * which is the direction that drift has to be resolved in. * ----------------------------------------------------------------- */ + --risk-critical-foreground: #b91c1c; + --risk-high-foreground: #9a3412; --risk-medium-foreground: #854d0e; --risk-low-foreground: #1d4ed8; - + --risk-info-foreground: #52525b; + + /* ------------------------------------------------------------------- * Status surfaces (G0-1). * @@ -350,39 +374,18 @@ } /* --------------------------------------------------------------------------- - * Risk tints (W16). + * Risk tints — the four `.risk-tint-*` classes that stood here (W16) are gone. * - * `bg-risk-critical/10` does not exist. Tailwind applies an opacity modifier - * only to colours whose channels it can parse, and these tokens hold a - * `var()` it cannot see into, so it emits NO rule at all for the slashed - * class — the element falls back to transparent and the tint silently never - * paints. The token comments above, and the design-system doc, describe - * `bg-risk-X/15` as though it worked. It does not, and the call sites across - * `src/` written in that spelling are painting nothing today. Repairing them - * is its own change (it moves pixels on a dozen screens and needs fresh - * baselines); what matters here is that new code does not add to the pile. + * They existed because `bg-risk-critical/10` painted nothing: Tailwind applies + * an opacity modifier only to colours whose channels it can parse, these + * tokens held a `var()` it could not see into, and it emitted no rule at all + * for the slashed class. Hand-written `color-mix` classes worked around that + * for one caller while twenty-six call sites elsewhere kept rendering + * transparent. * - * `color-mix` does what the slash was meant to do, in one place, with the - * severity hex still the single source of the hue. Text on these stays - * `--foreground`: near-black on a 10 % tint over white is far above AA, so - * no per-severity text token is needed. + * G0-7 fixed the mechanism instead, in `tailwind.config.ts`: the same + * `color-mix` now carries Tailwind's ``, so the spelling + * everybody was already writing works — including the variants a bespoke + * class cannot reach (`hover:bg-risk-low/20`). One mechanism, no second + * vocabulary to remember. * ------------------------------------------------------------------------- */ -.risk-tint-critical { - background-color: color-mix(in srgb, var(--risk-critical) 10%, transparent); - border-color: color-mix(in srgb, var(--risk-critical) 40%, transparent); -} - -.risk-tint-high { - background-color: color-mix(in srgb, var(--risk-high) 10%, transparent); - border-color: color-mix(in srgb, var(--risk-high) 40%, transparent); -} - -.risk-tint-medium { - background-color: color-mix(in srgb, var(--risk-medium) 12%, transparent); - border-color: color-mix(in srgb, var(--risk-medium) 45%, transparent); -} - -.risk-tint-low { - background-color: color-mix(in srgb, var(--risk-low) 8%, transparent); - border-color: color-mix(in srgb, var(--risk-low) 35%, transparent); -} diff --git a/apps/frontend/tailwind.config.ts b/apps/frontend/tailwind.config.ts index 7c36f66e..7b6240a7 100644 --- a/apps/frontend/tailwind.config.ts +++ b/apps/frontend/tailwind.config.ts @@ -23,6 +23,19 @@ import animate from "tailwindcss-animate"; * Dark mode is wired up (`darkMode: ["class"]`) but no `.dark` tokens are * populated in W11 — v2.5+ trail. Components should NOT use `dark:` here. */ +/** + * Wrap each `--risk-*` custom property so Tailwind's opacity modifier reaches + * it. See the `risk:` block below for why a bare `var()` cannot be used. + */ +function riskTint(tokens: Record): Record { + return Object.fromEntries( + Object.entries(tokens).map(([key, cssVar]) => [ + key, + `color-mix(in srgb, var(${cssVar}) calc( * 100%), transparent)`, + ]), + ); +} + const config: Config = { content: ["./index.html", "./src/**/*.{ts,tsx}"], darkMode: ["class"], @@ -74,16 +87,42 @@ const config: Config = { }, // Risk severity tokens (unchanged — domain semantics fixed). // `foreground` variants (G0-1) are for text ON a risk tint, e.g. - // `bg-risk-medium/15 text-risk-medium-foreground`. - risk: { - critical: "var(--risk-critical)", - high: "var(--risk-high)", - medium: "var(--risk-medium)", - "medium-foreground": "var(--risk-medium-foreground)", - low: "var(--risk-low)", - "low-foreground": "var(--risk-low-foreground)", - info: "var(--risk-info)", - }, + // `bg-risk-medium/10 text-risk-medium-foreground`. + // + // G0-7 — why these are wrapped in `color-mix` rather than written as + // a bare `var()`. + // + // Tailwind implements `bg-risk-high/10` by splitting the colour into + // channels and re-emitting it with an alpha. A bare `var(--risk-high)` + // gives it nothing to split, so it does not emit a degraded rule — it + // emits NO rule for the slashed class, and every element written that + // way falls back to transparent. That was true of 27 call sites across + // 13 files until G0-7, in a spelling the design-system doc described + // as working. + // + // `` is the documented hook: Tailwind substitutes the + // modifier (or `1` when there is none) into whatever expression is + // here, so `color-mix` composites the severity hex against + // `transparent` at exactly the requested alpha. The hex in + // `index.css` stays the single source of the hue — charts still read + // it raw — and the whole `risk` family is covered, including the + // `-foreground` shades, so no member of it can regress to a + // silently-dropped rule. + // + // `riskTintOpacity.test.ts` compiles this config and fails if any + // step stops emitting a rule. + risk: riskTint({ + critical: "--risk-critical", + "critical-foreground": "--risk-critical-foreground", + high: "--risk-high", + "high-foreground": "--risk-high-foreground", + medium: "--risk-medium", + "medium-foreground": "--risk-medium-foreground", + low: "--risk-low", + "low-foreground": "--risk-low-foreground", + info: "--risk-info", + "info-foreground": "--risk-info-foreground", + }), // Brand accent (W14) — product identity, applied to a whitelist. brand: { DEFAULT: "var(--brand)", diff --git a/apps/frontend/tests/a11y/a11y.spec.ts b/apps/frontend/tests/a11y/a11y.spec.ts index f6d38c81..05adde38 100644 --- a/apps/frontend/tests/a11y/a11y.spec.ts +++ b/apps/frontend/tests/a11y/a11y.spec.ts @@ -71,8 +71,36 @@ export interface ScreenScan { targets: Record; } +/** + * Wait until every finite animation on the page has finished. + * + * The routed subtree fades in over `duration-slow` (AppShell.tsx). axe folds + * an ancestor's opacity into the foreground colour it measures, so a scan + * that lands inside those 250ms reads the muted grey as #7c7c84 rather than + * #6c6c75 and reports a hundred contrast violations that do not exist once + * the frame settles. Every screen's `visit` waits for its own content, but + * content being mounted and the fade being over are different moments. + * + * It was a race, so it surfaced as a different set of screens each run — + * which is the shape of a gate that eventually gets muted rather than + * believed. Infinite animations (the skeleton pulse, spinners) are excluded + * because waiting on those never returns; they are also the only animations + * whose mid-flight frame IS the steady state, so their contrast is worth + * measuring where it stands. + */ +async function settle(page: Page): Promise { + await page.waitForFunction(() => + document + .getAnimations() + .filter((a) => a.effect?.getComputedTiming().iterations !== Infinity) + .every((a) => a.playState === "finished" || a.playState === "idle"), + ); +} + /** Violations for one screen. */ async function scan(page: Page): Promise { + await settle(page); + const results = await new AxeBuilder({ page }) .withTags(TAGS) // The devtools launcher is dev-server-only chrome that ships to nobody; diff --git a/apps/frontend/tests/unit/design/riskTintOpacity.test.ts b/apps/frontend/tests/unit/design/riskTintOpacity.test.ts new file mode 100644 index 00000000..134fffd1 --- /dev/null +++ b/apps/frontend/tests/unit/design/riskTintOpacity.test.ts @@ -0,0 +1,69 @@ +/** + * G0-7 — the opacity modifier on a risk token must actually emit a rule. + * + * The defect this guards against produced no error, no warning, and no + * visible diff in review. `bg-risk-critical/10` was written at 27 call sites + * across 13 files, and the design-system doc described that spelling as the + * way to tint a surface. Tailwind implements the modifier by splitting the + * colour into channels and re-emitting it with an alpha; the tokens held a + * bare `var(--risk-critical)`, which it cannot split, so it emitted NOTHING + * for the slashed class. Every one of those elements rendered transparent + * for as long as the spelling existed. The only way to see it was to compile + * the stylesheet and grep, or to read a computed style in a browser. + * + * So this test compiles the stylesheet and greps. Asserting the shape of + * `tailwind.config.ts` instead would be asserting our theory of the fix; + * running the compiler asserts the fix. It is also why the check is not + * folded into `tokenConsumers.test.ts` — that one reads source text, and + * source text is exactly what looked fine here. + */ +import postcss from "postcss"; +import tailwind from "tailwindcss"; +import { describe, expect, it } from "vitest"; + +import config from "../../../tailwind.config"; + +/** The three steps the tint scale allows — see the note in `index.css`. */ +const STEPS = ["10", "20", "40"] as const; +const SHADES = ["critical", "high", "medium", "low", "info"] as const; + +const CLASSES = SHADES.flatMap((shade) => + STEPS.map((step) => `bg-risk-${shade}/${step}`), +).concat( + SHADES.map((shade) => `border-risk-${shade}/40`), + // A variant, because the bespoke `.risk-tint-*` classes G0-7 removed could + // not carry one — `hover:bg-risk-low/20` is a live call site. + ["hover:bg-risk-low/20"], +); + +async function compile(classes: readonly string[]): Promise { + const result = await postcss([ + tailwind({ + ...config, + content: [{ raw: classes.map((c) => ``).join(""), extension: "html" }], + }), + ]).process("@tailwind utilities;", { from: undefined }); + return result.css; +} + +describe("risk tint opacity modifier", () => { + it("emits a rule for every step of every shade", async () => { + const css = await compile(CLASSES); + // A class Tailwind dropped leaves no trace, so collect the misses rather + // than failing on the first — the useful failure message is the whole + // list, which is how you tell "one shade regressed" from "the mechanism + // is gone again". + const missing = CLASSES.filter( + (c) => !css.includes(`.${c.replace(/[:/]/g, "\\$&")}`), + ); + expect(missing).toEqual([]); + }); + + it("resolves the emitted colour to the severity token, not a copy of it", async () => { + const css = await compile(["bg-risk-critical/10"]); + expect(css).toContain("var(--risk-critical)"); + // `index.css` is the single source of the hue. A config that inlined + // `#dc2626` here would pass the test above and silently fork the value. + expect(css).not.toContain("#dc2626"); + }); +}); diff --git a/apps/frontend/tests/unit/design/statusTokenContrast.test.ts b/apps/frontend/tests/unit/design/statusTokenContrast.test.ts index 81fa6303..c0f08cad 100644 --- a/apps/frontend/tests/unit/design/statusTokenContrast.test.ts +++ b/apps/frontend/tests/unit/design/statusTokenContrast.test.ts @@ -14,7 +14,7 @@ * * Thresholds: * - `--status-X-foreground` on `--status-X-subtle` ≥ 4.5:1 (WCAG AA text) - * - `--risk-X-foreground` on `bg-risk-X/15` over white ≥ 4.5:1 + * - `--risk-X-foreground` on `bg-risk-X/20` over --background ≥ 4.5:1 * - `--status-X` solid on `--background` ≥ 3:1 (WCAG 1.4.11, * non-text UI: dots, bars) * - `--status-X-border` is deliberately NOT gated — the tint plus the @@ -96,12 +96,28 @@ const STATUSES = ["success", "warning", "danger", "info"] as const; * untested contrast promise. */ const SOLID_STATUSES = ["success", "danger"] as const; -/** Risk shades with a call site — see the same reasoning. */ -const RISKS = ["medium", "low"] as const; +/** + * Every risk shade, because after G0-7 every one of them has a call site. + * The list was `["medium", "low"]` while the other three were spelled as raw + * palette classes at their call sites. + */ +const RISKS = ["critical", "high", "medium", "low", "info"] as const; -// `bg-risk-X/15` is the densest tint in use; a lighter tint over white only -// raises contrast for dark text, so 15% is the worst case worth asserting. -const RISK_TINT_ALPHA = 0.15; +/** + * The worst case a risk foreground has to survive. + * + * `/20` is the densest step in the tint scale (`/10` surface, `/20` the same + * surface emphasised, `/40` border) — a lighter tint only raises contrast for + * dark text. `--background` rather than `--card` because it is the darker of + * the two grounds a tint composites over, and dark-on-tint contrast falls as + * the ground darkens. + * + * This pair was `0.15` over `#ffffff` before G0-7, and it asserted nothing: + * Tailwind emitted no rule for a slashed risk class, so the surface being + * measured here had never once been rendered. It renders now. + */ +const RISK_TINT_ALPHA = 0.2; +const RISK_TINT_GROUND = "#fafafa"; describe("status token contrast", () => { it.each(STATUSES)( @@ -196,14 +212,35 @@ describe("neutral text contrast", () => { describe("risk tint foreground contrast", () => { it.each(RISKS)( - "--risk-%s-foreground clears AA on its own tint over white", + "--risk-%s-foreground clears AA on its own densest tint", (name) => { - const tint = composite(token(`risk-${name}`), RISK_TINT_ALPHA, "#ffffff"); + const tint = composite( + token(`risk-${name}`), + RISK_TINT_ALPHA, + RISK_TINT_GROUND, + ); const ratio = contrast(token(`risk-${name}-foreground`), tint); expect(ratio).toBeGreaterThanOrEqual(4.5); }, ); + it.each(RISKS)( + "--risk-%s itself is NOT readable on its tint — the reason the foregrounds exist", + (name) => { + // The pairing call sites reached for before G0-7, and the one a future + // edit is most likely to reach for again: the severity hex as text on + // its own tint. Asserting it fails keeps the comment in `index.css` + // honest, and turns "why not just use text-risk-high?" into a red test + // rather than an argument. + const tint = composite( + token(`risk-${name}`), + RISK_TINT_ALPHA, + RISK_TINT_GROUND, + ); + expect(contrast(token(`risk-${name}`), tint)).toBeLessThan(4.5); + }, + ); + it("keeps the severity fills themselves unchanged (brand rule)", () => { // CLAUDE.md forbids changing these hexes — legibility is solved with the // `-foreground` shades above, never by retuning the severity color. diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png index c4afaf0f..da8e0891 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/approvals.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/approvals.png index 94ad1b5e..1df836a0 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/approvals.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/approvals.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/dashboard.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/dashboard.png index 7125d9d8..917ed0be 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/dashboard.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/dashboard.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-overview.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-overview.png index ce1dea84..6645f095 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-overview.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-overview.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-vulnerabilities.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-vulnerabilities.png index ba3761f4..6de382c0 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-vulnerabilities.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/project-detail-vulnerabilities.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/projects-list.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/projects-list.png index 2f261c23..aa371d40 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/projects-list.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/projects-list.png differ diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/scans.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/scans.png index ea0a396c..998f10ad 100644 Binary files a/apps/frontend/tests/visual/visual.spec.ts-snapshots/scans.png and b/apps/frontend/tests/visual/visual.spec.ts-snapshots/scans.png differ diff --git a/apps/frontend/tsconfig.spec.json b/apps/frontend/tsconfig.spec.json index 418d3582..e038115a 100644 --- a/apps/frontend/tsconfig.spec.json +++ b/apps/frontend/tsconfig.spec.json @@ -32,5 +32,10 @@ }, "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, - "include": ["src", "tests"] + // `tailwind.config.ts` belongs to tsconfig.node.json, but riskTintOpacity + // (G0-7) imports it to compile the stylesheet for real rather than assert + // the config's shape. A project reference cannot express that — the node + // project sets `noEmit`, so it has no declarations to reference — so the + // file is listed in both projects and type-checked under each. + "include": ["src", "tests", "tailwind.config.ts"] } diff --git a/docs-site/docs/reference/design-system.md b/docs-site/docs/reference/design-system.md index b8968419..d3a815f0 100644 --- a/docs-site/docs/reference/design-system.md +++ b/docs-site/docs/reference/design-system.md @@ -82,28 +82,44 @@ All colour decisions reference the CSS custom properties declared in `index.css` The severity hex values are **never changed** between releases. They appear in: - Recharts fills and chart legends (raw hex via the `--risk-X` variable). -- `bg-risk-X/N` tints on badges and dot indicators. +- `bg-risk-X/N` tints on badges, callouts and grid cells. - Border accents (`border-risk-high/40` on buttons and alerts). +The tint scale has exactly three steps, and a surface should use no other: + +| Step | Use | +|---|---| +| `/10` | The tinted surface — the default for any callout, badge or cell. | +| `/20` | The same surface emphasised: a hover state, or a chip nested on `/10`. | +| `/40` | The border on either. | + +The opacity modifier works because `tailwind.config.ts` wraps each severity token in a `color-mix()` carrying Tailwind's ``. A bare `var()` does not work — Tailwind cannot split it into channels, and rather than degrading it emits no rule at all, so the element renders transparent. Every `bg-risk-X/N` in the app painted nothing until G0-7 repaired the mechanism. `apps/frontend/tests/unit/design/riskTintOpacity.test.ts` compiles the config and fails if any step stops emitting a rule. + #### Risk tint foregrounds Severity fills are chart colours; text sitting **on** a severity tint needs its own darker shade. Each severity therefore ships a `-foreground` companion. Use it — do not reach for a Tailwind palette class, which is how the same state ended up rendered as `text-yellow-800` in one file and `text-amber-900` in the next. -| Token | Hex | Contrast on `bg-risk-X/15` | +Contrast is quoted against the worst case a foreground has to survive: the densest tint (`/20`) composited over the page ground (`--background`, the darker of the two surfaces a tint sits on). On the ordinary `/10` step over a card each figure is roughly 1 point higher. + +| Token | Hex | Contrast on `bg-risk-X/20` | |---|---|---| -| `--risk-critical-foreground` | `#b91c1c` | 5.10:1 | -| `--risk-high-foreground` | `#9a3412` | 6.08:1 | -| `--risk-medium-foreground` | `#854d0e` | 5.90:1 | -| `--risk-low-foreground` | `#1d4ed8` | 5.44:1 | -| `--risk-info-foreground` | `#52525b` | 6.43:1 | +| `--risk-critical-foreground` | `#b91c1c` | 4.55:1 | +| `--risk-high-foreground` | `#9a3412` | 5.51:1 | +| `--risk-medium-foreground` | `#854d0e` | 5.43:1 | +| `--risk-low-foreground` | `#1d4ed8` | 4.86:1 | +| `--risk-info-foreground` | `#52525b` | 5.80:1 | ```tsx // Do -KEV +KEV +// Don't — the severity hex is not readable on its own tint (3.05:1 for high) +KEV // Don't — shade drifts per file, and nothing checks its contrast -KEV +KEV ``` +`statusTokenContrast.test.ts` asserts both halves: that each foreground clears AA on its densest tint, and that the severity token itself does **not** — so "why not just use `text-risk-high`?" answers itself with a red test. + See [Severity colour accessibility](#severity-colour-accessibility). ### Brand accent (W14) @@ -349,7 +365,7 @@ Do not hand-roll a `

    ` block — extend `PageHeader` if a new layout `apps/frontend/src/components/ui/badge.tsx` -Risk-tinted variants pair a status word with the design-system colour. Background uses `bg-risk-X/10` (or `/15` for medium / info) so the chip reads as a coloured tint. Text uses a deeper shade from the same hue family so the rendered contrast clears WCAG AA 4.5:1 — see [Severity colour accessibility](#severity-colour-accessibility). +Risk-tinted variants pair a status word with the design-system colour. Background uses `bg-risk-X/10` so the chip reads as a coloured tint. Text uses the severity's `-foreground` token so the rendered contrast clears WCAG AA 4.5:1 — see [Severity colour accessibility](#severity-colour-accessibility). ### Toast @@ -402,17 +418,21 @@ Decorative borders (`--border` on `--background`, 1.20:1) are **intentionally lo ### Severity colour accessibility -Severity hex values (`#dc2626` / `#ea580c` / `#ca8a04` / `#2563eb` / `#71717a`) are brand-fixed. Used as **body text** on a light tint they would measure as low as 2.5:1, which fails AA. The fix is structural, not chromatic — when the severity tone is used as text, the rendered text colour uses a deeper shade from the same Tailwind hue family: +Severity hex values (`#dc2626` / `#ea580c` / `#ca8a04` / `#2563eb` / `#71717a`) are brand-fixed. Used as **body text** on a light tint they measure as low as 2.5:1, which fails AA. The fix is structural, not chromatic — when the severity tone is used as text, the rendered colour is the severity's `-foreground` token, a deeper shade of the same hue: -| Tone | Tint background | Text colour | Contrast | +| Tone | Tint background | Text colour | Contrast on a card | |---|---|---|---| -| `critical` | `bg-risk-critical/10` | `text-red-700` (`#b91c1c`) | 5.54:1 | -| `high` | `bg-risk-high/10` | `text-orange-800` (`#9a3412`) | 6.47:1 | -| `medium` | `bg-risk-medium/15` | `text-yellow-800` (`#854d0e`) | 5.91:1 | -| `low` | `bg-risk-low/10` | `text-blue-700` (`#1d4ed8`) | 5.83:1 | -| `info` | `bg-risk-info/15` | `text-slate-600` (`#52525b`) | 6.41:1 | +| `critical` | `bg-risk-critical/10` | `text-risk-critical-foreground` (`#b91c1c`) | 5.54:1 | +| `high` | `bg-risk-high/10` | `text-risk-high-foreground` (`#9a3412`) | 6.46:1 | +| `medium` | `bg-risk-medium/10` | `text-risk-medium-foreground` (`#854d0e`) | 6.21:1 | +| `low` | `bg-risk-low/10` | `text-risk-low-foreground` (`#1d4ed8`) | 5.82:1 | +| `info` | `bg-risk-info/10` | `text-risk-info-foreground` (`#52525b`) | 6.85:1 | + +Until G0-7 these were spelled as raw Tailwind palette classes (`text-red-700`) with the measurements written in a code comment. They are tokens now, so the same pairing is reused everywhere and the numbers come from a test rather than a comment. + +The **dot indicators** (in `SeverityBadge`, chart legends, status pills) continue to use the raw `bg-risk-X` token — colour identity stays recognisable; only text shade is darkened. The reference implementation is `apps/frontend/src/components/ui/badge.tsx`. -The **dot indicators** (in `SeverityBadge`, chart legends, status pills) continue to use the raw `bg-risk-X` token — colour identity stays recognisable; only text shade is darkened. The reference implementation is `apps/frontend/src/components/ui/badge.tsx` (W11-H). +One consequence worth stating plainly: `--muted-foreground` clears AA on the page ground by 0.03, so it does **not** clear AA on a risk tint. Secondary text inside a tinted callout takes `text-foreground` and relies on size and weight for hierarchy. ### Colour is not the only signal 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 2907a60b..7adc5edc 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 @@ -82,28 +82,44 @@ Severity 팔레트 (Critical / High / Medium / Low / Info) 는 **의도적으로 Severity hex 는 **릴리스 간 변경하지 않습니다**. 사용처: - Recharts fill · 차트 범례 (raw hex, `--risk-X` 변수 참조). -- 배지 · dot 인디케이터의 `bg-risk-X/N` tint. +- 배지 · 콜아웃 · 격자 셀의 `bg-risk-X/N` tint. - 버튼 · alert 의 border accent (`border-risk-high/40`). +Tint 단계는 정확히 셋이며, 표면은 이 밖의 값을 쓰지 않습니다. + +| 단계 | 용도 | +|---|---| +| `/10` | tint 를 입힌 표면. 콜아웃·배지·셀의 기본값. | +| `/20` | 같은 표면을 강조할 때. hover 상태, 또는 `/10` 위에 겹치는 칩. | +| `/40` | 두 경우 모두의 테두리. | + +불투명도 수식어가 동작하는 이유는 `tailwind.config.ts` 가 severity 토큰마다 Tailwind 의 `` 를 담은 `color-mix()` 로 감싸기 때문입니다. `var()` 를 그대로 두면 동작하지 않습니다 — Tailwind 가 채널을 분해하지 못하고, 값을 낮춰 내보내는 대신 규칙을 아예 만들지 않아 요소가 투명하게 그려집니다. G0-7 이 이 구조를 고치기 전까지 앱의 모든 `bg-risk-X/N` 은 아무것도 칠하지 않았습니다. `apps/frontend/tests/unit/design/riskTintOpacity.test.ts` 가 이 설정을 실제로 컴파일해, 한 단계라도 규칙이 사라지면 실패합니다. + #### Severity tint 전경색 Severity hex 는 차트용 색입니다. tint **위에** 얹는 텍스트는 별도의 짙은 shade 가 필요하므로, severity 마다 `-foreground` 짝을 제공합니다. 이 토큰을 쓰고 Tailwind 팔레트 클래스를 직접 쓰지 않습니다 — 같은 상태가 어떤 파일에서는 `text-yellow-800`, 다른 파일에서는 `text-amber-900` 으로 갈린 것이 그 방식의 결과입니다. -| 토큰 | Hex | `bg-risk-X/15` 위 대비 | +대비 수치는 전경색이 견뎌야 하는 가장 불리한 조건, 곧 가장 짙은 tint(`/20`)를 페이지 바탕(`--background`, tint 가 얹히는 두 표면 중 어두운 쪽) 위에 합성한 값입니다. 일반적인 `/10` 단계를 카드 위에 올리면 각 수치는 대략 1 정도 높아집니다. + +| 토큰 | Hex | `bg-risk-X/20` 위 대비 | |---|---|---| -| `--risk-critical-foreground` | `#b91c1c` | 5.10:1 | -| `--risk-high-foreground` | `#9a3412` | 6.08:1 | -| `--risk-medium-foreground` | `#854d0e` | 5.90:1 | -| `--risk-low-foreground` | `#1d4ed8` | 5.44:1 | -| `--risk-info-foreground` | `#52525b` | 6.43:1 | +| `--risk-critical-foreground` | `#b91c1c` | 4.55:1 | +| `--risk-high-foreground` | `#9a3412` | 5.51:1 | +| `--risk-medium-foreground` | `#854d0e` | 5.43:1 | +| `--risk-low-foreground` | `#1d4ed8` | 4.86:1 | +| `--risk-info-foreground` | `#52525b` | 5.80:1 | ```tsx // 권장 -KEV +KEV +// 지양 — severity hex 는 자기 tint 위에서 읽히지 않습니다 (high 는 3.05:1) +KEV // 지양 — 파일마다 shade 가 갈리고, 대비를 검사하는 장치가 없습니다 -KEV +KEV ``` +`statusTokenContrast.test.ts` 는 양쪽을 모두 단언합니다. 각 전경색이 자기 tint 위에서 AA 를 넘는다는 것과, severity 토큰 자체는 넘지 **못한다**는 것 — 그래서 "`text-risk-high` 를 쓰면 안 되나?" 라는 물음에는 실패하는 테스트가 답합니다. + [Severity 색 접근성](#severity-색-접근성) 을 참고하세요. ### 브랜드 액센트 (W14) @@ -351,7 +367,7 @@ stacked 변형에는 선택적 `meta` 슬롯도 있습니다 — 부제 아래 `apps/frontend/src/components/ui/badge.tsx` -Risk-tinted variant 는 상태 단어와 디자인 시스템 색을 짝지움. 배경은 `bg-risk-X/10` (medium / info 는 `/15`) — 칩이 색 tint 로 읽히도록. 텍스트는 같은 hue family 의 더 짙은 shade — 렌더링 대비가 WCAG AA 4.5:1 을 통과 — [Severity 색 접근성](#severity-색-접근성) 참고. +Risk-tinted variant 는 상태 단어와 디자인 시스템 색을 짝지움. 배경은 `bg-risk-X/10` — 칩이 색 tint 로 읽히도록. 텍스트는 그 severity 의 `-foreground` 토큰 — 렌더링 대비가 WCAG AA 4.5:1 을 통과 — [Severity 색 접근성](#severity-색-접근성) 참고. ### Toast @@ -404,17 +420,21 @@ W11-F polish phase 가 모든 인터랙티브 transition 의 타이밍 · easing ### Severity 색 접근성 -Severity hex (`#dc2626` / `#ea580c` / `#ca8a04` / `#2563eb` / `#71717a`) 는 브랜드 고정. Light tint 위 **본문 텍스트** 로 쓰이면 2.5:1 까지 떨어져 AA 실패. 해결은 색이 아닌 구조 — severity 톤이 텍스트로 쓰일 때 렌더링 텍스트 색은 같은 Tailwind hue family 의 더 짙은 shade: +Severity hex (`#dc2626` / `#ea580c` / `#ca8a04` / `#2563eb` / `#71717a`) 는 브랜드 고정. Light tint 위 **본문 텍스트** 로 쓰이면 2.5:1 까지 떨어져 AA 실패. 해결은 색이 아닌 구조 — severity 톤이 텍스트로 쓰일 때 렌더링 텍스트 색은 같은 hue 의 더 짙은 shade, 곧 그 severity 의 `-foreground` 토큰입니다. -| Tone | Tint 배경 | 텍스트 색 | 대비 | +| Tone | Tint 배경 | 텍스트 색 | 카드 위 대비 | |---|---|---|---| -| `critical` | `bg-risk-critical/10` | `text-red-700` (`#b91c1c`) | 5.54:1 | -| `high` | `bg-risk-high/10` | `text-orange-800` (`#9a3412`) | 6.47:1 | -| `medium` | `bg-risk-medium/15` | `text-yellow-800` (`#854d0e`) | 5.91:1 | -| `low` | `bg-risk-low/10` | `text-blue-700` (`#1d4ed8`) | 5.83:1 | -| `info` | `bg-risk-info/15` | `text-slate-600` (`#52525b`) | 6.41:1 | +| `critical` | `bg-risk-critical/10` | `text-risk-critical-foreground` (`#b91c1c`) | 5.54:1 | +| `high` | `bg-risk-high/10` | `text-risk-high-foreground` (`#9a3412`) | 6.46:1 | +| `medium` | `bg-risk-medium/10` | `text-risk-medium-foreground` (`#854d0e`) | 6.21:1 | +| `low` | `bg-risk-low/10` | `text-risk-low-foreground` (`#1d4ed8`) | 5.82:1 | +| `info` | `bg-risk-info/10` | `text-risk-info-foreground` (`#52525b`) | 6.85:1 | + +G0-7 이전에는 이 색들을 Tailwind 팔레트 클래스(`text-red-700`)로 적고 측정값은 코드 주석에 남겼습니다. 지금은 토큰이므로 같은 조합을 어디서나 재사용하고, 위 수치는 주석이 아니라 테스트에서 나옵니다. + +**Dot 인디케이터** (SeverityBadge · 차트 범례 · status pill) 는 계속 raw `bg-risk-X` 토큰 사용 — 색 정체성은 그대로, 텍스트 shade 만 어둡게. 참조 구현은 `apps/frontend/src/components/ui/badge.tsx`. -**Dot 인디케이터** (SeverityBadge · 차트 범례 · status pill) 는 계속 raw `bg-risk-X` 토큰 사용 — 색 정체성은 그대로, 텍스트 shade 만 어둡게. 참조 구현은 `apps/frontend/src/components/ui/badge.tsx` (W11-H). +한 가지는 분명히 적어 둡니다. `--muted-foreground` 는 페이지 바탕 위에서 AA 를 0.03 차이로 통과하므로, risk tint 위에서는 통과하지 못합니다. tint 를 입힌 콜아웃 안의 보조 텍스트는 `text-foreground` 를 쓰고 위계는 크기와 굵기로 표현합니다. ### 색이 신호의 단독 수단 아님