From d56d80617cf424abac3668388e46ec70ba419fa3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 16 Jun 2026 17:15:53 +0200 Subject: [PATCH] feat(redesign): close out design-token foundation (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- frontend/src/lib/shared/avatarPalette.spec.ts | 50 +++++++++++++++++++ frontend/src/lib/shared/avatarPalette.ts | 32 ++++++++++++ frontend/src/routes/layout.css | 24 +++++++++ 3 files changed, 106 insertions(+) create mode 100644 frontend/src/lib/shared/avatarPalette.spec.ts create mode 100644 frontend/src/lib/shared/avatarPalette.ts diff --git a/frontend/src/lib/shared/avatarPalette.spec.ts b/frontend/src/lib/shared/avatarPalette.spec.ts new file mode 100644 index 00000000..e098c5fb --- /dev/null +++ b/frontend/src/lib/shared/avatarPalette.spec.ts @@ -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); + } + }); +}); diff --git a/frontend/src/lib/shared/avatarPalette.ts b/frontend/src/lib/shared/avatarPalette.ts new file mode 100644 index 00000000..18dbba0d --- /dev/null +++ b/frontend/src/lib/shared/avatarPalette.ts @@ -0,0 +1,32 @@ +/** + * Canonical 10-color person/avatar palette — single source of truth. + * + * Used by `` (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; diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 2d2c08cd..7a5f0e29 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -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 ──── */