From 3ec63428808d772a1bea3d740dfbfeda8de4fc2a Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Sun, 18 Oct 2026 19:22:19 +0100 Subject: [PATCH] feat(theme): HSL design tokens & WCAG AAA contrast validator (#1337)\n\n- Add semantic HSL color tokens to globals.css for dark/light themes\n- Implement contrast.ts with relative luminance, contrast ratio, WCAG AAA checks\n- Add accessibleForeground helper picking black/white for 7:1 ratio\n- Add validateSyntaxTokens for code highlight WCAG AAA compliance\n- Code syntax highlighting achieves WCAG AAA contrast in both modes\n\nCloses #1337 --- frontend/src/app/globals.css | 78 ++++++++++++++ frontend/src/lib/contrast.ts | 193 +++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 frontend/src/lib/contrast.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 44e1b132..d10f80ee 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -455,3 +455,81 @@ textarea { .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #ef4444; } + +/* ─── Issue #1337: Dynamic Theme Engine – HSL Design Tokens ──────────────── + * Semantic HSL color tokens used by the contrast-aware theming engine. + * HSL makes it trivial to derive accessible lightness steps while holding + * hue/saturation constant, which is what guarantees WCAG AAA contrast across + * dark and light modes. + * ─────────────────────────────────────────────────────────────────────── */ +:root { + /* Base brand hue (orange) held constant across themes; only lightness shifts. */ + --hsl-brand-h: 18; + --hsl-brand-s: 88%; + --hsl-brand-l-dark: 55%; + --hsl-brand-l-light: 45%; + + /* Surface HSL tokens – lightness is the theme axis. */ + --hsl-surface-bg-h: 210; + --hsl-surface-bg-s: 12%; + --hsl-surface-bg-l-dark: 8%; + --hsl-surface-bg-l-light: 98%; + + --hsl-surface-panel-h: 210; + --hsl-surface-panel-s: 10%; + --hsl-surface-panel-l-dark: 14%; + --hsl-surface-panel-l-light: 94%; + + /* Text HSL tokens – contrast is derived from the gap between text and bg L. */ + --hsl-text-h: 210; + --hsl-text-s: 8%; + --hsl-text-l-dark: 88%; + --hsl-text-l-light: 12%; + + --hsl-muted-h: 210; + --hsl-muted-s: 6%; + --hsl-muted-l-dark: 52%; + --hsl-muted-l-light: 42%; +} + +.dark { + --hsl-bg: hsl(var(--hsl-surface-bg-h), var(--hsl-surface-bg-s), var(--hsl-surface-bg-l-dark)); + --hsl-panel: hsl(var(--hsl-surface-panel-h), var(--hsl-surface-panel-s), var(--hsl-surface-panel-l-dark)); + --hsl-text: hsl(var(--hsl-text-h), var(--hsl-text-s), var(--hsl-text-l-dark)); + --hsl-muted: hsl(var(--hsl-muted-h), var(--hsl-muted-s), var(--hsl-muted-l-dark)); + --hsl-brand: hsl(var(--hsl-brand-h), var(--hsl-brand-s), var(--hsl-brand-l-dark)); +} + +html:not(.dark) { + --hsl-bg: hsl(var(--hsl-surface-bg-h), var(--hsl-surface-bg-s), var(--hsl-surface-bg-l-light)); + --hsl-panel: hsl(var(--hsl-surface-panel-h), var(--hsl-surface-panel-s), var(--hsl-surface-panel-l-light)); + --hsl-text: hsl(var(--hsl-text-h), var(--hsl-text-s), var(--hsl-text-l-light)); + --hsl-muted: hsl(var(--hsl-muted-h), var(--hsl-muted-s), var(--hsl-muted-l-light)); + --hsl-brand: hsl(var(--hsl-brand-h), var(--hsl-brand-s), var(--hsl-brand-l-light)); +} + +/* Code syntax highlight tokens tuned for WCAG AAA contrast on both themes. + * Foreground lightness is chosen so contrast against the code bg is >= 7:1. */ +.lesson-viewer pre, +.lesson-viewer code { + --code-hsl-bg-h: 210; + --code-hsl-bg-s: 10%; + --code-hsl-bg-l-dark: 6%; + --code-hsl-bg-l-light: 96%; + --code-hsl-fg-h: 210; + --code-hsl-fg-s: 6%; + --code-hsl-fg-l-dark: 92%; + --code-hsl-fg-l-light: 14%; +} + +.dark .lesson-viewer pre, +.dark .lesson-viewer code { + --code-bg: hsl(var(--code-hsl-bg-h), var(--code-hsl-bg-s), var(--code-hsl-bg-l-dark)); + --code-fg: hsl(var(--code-hsl-fg-h), var(--code-hsl-fg-s), var(--code-hsl-fg-l-dark)); +} + +html:not(.dark) .lesson-viewer pre, +html:not(.dark) .lesson-viewer code { + --code-bg: hsl(var(--code-hsl-bg-h), var(--code-hsl-bg-s), var(--code-hsl-bg-l-light)); + --code-fg: hsl(var(--code-hsl-fg-h), var(--code-hsl-fg-s), var(--code-hsl-fg-l-light)); +} diff --git a/frontend/src/lib/contrast.ts b/frontend/src/lib/contrast.ts new file mode 100644 index 00000000..6110d3f8 --- /dev/null +++ b/frontend/src/lib/contrast.ts @@ -0,0 +1,193 @@ +'use client'; + +// WCAG contrast ratio utilities for the Dynamic Theme Engine (FE-HARD-41). +// Provides relative luminance calculation, contrast ratio computation, and +// WCAG AAA (7:1) compliance checks for text and code syntax highlighting. + +/** + * Converts an 8-bit sRGB channel to linear RGB. + */ +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); +} + +/** + * Parses a hex color string (#RRGGBB or #RGB) into [r, g, b] 0-255. + * Returns null for invalid input. + */ +export function parseHex(color: string): [number, number, number] | null { + const hex = color.trim().replace(/^#/, ''); + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + return [r, g, b]; + } + if (hex.length === 6) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return [r, g, b]; + } + return null; +} + +/** + * Converts an RGB color to HSL [h, s, l] where h is 0-360, s and l are 0-1. + */ +export function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + const rr = r / 255; + const gg = g / 255; + const bb = b / 255; + const max = Math.max(rr, gg, bb); + const min = Math.min(rr, gg, bb); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rr: + h = (gg - bb) / d + (gg < bb ? 6 : 0); + break; + case gg: + h = (bb - rr) / d + 2; + break; + case bb: + h = (rr - gg) / d + 4; + break; + } + h *= 60; + if (h < 0) h += 360; + } + + return [h, s, l]; +} + +/** + * Converts HSL [h, s, l] to RGB [r, g, b] 0-255. + */ +export function hslToRgb(h: number, s: number, l: number): [number, number, number] { + let r = 0; + let g = 0; + let bb = 0; + + if (s === 0) { + r = g = bb = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h / 360 + 1 / 3); + g = hue2rgb(p, q, h / 360); + bb = hue2rgb(p, q, h / 360 - 1 / 3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(bb * 255)]; +} + +/** + * Returns the relative luminance of an sRGB color (WCAG definition). + */ +export function relativeLuminance(color: string): number | null { + const rgb = parseHex(color); + if (!rgb) return null; + const [r, g, b] = rgb.map(sRGBToLinear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** + * Returns the WCAG contrast ratio between two colors (1 to 21). + * Returns null if either color cannot be parsed. + */ +export function contrastRatio(foreground: string, background: string): number | null { + const lum1 = relativeLuminance(foreground); + const lum2 = relativeLuminance(background); + if (lum1 === null || lum2 === null) return null; + const lighter = Math.max(lum1, lum2); + const darker = Math.min(lum1, lum2); + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * WCAG levels: AA normal text = 4.5:1, AA large text = 3:1, AAA normal = 7:1, AAA large = 4.5:1. + */ +export const WCAG = { + AA_NORMAL: 4.5, + AA_LARGE: 3.0, + AAA_NORMAL: 7.0, + AAA_LARGE: 4.5, +} as const; + +/** + * Checks whether a foreground/background pair meets a given WCAG contrast ratio. + */ +export function meetsContrast( + foreground: string, + background: string, + level: keyof typeof WCAG +): { ratio: number | null; passes: boolean } { + const ratio = contrastRatio(foreground, background); + return { ratio, passes: ratio !== null && ratio >= WCAG[level] }; +} + +/** + * Given a background color, returns a foreground color (black or white) that + * satisfies the requested WCAG level. + */ +export function accessibleForeground( + background: string, + level: keyof typeof WCAG = 'AAA_NORMAL' +): string { + const ratioBlack = contrastRatio('#000000', background); + const ratioWhite = contrastRatio('#ffffff', background); + const target = WCAG[level]; + + if (ratioBlack !== null && ratioWhite !== null) { + // Pick the color with the higher ratio; prefer the one that passes. + const blackPasses = ratioBlack >= target; + const whitePasses = ratioWhite >= target; + if (blackPasses && whitePasses) { + return ratioBlack >= ratioWhite ? '#000000' : '#ffffff'; + } + if (blackPasses) return '#000000'; + if (whitePasses) return '#ffffff'; + // Neither passes: choose the higher-contrast option anyway. + return ratioBlack >= ratioWhite ? '#000000' : '#ffffff'; + } + return '#000000'; +} + +/** + * Validates a set of code-syntax highlight token colors against a background + * for WCAG AAA compliance. Returns a report of which tokens pass/fail. + */ +export function validateSyntaxTokens( + tokens: Record, + background: string +): Record { + const report: Record = + {}; + for (const [token, color] of Object.entries(tokens)) { + const { ratio, passes } = meetsContrast(color, background, 'AAA_NORMAL'); + const entry: { ratio: number | null; passesAAA: boolean; fixed?: string } = { + ratio, + passesAAA: passes, + }; + if (!passes && ratio !== null) { + entry.fixed = accessibleForeground(background, 'AAA_NORMAL'); + } + report[token] = entry; + } + return report; +}