learning_ai_common_plat/packages/generative-theme/src/contrast.ts
saravanakumardb1 4af06f732b feat(generative-theme): @bytelyst/generative-theme@0.1.0 — Wave 13.E
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.
2026-05-27 17:23:06 -07:00

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)),
};
}