From 82934e168197c7ddc41fdebe9df5967935989625 Mon Sep 17 00:00:00 2001 From: Sishir P Date: Tue, 18 Aug 2026 18:40:43 -0400 Subject: [PATCH] chore: guard the contrast and theming contract in CI Nothing in lint, typecheck, or the test suite could see the button gradient bug. The stylesheet rule looked correct in isolation; only the interaction with a lower-specificity rule broke it. These are the checks that would have caught it. scripts/css-contract-scan.ts, wired into check.sh and the security-scan CI job. Eight rules, each one a bug that actually shipped: 1. background-color longhand in a rule that declares no image of its own, which is the original defect stated as a decidable rule. 2. brand rgba outside :root, the values that cannot follow an agency. 3. raw hex outside :root. The old test banned hex in .tsx but exempted the stylesheet, which is how ~27 stray values accumulated there. 4. var() references with no :root definition. 5. AGENCY_THEME_VARIABLES against :root, both directions. This is the one that permanently closes the original hole: a token derived from the brand can no longer be added without being made overridable, and a resolver-managed token cannot lack a static default. 6. --color-brand-raw used as a text color. It deliberately holds the UNADJUSTED agency color and may fail AA. 7. the hex-alpha-append idiom. 8. dead var() fallbacks. Verified by reintroducing the original defect: the scan fails with the exact line and reason, and passes again on restore. Against the stylesheet as it stood before this work it reports 130 issues. design-system.test.ts gains a table of real (background, foreground) pairings. It previously only checked white-against-token, so it had no way to know which foreground met which background and .btn-ghost passed it. Translucent tokens are composited before scoring, since the contrast of an 8% tint is not the contrast of the color it is mixed from. lib/contrast-audit.ts is a dev-only DOM walker for the cases static rules cannot reach. It resolves each text node's effective background by climbing through transparent ancestors, and when it meets a background-image it parses the gradient stops and scores the worst one , exactly the case that made this bug invisible. Call window.__contrastAudit(), or append ?contrast=1 to outline offenders. It is tree-shaken out of production. --- .github/workflows/ci.yml | 2 + package.json | 1 + packages/web/src/design-system.test.ts | 165 ++++++++++++--- packages/web/src/lib/contrast-audit.ts | 220 +++++++++++++++++++ packages/web/src/main.tsx | 9 + scripts/check.sh | 1 + scripts/css-contract-scan.ts | 279 +++++++++++++++++++++++++ 7 files changed, 645 insertions(+), 32 deletions(-) create mode 100644 packages/web/src/lib/contrast-audit.ts create mode 100644 scripts/css-contract-scan.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac5f5014..b408cf78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: - run: npm run security:scan - name: No string-built SQL (parameterized-query guard) run: npm run sql:scan + - name: CSS contract (contrast and theming guards) + run: npm run css:scan test-core: name: test-core diff --git a/package.json b/package.json index b10f4e81..ad059b32 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint": "turbo lint", "typecheck": "turbo typecheck", "security:scan": "node --import tsx scripts/security-surface-scan.ts", + "css:scan": "node --import tsx scripts/css-contract-scan.ts", "sql:scan": "bash scripts/check-no-string-sql.sh", "check": "bash scripts/check.sh", "db:migrate": "npm run db:migrate --workspace=@rayhealth/core", diff --git a/packages/web/src/design-system.test.ts b/packages/web/src/design-system.test.ts index 613143e0..db5ce359 100644 --- a/packages/web/src/design-system.test.ts +++ b/packages/web/src/design-system.test.ts @@ -1,27 +1,68 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { + AA_NON_TEXT, + AA_TEXT, + AGENCY_THEME_VARIABLES, + composite, + contrastRatio, + parseCssColor, + type Rgb, +} from '@rayhealth/core/domain/theme-resolver.js'; const srcDirectory = resolve(process.cwd(), 'src'); const colorLiteralPattern = /#[\da-f]{3,4}\b|#[\da-f]{6}(?:[\da-f]{2})?\b/gi; +const css = readFileSync(join(srcDirectory, 'index.css'), 'utf8'); -function tokenHex(css: string, token: string): string { - const value = css.match(new RegExp(`${token}:\\s*(#[\\da-f]{6})`, 'i'))?.[1]; - if (!value) throw new Error(`Missing hex value for ${token}`); - return value; -} +/** + * The `:root` declarations, as a raw name -> value map. + * + * Comments are stripped first: token names appear inside the explanatory + * comments in index.css, and a naive scan reads those as declarations and + * silently overwrites the real values with prose. + */ +const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); +const tokens: Record = Object.fromEntries( + [...(/:root\s*\{[\s\S]*?\n\}/.exec(cssWithoutComments)?.[0] ?? '').matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)] + .map((match) => [match[1], match[2].trim()]) +); -function relativeLuminance(hex: string): number { - const channels = hex.match(/[\da-f]{2}/gi)?.map((channel) => parseInt(channel, 16) / 255) ?? []; - const linear = channels.map((channel) => - channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 - ); - return (0.2126 * linear[0]) + (0.7152 * linear[1]) + (0.0722 * linear[2]); -} +const WHITE: Rgb = { r: 255, g: 255, b: 255 }; + +/** + * Resolve a token to a concrete color, following `var()` indirection and + * flattening any alpha over `backdrop`. + * + * Compositing is the point: several tokens are 8-25% tints, and their real + * contrast is the contrast of the composited result. Scoring the tint color + * itself , which is what a naive check does , reports a number the user never + * actually sees. + */ +function colorOf(token: string, backdrop: Rgb = WHITE, depth = 0): Rgb { + const raw = tokens[token]; + if (!raw) throw new Error(`token ${token} is not defined in :root`); + if (depth > 5) throw new Error(`token ${token} has a circular definition`); + + const indirect = /^var\(\s*(--[\w-]+)\s*\)$/.exec(raw); + if (indirect) return colorOf(indirect[1], backdrop, depth + 1); + + // Translucent values MUST be matched before parseCssColor, which drops the + // alpha channel. Scoring rgba(16, 116, 128, 0.08) as if it were solid teal + // reports the contrast of a color nobody ever sees. + const rgba = /^rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)$/.exec(raw); + if (rgba) { + return composite( + { r: Number(rgba[1]), g: Number(rgba[2]), b: Number(rgba[3]) }, + Number(rgba[4]), + backdrop + ); + } -function contrastRatio(first: string, second: string): number { - const [lighter, darker] = [relativeLuminance(first), relativeLuminance(second)].sort((a, b) => b - a); - return (lighter + 0.05) / (darker + 0.05); + const direct = parseCssColor(raw); + if (direct) return direct; + + throw new Error(`token ${token} has an unparseable value: ${raw}`); } function sourceFiles(directory: string): string[] { @@ -42,8 +83,6 @@ function sourceFiles(directory: string): string[] { describe('RayHealth visual system', () => { it('defines one bold, accessible brand contract for every surface', () => { - const css = readFileSync(join(srcDirectory, 'index.css'), 'utf8'); - expect(css).toContain('--color-on-brand:'); expect(css).toContain('--color-text-on-dark:'); expect(css).toContain('--color-surface-elevated:'); @@ -64,21 +103,83 @@ describe('RayHealth visual system', () => { expect(violations).toEqual([]); }); - it('keeps white labels WCAG AA-safe on every bold action color', () => { - const css = readFileSync(join(srcDirectory, 'index.css'), 'utf8'); - const onBrand = tokenHex(css, '--color-on-brand'); - const actionTokens = [ - '--color-primary', - '--color-primary-dark', - '--color-accent', - '--color-accent-dark', - '--color-success', - '--color-danger', - '--color-warning', - ]; - - for (const token of actionTokens) { - expect(contrastRatio(onBrand, tokenHex(css, token)), token).toBeGreaterThanOrEqual(4.5); + it('gives every resolver-managed variable a static default', () => { + // Without a default, an agency with no theme configured would get nothing + // at all for that variable. scripts/css-contract-scan.ts enforces the + // reverse direction too. + for (const name of AGENCY_THEME_VARIABLES) { + expect(tokens[name], `${name} missing from :root`).toBeTruthy(); } }); + + /** + * Every pair below is a real pairing somewhere in index.css or a feature + * page. The old version of this suite only checked white-against-token, which + * is why `.btn-ghost` , brand-colored text on a brand-colored surface , went + * undetected: nothing here knew which foreground met which background. + */ + describe.each([ + // Labels on a brand-filled surface (.btn-primary, badges, avatars, and + // every stop of --gradient-brand). + ['--color-on-brand', '--color-primary', WHITE, AA_TEXT], + ['--color-on-brand', '--color-primary-dark', WHITE, AA_TEXT], + ['--color-on-brand', '--color-accent', WHITE, AA_TEXT], + ['--color-on-brand', '--color-success', WHITE, AA_TEXT], + ['--color-on-brand', '--color-danger', WHITE, AA_TEXT], + ['--color-on-brand', '--color-warning', WHITE, AA_TEXT], + ['--color-on-accent', '--color-accent', WHITE, AA_TEXT], + + // Brand color used AS TEXT on a light surface: .btn-ghost labels, every + // link, and ~229 inline call sites. This is the pair whose absence produced + // the invisible Refresh button. + ['--color-primary', '--color-surface', WHITE, AA_TEXT], + ['--color-primary-dark', '--color-surface', WHITE, AA_TEXT], + ['--color-accent', '--color-surface', WHITE, AA_TEXT], + ['--color-primary-dark', '--color-primary-bg', WHITE, AA_TEXT], + + // The text ramp on light surfaces. + ['--color-text', '--color-surface', WHITE, AA_TEXT], + ['--color-text-secondary', '--color-surface', WHITE, AA_TEXT], + ['--color-text-muted', '--color-surface', WHITE, AA_TEXT], + ['--color-text-subtle', '--color-surface', WHITE, AA_TEXT], + ['--color-slate-700', '--color-surface', WHITE, AA_TEXT], + ['--color-text', '--color-surface-soft', WHITE, AA_TEXT], + ['--color-text-secondary', '--color-surface-soft', WHITE, AA_TEXT], + + // Semantic banners and badges: colored text on its own tint. + ['--color-success-text', '--color-success-bg', WHITE, AA_TEXT], + ['--color-danger-text', '--color-danger-bg', WHITE, AA_TEXT], + ['--color-warning-text', '--color-warning-bg', WHITE, AA_TEXT], + ['--color-info-text', '--color-info-bg', WHITE, AA_TEXT], + + // The dark sidebar rail. Contrast runs the other way here, which is why + // --color-text-muted-on-dark exists as a separate token. + ['--color-sidebar-text', '--color-sidebar', WHITE, AA_TEXT], + ['--color-sidebar-text-active', '--color-sidebar', WHITE, AA_TEXT], + ['--color-text-on-dark', '--color-sidebar', WHITE, AA_TEXT], + ['--color-text-muted-on-dark', '--color-sidebar', WHITE, AA_TEXT], + + // Note on borders: --color-border and friends are deliberately NOT asserted + // at 3:1. WCAG 1.4.11 covers non-text content required to IDENTIFY a + // control, and these are decorative separators , a control here is + // identified by its label and its fill, and its state by the focus ring, + // which the theme resolver does guarantee at 3:1 against the surface. + ] as const)('%s on %s', (foreground, background, backdrop, required) => { + it(`meets ${required}:1`, () => { + // A translucent background composites over the surface it sits on; the + // dark rail composites over itself. + const isDark = background.includes('sidebar'); + const base = isDark ? colorOf('--color-sidebar') : backdrop; + const ratio = contrastRatio(colorOf(foreground, base), colorOf(background, base)); + expect(Number(ratio.toFixed(2))).toBeGreaterThanOrEqual(required); + }); + }); + + it('keeps the active sidebar item and role badge legible on the rail', () => { + const rail = colorOf('--color-sidebar'); + expect(contrastRatio(colorOf('--color-sidebar-text-active'), colorOf('--color-sidebar-active', rail))) + .toBeGreaterThanOrEqual(AA_TEXT); + expect(contrastRatio(colorOf('--color-brand-badge-text'), colorOf('--color-brand-badge-bg', rail))) + .toBeGreaterThanOrEqual(AA_NON_TEXT); + }); }); diff --git a/packages/web/src/lib/contrast-audit.ts b/packages/web/src/lib/contrast-audit.ts new file mode 100644 index 00000000..24f115de --- /dev/null +++ b/packages/web/src/lib/contrast-audit.ts @@ -0,0 +1,220 @@ +/** + * Dev-only runtime contrast auditor. + * + * Static analysis cannot see composed color. The bug that motivated this file + * was a `