feat(redesign): close out design-token foundation (#854)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m1s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m32s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m1s
CI / OCR Service Tests (pull_request) Successful in 26s
CI / Backend Unit Tests (pull_request) Successful in 6m32s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 19s
Completes DESIGN_RULES §1 so the rest of the Mappe shared components have a full token substrate: - Add --radius-sm/md/full and --shadow-sm/md as first-class :root tokens in layout.css. Radius is theme-invariant; --shadow-sm/md get distinct, stronger dark values in BOTH dark blocks (the @media and [data-theme='dark'] selectors stay in sync) because the light navy-on-sand shadow is invisible on dark surfaces. - Add $lib/shared/avatarPalette.ts: the canonical 10-color person/avatar palette as a single exported constant (single source of truth for <Avatar>, issue #855). Three §5 hues failed the >=4.5:1 white-initials contrast floor and ship as AA-darkened variants (sage #527e61, amber #a46800, sand #897239); the bright hues stay the decorative tag-dot colors in layout.css. Guarded by avatarPalette.spec.ts (length, hex, distinct, WCAG AA). Tag-sand naming and the theme storage key were already correct in layout.css/app.html; this only closed the remaining token gaps. No consumer rewiring (deferred to #855). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
50
frontend/src/lib/shared/avatarPalette.spec.ts
Normal file
50
frontend/src/lib/shared/avatarPalette.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
32
frontend/src/lib/shared/avatarPalette.ts
Normal file
32
frontend/src/lib/shared/avatarPalette.ts
Normal 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;
|
||||
@@ -201,6 +201,20 @@
|
||||
rows without competing with node fills. 8% on light surface ≈ #ECF6F4
|
||||
(~1.04:1 vs canvas — decorative carve-out). */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.08);
|
||||
|
||||
/* Radius — theme-invariant (DESIGN_RULES §1). sm is the default (cards,
|
||||
inputs, buttons, segmented control); md is tag chips/badges only; full is
|
||||
avatars, dots, pills. */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Elevation — resting cards (sm) and dropdowns/menus (md). Dark mode defines
|
||||
its own values in both dark blocks below: a navy-on-sand shadow is nearly
|
||||
invisible on dark surfaces, so dark cards need a stronger shadow to keep
|
||||
the 3px-mint-top-border card signature lifted. */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
@@ -299,6 +313,11 @@
|
||||
--c-warning-bg: #2a2113;
|
||||
--c-warning-border: #6d5417;
|
||||
--c-warning-text: #fbd38d;
|
||||
|
||||
/* Elevation — stronger than light mode; the light shadows are invisible
|
||||
on dark navy surfaces. KEEP IN SYNC with :root[data-theme='dark']. */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
|
||||
--shadow-md: 0 4px 8px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +409,11 @@
|
||||
--c-warning-bg: #2a2113;
|
||||
--c-warning-border: #6d5417;
|
||||
--c-warning-text: #fbd38d;
|
||||
|
||||
/* Elevation — stronger than light mode; the light shadows are invisible
|
||||
on dark navy surfaces. KEEP IN SYNC with the @media block above. */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
|
||||
--shadow-md: 0 4px 8px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
Reference in New Issue
Block a user