style(web): unify accessible control system
This commit is contained in:
+768
-237
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppShell } from '../app-shell.js';
|
||||
import { flatten } from './test-css.js';
|
||||
|
||||
function channel(value: number): number {
|
||||
const ratio = value / 255;
|
||||
return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function luminance(rgb: readonly [number, number, number]): number {
|
||||
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
|
||||
}
|
||||
|
||||
function parseColor(input: string): readonly [number, number, number] | null {
|
||||
const m = input.match(/rgba?\(([^)]+)\)/);
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(',').map((p) => Number.parseFloat(p.trim()));
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
||||
function contrast(
|
||||
fore: readonly [number, number, number],
|
||||
back: readonly [number, number, number],
|
||||
) {
|
||||
const l1 = luminance(fore);
|
||||
const l2 = luminance(back);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function effectiveBackground(element: Element): readonly [number, number, number] {
|
||||
let node: Element | null = element;
|
||||
while (node) {
|
||||
const value = parseColor(window.getComputedStyle(node).backgroundColor);
|
||||
const isTransparent =
|
||||
!value ||
|
||||
(value[0] === 0 &&
|
||||
value[1] === 0 &&
|
||||
value[2] === 0 &&
|
||||
getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)');
|
||||
if (!isTransparent) return value ?? [255, 255, 255];
|
||||
node = node.parentElement;
|
||||
}
|
||||
return [233, 244, 223];
|
||||
}
|
||||
|
||||
const ROUTES = [
|
||||
'/fleet',
|
||||
'/fleet/messages',
|
||||
'/fleet/notifications',
|
||||
'/fleet/logs',
|
||||
'/automation',
|
||||
'/jobs',
|
||||
'/audit',
|
||||
'/settings/system',
|
||||
] as const;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('painted control contrast', () => {
|
||||
it('keeps every rendered control at WCAG AA', { timeout: 30_000 }, async () => {
|
||||
const raw = await readFile(
|
||||
new URL('file:///Users/chick/.local/opt/multi-simadmin/apps/web/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const style = document.createElement('style');
|
||||
style.textContent = flatten(raw);
|
||||
document.head.append(style);
|
||||
|
||||
const failures: string[] = [];
|
||||
for (const pathname of ROUTES) {
|
||||
const { unmount } = render(<AppShell pathname={pathname} version="0.1.0" />);
|
||||
await new Promise((done) => window.setTimeout(done, 40));
|
||||
for (const element of document.querySelectorAll<HTMLElement>('button, a, [role="button"]')) {
|
||||
const cs = window.getComputedStyle(element);
|
||||
const fore = parseColor(cs.color);
|
||||
if (!fore) continue;
|
||||
const back = effectiveBackground(element);
|
||||
const ratio = contrast(fore, back);
|
||||
if (ratio >= 4.5) continue;
|
||||
const label = (element.textContent ?? '').trim().slice(0, 30) || element.className;
|
||||
failures.push(
|
||||
`${pathname} ${ratio.toFixed(2)}:1 fg=${cs.color} bg=rgb(${back.join(',')}) <${element.tagName.toLowerCase()} class="${element.className}"> "${label}"`,
|
||||
);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
|
||||
for (const line of failures) console.log(line);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Rule = { selector: string; declarations: Record<string, string> };
|
||||
|
||||
/**
|
||||
* The stylesheet is layered by years of incremental patches, so asserting on raw substrings
|
||||
* proves nothing about what the browser finally paints. These helpers resolve the cascade.
|
||||
*/
|
||||
function parseDeclarations(body: string): Record<string, string> {
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const part of body.split(';')) {
|
||||
const separator = part.indexOf(':');
|
||||
if (separator === -1) continue;
|
||||
const property = part.slice(0, separator).trim();
|
||||
const value = part.slice(separator + 1).trim();
|
||||
if (property && value) declarations[property] = value;
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function collectRules(css: string, rules: Rule[] = []): Rule[] {
|
||||
let index = 0;
|
||||
let preludeStart = 0;
|
||||
while (index < css.length) {
|
||||
if (css[index] !== '{') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const selector = css.slice(preludeStart, index).trim();
|
||||
let cursor = index + 1;
|
||||
let depth = 1;
|
||||
while (cursor < css.length && depth > 0) {
|
||||
if (css[cursor] === '{') depth += 1;
|
||||
else if (css[cursor] === '}') depth -= 1;
|
||||
cursor += 1;
|
||||
}
|
||||
const body = css.slice(index + 1, cursor - 1);
|
||||
if (selector.startsWith('@')) {
|
||||
collectRules(body, rules);
|
||||
} else if (selector) {
|
||||
rules.push({ selector, declarations: parseDeclarations(body) });
|
||||
}
|
||||
preludeStart = cursor;
|
||||
index = cursor;
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
async function loadStylesheet() {
|
||||
const css = await readFile(new URL('../styles.css', import.meta.url), 'utf8');
|
||||
const rules = collectRules(css.replace(/\/\*[\s\S]*?\*\//g, ''), []);
|
||||
const tokens: Record<string, string> = {};
|
||||
for (const rule of rules) {
|
||||
if (rule.selector !== ':root') {
|
||||
continue;
|
||||
}
|
||||
for (const [property, value] of Object.entries(rule.declarations)) {
|
||||
if (property.startsWith('--')) tokens[property] = value;
|
||||
}
|
||||
}
|
||||
const wins = (target: string, property: string) => {
|
||||
let winner: string | undefined;
|
||||
for (const rule of rules) {
|
||||
if (!rule.selector.split(',').some((entry) => entry.replace(/\s+/g, ' ').trim() === target))
|
||||
continue;
|
||||
const value = rule.declarations[property];
|
||||
if (value !== undefined) winner = value;
|
||||
}
|
||||
return winner;
|
||||
};
|
||||
const resolve = (value: string | undefined): string | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
return value.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name: string) => tokens[name] ?? '');
|
||||
};
|
||||
const exact = (selector: string, property: string) =>
|
||||
rules.filter((rule) => rule.selector.replace(/\s+/g, ' ') === selector).at(-1)?.declarations[
|
||||
property
|
||||
];
|
||||
return { rules, tokens, wins, resolve, exact };
|
||||
}
|
||||
|
||||
function channel(value: number): number {
|
||||
const ratio = value / 255;
|
||||
return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: string, background: string): number {
|
||||
const parse = (input: string) => {
|
||||
const hex = input.trim().match(/^#([0-9a-f]{6})$/i);
|
||||
if (!hex) throw new Error(`expected a 6 digit hex colour, got "${input}"`);
|
||||
const int = Number.parseInt(hex[1], 16);
|
||||
return [int >> 16, (int >> 8) & 0xff, int & 0xff] as const;
|
||||
};
|
||||
const luminance = (input: string) => {
|
||||
const [red, green, blue] = parse(input);
|
||||
return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
|
||||
};
|
||||
const lighter = Math.max(luminance(foreground), luminance(background));
|
||||
const darker = Math.min(luminance(foreground), luminance(background));
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
describe('button system', () => {
|
||||
it('exposes one compact height and radius scale', async () => {
|
||||
const { tokens } = await loadStylesheet();
|
||||
|
||||
expect(tokens['--button-height']).toBe('2.375rem');
|
||||
expect(tokens['--button-height-compact']).toBe('2.125rem');
|
||||
expect(tokens['--button-height-large']).toBe('2.625rem');
|
||||
expect(tokens['--icon-button-size']).toBe('2.5rem');
|
||||
expect(tokens['--button-radius']).toBe('10px');
|
||||
});
|
||||
|
||||
it('paints the primary action with the accessible token pair, not the old teal pill', async () => {
|
||||
const { rules, wins, resolve } = await loadStylesheet();
|
||||
|
||||
// A bare `.primary-action` selector has specificity (0,1,0) and loses to the generic
|
||||
// `button:not([class*='animal-'])` surface rule at (0,1,1). That is what turned the label
|
||||
// white on cream, so the winning declarations must live on an element-qualified selector.
|
||||
const bare = rules.filter(
|
||||
(rule) =>
|
||||
rule.declarations.background !== undefined &&
|
||||
rule.selector.split(',').some((entry) => entry.trim() === '.primary-action'),
|
||||
);
|
||||
expect(bare.map((rule) => rule.selector)).toEqual([]);
|
||||
|
||||
expect(wins('button.primary-action', 'border-radius')).toBe('var(--button-radius)');
|
||||
const background = resolve(wins('button.primary-action', 'background'));
|
||||
const color = resolve(wins('button.primary-action', 'color'));
|
||||
|
||||
expect(background).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(contrastRatio(color as string, background as string)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it('keeps disabled actions legible instead of fading them out', async () => {
|
||||
const { wins, resolve, tokens } = await loadStylesheet();
|
||||
|
||||
expect(wins('button.primary-action:disabled', 'opacity')).toBe('1');
|
||||
expect(wins('button:disabled', 'opacity')).toBe('1');
|
||||
expect(
|
||||
contrastRatio(
|
||||
resolve(tokens['--button-disabled-text']) as string,
|
||||
resolve(tokens['--button-disabled-bg']) as string,
|
||||
),
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('uses one accessible field scale shared by inputs, selects and textareas', async () => {
|
||||
const { tokens, exact } = await loadStylesheet();
|
||||
|
||||
expect(tokens['--field-height']).toBe('2.375rem');
|
||||
expect(tokens['--field-radius']).toBe('10px');
|
||||
expect(tokens['--field-border']).toBe('var(--line-strong)');
|
||||
expect(tokens['--field-focus-border']).toBe('var(--button-focus-ring)');
|
||||
expect(exact('button, input, select, textarea', 'border')).toBe(
|
||||
'1px solid var(--field-border)',
|
||||
);
|
||||
expect(exact('button, input, select, textarea', 'background')).toBe('var(--field-bg)');
|
||||
expect(exact('input:focus, select:focus, textarea:focus', 'border-color')).toBe(
|
||||
'var(--field-focus-border)',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalises semantic surfaces in notification, log and message centres', async () => {
|
||||
const { tokens, rules } = await loadStylesheet();
|
||||
const semanticNames = [
|
||||
'--workbench-mint-border',
|
||||
'--workbench-warning-border',
|
||||
'--workbench-warning-text',
|
||||
'--workbench-warning-soft',
|
||||
'--workbench-danger-border',
|
||||
];
|
||||
|
||||
expect(tokens['--workbench-warning-text']).toBe('#825f0c');
|
||||
expect(semanticNames.every((name) => /^#[0-9a-f]{6}$/i.test(tokens[name] ?? ''))).toBe(true);
|
||||
expect(
|
||||
contrastRatio(tokens['--workbench-warning-text'], tokens['--workbench-warning-soft']),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
rules
|
||||
.filter((rule) =>
|
||||
[
|
||||
'.fleet-notifications-retry-all',
|
||||
'.fleet-messages-delete',
|
||||
'.fleet-log-filters select',
|
||||
].some((selector) => rule.selector.split(',').some((entry) => entry.trim() === selector)),
|
||||
)
|
||||
.every((rule) => !Object.values(rule.declarations).join(' ').includes('rgba(')),
|
||||
).toBe(true);
|
||||
|
||||
const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
expect(css).not.toMatch(
|
||||
/#8a6722|#fff8e8|#fffafa|rgba\(17,\s*119,\s*110|rgba\(170,\s*120,\s*35|rgba\(183,\s*77,\s*72/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps workbench text and focus indicators on the accessible brand scale', async () => {
|
||||
const { tokens, resolve } = await loadStylesheet();
|
||||
|
||||
expect(tokens['--workbench-mint']).toBe('#0f6e66');
|
||||
expect(tokens['--workbench-muted']).toBe('#6f6350');
|
||||
expect(tokens['--button-focus-ring']).toBe('#0f6e66');
|
||||
expect(tokens['--animal-primary-color-hover']).toBe('#0f6e66');
|
||||
expect(tokens['--animal-text-color-muted']).toBe('#6f6350');
|
||||
expect(
|
||||
contrastRatio(
|
||||
resolve(tokens['--workbench-muted']) as string,
|
||||
resolve(tokens['--animal-canvas-fallback'] ?? '#e9f4df') as string,
|
||||
),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it('does not resurrect low-contrast legacy accents', async () => {
|
||||
const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(css).not.toMatch(/#19b8aa|#178f84|#109d92|rgba\(25,\s*184,\s*170/i);
|
||||
expect(css).not.toMatch(/#d95757|rgba\(217,\s*87,\s*87/i);
|
||||
expect(css).not.toMatch(/#8f806b|#81745f|#8d816e|#766a58|#7f725f/);
|
||||
});
|
||||
|
||||
it('normalises Animal UI buttons through attribute selectors, not hashed classes', async () => {
|
||||
const { rules, wins } = await loadStylesheet();
|
||||
const selectors = rules.map((rule) => rule.selector);
|
||||
|
||||
expect(selectors.some((selector) => selector.includes("[class*='animal-btn-']"))).toBe(true);
|
||||
expect(selectors.some((s) => s.includes("[class*='animal-btn-small']"))).toBe(true);
|
||||
// Hashed module classes such as animal-btn-QoKe1 are regenerated on every library upgrade
|
||||
// and silently stop matching, so only the semantic suffixes may appear here.
|
||||
const semantic =
|
||||
'small|middle|large|icon|block|primary|default|dashed|text|link|ghost|danger|loading';
|
||||
expect(selectors.join(' ')).not.toMatch(
|
||||
new RegExp(`animal-btn-(?!${semantic}\\b)[A-Za-z0-9]{4,}`),
|
||||
);
|
||||
expect(wins("[class*='animal-btn-']", 'border-radius')).toBe('var(--button-radius)');
|
||||
});
|
||||
|
||||
it('gives icon buttons a square target that matches the text scale', async () => {
|
||||
const { wins } = await loadStylesheet();
|
||||
|
||||
expect(wins('button.icon-button', 'width')).toBe('var(--icon-button-size)');
|
||||
expect(wins('button.icon-button', 'border-radius')).toBe('var(--button-radius)');
|
||||
expect(wins('button.fleet-card-menu-trigger', 'border-radius')).toBe('var(--button-radius)');
|
||||
expect(wins('button.row-action-trigger', 'min-height')).toBe('var(--icon-button-size)');
|
||||
});
|
||||
|
||||
it('drops the capsule radius from every primary action variant', async () => {
|
||||
const { rules } = await loadStylesheet();
|
||||
|
||||
const offenders = rules
|
||||
.filter((rule) =>
|
||||
rule.selector
|
||||
.split(',')
|
||||
.some((entry) => /(^|[\s>+~])\.primary-action(?![-\w])/.test(entry.trim())),
|
||||
)
|
||||
.filter((rule) => /999px|50%/.test(Object.values(rule.declarations).join(' ')));
|
||||
|
||||
expect(offenders.map((rule) => rule.selector)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// jsdom can read custom properties, but computed values that inherit through multiple
|
||||
// layers are easier to audit when the stylesheet is flattened to literals first.
|
||||
export function flatten(css: string): string {
|
||||
const tokens: Record<string, string> = {};
|
||||
const root = /:root\s*\{([^}]*)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = root.exec(css))) {
|
||||
for (const declaration of match[1]!.split(';')) {
|
||||
const separator = declaration.indexOf(':');
|
||||
if (separator === -1) continue;
|
||||
const property = declaration.slice(0, separator).trim();
|
||||
const value = declaration.slice(separator + 1).trim();
|
||||
if (property.startsWith('--')) tokens[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
function resolve(name: string, seen: ReadonlySet<string> = new Set()): string {
|
||||
const cacheKey = `${name}\u0000${[...seen].join('\u0001')}`;
|
||||
if (resolved[cacheKey] !== undefined) return resolved[cacheKey]!;
|
||||
if (seen.has(name)) return '';
|
||||
const raw = tokens[name];
|
||||
if (raw === undefined) return '';
|
||||
return substitute(raw, new Set(seen).add(name));
|
||||
}
|
||||
|
||||
function substitute(text: string, seen: ReadonlySet<string> = new Set()): string {
|
||||
let output = text;
|
||||
for (let guard = 0; guard < 12 && /var\(/.test(output); guard += 1) {
|
||||
output = output.replace(
|
||||
/var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^()]*))?\)/gi,
|
||||
(_, name: string, fallback?: string) =>
|
||||
resolve(name, seen) || (fallback ? substitute(fallback, seen) : ''),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return substitute(css.replace(/^\s*--[a-z0-9-]+\s*:[^;]*;\s*$/gm, ''));
|
||||
}
|
||||
Reference in New Issue
Block a user