Some checks failed
CI / Unit & Component Tests (push) Failing after 2m1s
CI / OCR Service Tests (push) Successful in 28s
CI / Backend Unit Tests (push) Successful in 6m21s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
Adds the Mappe design handoff as in-repo ground truth and closes out the design-token foundation: --radius-sm/md/full + --shadow-sm/md tokens (distinct dark values in both dark blocks) and the canonical 10-color $lib/shared/avatarPalette.ts (AA-darkened sage/amber/sand swatches), guarded by avatarPalette.spec.ts. Closes #854.
51 lines
1.7 KiB
TypeScript
51 lines
1.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { AVATAR_PALETTE } from './avatarPalette';
|
|
|
|
// ─── WCAG 2.1 relative-luminance / contrast (1.4.3) ──────────────────────────
|
|
// White initials sit on every palette color (DESIGN_RULES §5). The avatar
|
|
// background is theme-invariant (same hex in light and dark), so a single
|
|
// white-on-color check covers both themes.
|
|
|
|
function srgbToLinear(channel: number): number {
|
|
const c = channel / 255;
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
function relativeLuminance(hex: string): number {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
}
|
|
|
|
function contrastRatio(a: string, b: string): number {
|
|
const la = relativeLuminance(a);
|
|
const lb = relativeLuminance(b);
|
|
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
}
|
|
|
|
const WHITE = '#ffffff';
|
|
|
|
describe('AVATAR_PALETTE', () => {
|
|
it('has exactly 10 colors (DESIGN_RULES §5)', () => {
|
|
expect(AVATAR_PALETTE).toHaveLength(10);
|
|
});
|
|
|
|
it('is all lowercase 6-digit hex', () => {
|
|
for (const c of AVATAR_PALETTE) {
|
|
expect(c).toMatch(/^#[0-9a-f]{6}$/);
|
|
}
|
|
});
|
|
|
|
it('has no duplicate colors (each person color is distinct)', () => {
|
|
expect(new Set(AVATAR_PALETTE).size).toBe(AVATAR_PALETTE.length);
|
|
});
|
|
|
|
it('every color meets WCAG AA (>=4.5:1) against white initials in both themes', () => {
|
|
for (const c of AVATAR_PALETTE) {
|
|
expect(contrastRatio(c, WHITE)).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
});
|
|
});
|