Brand-prompt → Tier-2 token-override generator. Local deterministic
generator by default; pluggable async LLM hook. WCAG-correct
contrast enforcement with AA / AAA lock policies.
──────────────────────────────────────────────────────────────────
Module surface
──────────────────────────────────────────────────────────────────
generateThemeFromPrompt(prompt, opts?)
- opts.generate: optional async LLM hook (host wires real LLM)
- opts.enforce: 'aa' (default) | 'aaa' | 'off'
- Always runs the contrast pass when enforce !== 'off'
- WAVE 13.E.1
localGenerate(prompt)
- Synchronous; 7 hand-curated palettes (midnight, citrus,
forest, ocean, rose, graphite, violet) + default fallback
- Keyword-matched via regex; deterministic for caching
Contrast utilities (Wave 13.E.2):
parseHex / toHex — input + output round-trip safe
relativeLuminance — WCAG 2.x luminance
contrast(a, b) — pair contrast ratio
report(fg, bg) — { ratio, aa, aaa }
auditTheme(theme) — 4 canonical text/accent pairings
adjustForContrast(fg,bg,t) — iteratively darken/lighten until ≥ t
enforceContrast(theme,'aa') — returns adjusted ThemeProposal
applyTheme(theme, target?)
- Writes 11 --bl-* custom properties onto the target element
- Returns a tear-down fn (saved prior values, restored on call)
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ 18/18 tests passing (hex / contrast math / generator /
enforcement / applyTheme cleanup)
✓ tsc build clean
✓ Zero runtime deps, pure functions where possible
──────────────────────────────────────────────────────────────────
Roadmap (lands in subsequent commit)
──────────────────────────────────────────────────────────────────
13.E.1 Deterministic prompt → palette generator
13.E.2 Contrast checker + AA / AAA enforcement
Showcase route /futurism/theme-studio (MAG.5) lands in paired
showcase commit.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import type { ContrastCheck, ContrastReport, ThemeProposal } from './types.js';
|
|
|
|
/** Parse `#rgb` / `#rrggbb` → `{ r, g, b }` 0..255. Throws on invalid. */
|
|
export function parseHex(hex: string): { r: number; g: number; b: number } {
|
|
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
if (!m) throw new Error(`Invalid hex colour: ${hex}`);
|
|
let body = m[1]!;
|
|
if (body.length === 3) {
|
|
body = body.split('').map((ch) => ch + ch).join('');
|
|
}
|
|
const num = parseInt(body, 16);
|
|
return {
|
|
r: (num >> 16) & 0xff,
|
|
g: (num >> 8) & 0xff,
|
|
b: num & 0xff,
|
|
};
|
|
}
|
|
|
|
/** Render `{ r, g, b }` → `#rrggbb`. */
|
|
export function toHex(r: number, g: number, b: number): string {
|
|
const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
|
return `#${c(r)}${c(g)}${c(b)}`;
|
|
}
|
|
|
|
/** Relative luminance per WCAG 2.x. */
|
|
export function relativeLuminance(hex: string): number {
|
|
const { r, g, b } = parseHex(hex);
|
|
const f = (v: number) => {
|
|
const s = v / 255;
|
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
};
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
}
|
|
|
|
/** WCAG contrast ratio for two hex colours. */
|
|
export function contrast(a: string, b: string): number {
|
|
const la = relativeLuminance(a);
|
|
const lb = relativeLuminance(b);
|
|
const lo = Math.min(la, lb);
|
|
const hi = Math.max(la, lb);
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
}
|
|
|
|
export function report(fg: string, bg: string): ContrastReport {
|
|
const ratio = contrast(fg, bg);
|
|
return { ratio, aa: ratio >= 4.5, aaa: ratio >= 7 };
|
|
}
|
|
|
|
/** Audit the canonical text/accent pairings in a theme proposal. */
|
|
export function auditTheme(theme: ThemeProposal): ContrastCheck {
|
|
return {
|
|
textPrimaryOnSurface: report(theme.textPrimary, theme.surface),
|
|
textSecondaryOnSurface: report(theme.textSecondary, theme.surface),
|
|
textPrimaryOnSurfaceCard: report(theme.textPrimary, theme.surfaceCard),
|
|
accentOnSurface: report(theme.accent, theme.surface),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Iteratively darken or lighten `fg` until `contrast(fg, bg) >=
|
|
* target`. Caps at black / white. Pure function.
|
|
*/
|
|
export function adjustForContrast(fg: string, bg: string, target: number): string {
|
|
let current = fg;
|
|
// Decide direction once: if bg is light, push fg darker; else lighter.
|
|
const bgLum = relativeLuminance(bg);
|
|
const darker = bgLum > 0.5;
|
|
for (let i = 0; i < 30; i++) {
|
|
if (contrast(current, bg) >= target) return current;
|
|
current = shift(current, darker ? -0.04 : 0.04);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function shift(hex: string, delta: number): string {
|
|
const { r, g, b } = parseHex(hex);
|
|
const k = delta * 255;
|
|
return toHex(r + k, g + k, b + k);
|
|
}
|
|
|
|
/**
|
|
* Enforce a contrast policy across the audited pairings, returning a
|
|
* new ThemeProposal where any failing fg has been nudged toward black /
|
|
* white until it passes.
|
|
*/
|
|
export function enforceContrast(
|
|
theme: ThemeProposal,
|
|
policy: 'aa' | 'aaa' = 'aa',
|
|
): ThemeProposal {
|
|
const target = policy === 'aaa' ? 7 : 4.5;
|
|
return {
|
|
...theme,
|
|
textPrimary: adjustForContrast(theme.textPrimary, theme.surface, target),
|
|
textSecondary: adjustForContrast(theme.textSecondary, theme.surface, Math.max(3, target - 1.5)),
|
|
accent: adjustForContrast(theme.accent, theme.surface, Math.max(3, target - 1.5)),
|
|
};
|
|
}
|