feat(redesign): token foundation close-out + design handoff (#854)
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
nightly / deploy-staging (push) Successful in 4m43s
nightly / npm-audit (push) Failing after 19s
Renovate / renovate (push) Failing after 38s

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.
This commit was merged in pull request #884.
This commit is contained in:
2026-06-16 17:17:27 +02:00
parent 3ba5ae982b
commit fa510f3991
49 changed files with 7281 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
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);
}
});
});

View File

@@ -0,0 +1,32 @@
/**
* Canonical 10-color person/avatar palette — single source of truth.
*
* Used by `<Avatar>` (issue #855), hashed by name so the same person renders the
* same color on every screen (DESIGN_RULES §5). Do not duplicate these values in
* CSS or another module; import this constant instead.
*
* White Montserrat initials sit on each color, so every swatch must meet WCAG AA
* (≥4.5:1) for normal text — avatar initials are 700-weight but ≤16px, below the
* large-text threshold. The background is theme-invariant (identical hex in light
* and dark, white initials in both), so one white-on-color check covers both
* themes. Guarded by `avatarPalette.spec.ts`.
*
* Three §1 brand hues failed white-on-color and use AA-darkened variants here;
* the bright originals remain the decorative tag-dot colors in `layout.css`
* (tag dots carry no text, so the contrast floor does not apply to them):
* sage #5a8a6a → #527e61 (3.98 → 4.65:1)
* amber #c17a00 → #a46800 (3.47 → 4.61:1)
* sand #9a8040 → #897239 (3.79 → 4.64:1)
*/
export const AVATAR_PALETTE = [
'#527e61', // sage (AA-adjusted from #5a8a6a)
'#a0522d', // sienna
'#a46800', // amber (AA-adjusted from #c17a00)
'#607080', // slate
'#7a4f9a', // violet
'#c0446e', // rose
'#3060b0', // cobalt
'#4a7a3a', // moss
'#897239', // sand (AA-adjusted from #9a8040)
'#c05540' // coral
] as const;