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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 133 additions & 32 deletions packages/web/src/design-system.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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[] {
Expand All @@ -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:');
Expand All @@ -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);
});
});
Loading
Loading