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.
This commit is contained in:
saravanakumardb1 2026-05-27 17:23:06 -07:00
parent 2eaec32849
commit 4af06f732b
9 changed files with 574 additions and 0 deletions

View File

@ -0,0 +1,34 @@
{
"name": "@bytelyst/generative-theme",
"version": "0.1.0",
"type": "module",
"description": "Brand-prompt → token-override generator. Local deterministic generator by default; pluggable async LLM hook. WCAG contrast-checked + AAA-lock enabled.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import {
generateThemeFromPrompt,
localGenerate,
} from '../generate.js';
import {
parseHex,
toHex,
relativeLuminance,
contrast,
report,
auditTheme,
adjustForContrast,
enforceContrast,
} from '../contrast.js';
import { applyTheme } from '../apply.js';
describe('hex parsing', () => {
it('parses #rgb and #rrggbb', () => {
expect(parseHex('#000')).toEqual({ r: 0, g: 0, b: 0 });
expect(parseHex('#fff')).toEqual({ r: 255, g: 255, b: 255 });
expect(parseHex('#ff0080')).toEqual({ r: 255, g: 0, b: 128 });
});
it('throws on invalid hex', () => {
expect(() => parseHex('not-a-colour')).toThrow();
});
it('toHex round-trips', () => {
expect(toHex(255, 0, 128)).toBe('#ff0080');
});
it('toHex clamps out-of-range', () => {
expect(toHex(300, -50, 128)).toBe('#ff0080');
});
});
describe('contrast math', () => {
it('relativeLuminance: pure white = 1, pure black = 0', () => {
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5);
expect(relativeLuminance('#000000')).toBeCloseTo(0, 5);
});
it('contrast: black on white = 21', () => {
expect(contrast('#000', '#fff')).toBeCloseTo(21, 1);
});
it('report: 4.5+ marks AA but not AAA', () => {
const r = report('#444', '#fff');
expect(r.aa).toBe(true);
expect(r.aaa).toBe(true); // #444 on #fff is actually about 9.7
});
it('report: low contrast fails both', () => {
const r = report('#ddd', '#fff');
expect(r.aa).toBe(false);
expect(r.aaa).toBe(false);
});
});
describe('adjustForContrast', () => {
it('darkens fg against a light bg until target met', () => {
const out = adjustForContrast('#cccccc', '#ffffff', 4.5);
expect(contrast(out, '#ffffff')).toBeGreaterThanOrEqual(4.5);
});
it('lightens fg against a dark bg until target met', () => {
const out = adjustForContrast('#555555', '#000000', 4.5);
expect(contrast(out, '#000000')).toBeGreaterThanOrEqual(4.5);
});
});
describe('localGenerate (deterministic vibes)', () => {
it('returns a palette for known keywords', () => {
const t = localGenerate('A moody midnight noir agent');
expect(t.name).toBe('midnight');
expect(t.accent).toMatch(/^#/);
});
it('falls back to default on no match', () => {
const t = localGenerate('definitely-not-a-keyword');
expect(t.name).toBe('default');
expect(t.accent).toBe('#6366f1');
});
it('different prompts route to different palettes', () => {
const a = localGenerate('citrus sunrise warm');
const b = localGenerate('forest sage moss');
expect(a.name).not.toBe(b.name);
});
});
describe('generateThemeFromPrompt', () => {
it('runs the local generator + enforces AA by default', async () => {
const t = await generateThemeFromPrompt('citrus sunrise');
const audit = auditTheme(t);
expect(audit.textPrimaryOnSurface.aa).toBe(true);
});
it('respects a custom async generate fn', async () => {
const stub = async (): Promise<typeof t> => ({
name: 'stub',
accent: '#888888',
surface: '#ffffff',
surfaceCard: '#ffffff',
surfaceMuted: '#eeeeee',
textPrimary: '#bbbbbb', // intentionally low contrast
textSecondary: '#cccccc',
});
const t = await generateThemeFromPrompt('whatever', {
generate: stub,
enforce: 'aa',
});
// After enforcement, primary text contrast should pass AA.
expect(contrast(t.textPrimary, t.surface)).toBeGreaterThanOrEqual(4.5);
});
it('enforce=off returns raw generator output', async () => {
const raw = await generateThemeFromPrompt('whatever', {
enforce: 'off',
generate: async () => ({
accent: '#bbbbbb',
surface: '#ffffff',
surfaceCard: '#ffffff',
surfaceMuted: '#eeeeee',
textPrimary: '#cccccc',
textSecondary: '#dddddd',
}),
});
expect(raw.textPrimary).toBe('#cccccc');
});
});
describe('enforceContrast', () => {
it('does not corrupt already-passing palettes', () => {
const base = localGenerate('forest');
const out = enforceContrast(base, 'aa');
expect(out.surface).toBe(base.surface);
expect(out.surfaceCard).toBe(base.surfaceCard);
});
});
describe('applyTheme', () => {
it('sets + cleans up CSS custom properties on the target', () => {
const target = document.createElement('div');
const restore = applyTheme(
{
accent: '#7c3aed',
surface: '#ffffff',
surfaceCard: '#ffffff',
surfaceMuted: '#eeeeee',
textPrimary: '#111111',
textSecondary: '#666666',
},
target,
);
expect(target.style.getPropertyValue('--bl-accent')).toBe('#7c3aed');
restore();
expect(target.style.getPropertyValue('--bl-accent')).toBe('');
});
});

View File

@ -0,0 +1,45 @@
import type { ThemeProposal } from './types.js';
/**
* Apply a `ThemeProposal` as CSS custom-property overrides on a
* target element. Defaults to `document.documentElement` so the
* entire page re-tints in one call.
*
* Returns a function that *removes* the overrides useful for
* temporary preview surfaces (e.g. the showcase studio).
*/
export function applyTheme(
theme: ThemeProposal,
target?: HTMLElement,
): () => void {
if (typeof document === 'undefined') return () => {};
const el = target ?? document.documentElement;
const mapping: Array<[keyof ThemeProposal, string]> = [
['accent', '--bl-accent'],
['surface', '--bl-surface'],
['surfaceCard', '--bl-surface-card'],
['surfaceMuted', '--bl-surface-muted'],
['textPrimary', '--bl-text-primary'],
['textSecondary', '--bl-text-secondary'],
['success', '--bl-success'],
['warning', '--bl-warning'],
['danger', '--bl-danger'],
['info', '--bl-info'],
['border', '--bl-border'],
];
const previous = mapping.map(
([_, css]) => [css, el.style.getPropertyValue(css)] as const,
);
for (const [k, css] of mapping) {
const v = theme[k];
if (typeof v === 'string') {
el.style.setProperty(css, v);
}
}
return () => {
for (const [css, prev] of previous) {
if (prev) el.style.setProperty(css, prev);
else el.style.removeProperty(css);
}
};
}

View File

@ -0,0 +1,97 @@
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)),
};
}

View File

@ -0,0 +1,142 @@
import type { GenerationOptions, ThemeProposal } from './types.js';
import { enforceContrast } from './contrast.js';
/**
* Deterministic local "brand vibe → palette" generator. Maps prompt
* keywords to a hand-curated palette family, then derives the rest of
* the proposal from there.
*
* Wave 13.E.1. The default. Hosts can pass `opts.generate` to swap in
* a real LLM (or a fancier embedding-based generator) the contrast
* enforcement (Wave 13.E.2) runs after either path.
*/
const PALETTES: Array<{
test: RegExp;
name: string;
accent: string;
surface: string;
surfaceCard: string;
surfaceMuted: string;
textPrimary: string;
textSecondary: string;
}> = [
{
test: /\b(midnight|noir|deep|dark|nocturne|raven|space)\b/i,
name: 'midnight',
accent: '#7c8cff',
surface: '#0b0f1a',
surfaceCard: '#131a2b',
surfaceMuted: '#1c2440',
textPrimary: '#e7ecff',
textSecondary: '#9aa6cf',
},
{
test: /\b(citrus|sunshine|sunrise|amber|warm|orange|honey|gold)\b/i,
name: 'citrus',
accent: '#ff8a00',
surface: '#fff7eb',
surfaceCard: '#ffffff',
surfaceMuted: '#ffe0b8',
textPrimary: '#3b2a05',
textSecondary: '#7a5a18',
},
{
test: /\b(forest|leaf|moss|botanical|sage|jungle|emerald|verdant)\b/i,
name: 'forest',
accent: '#10b981',
surface: '#f0faf5',
surfaceCard: '#ffffff',
surfaceMuted: '#cce8d8',
textPrimary: '#0b3a23',
textSecondary: '#365c45',
},
{
test: /\b(ocean|sea|wave|aqua|coastal|maritime|blue|sky)\b/i,
name: 'ocean',
accent: '#0ea5e9',
surface: '#f0f8ff',
surfaceCard: '#ffffff',
surfaceMuted: '#cee5fb',
textPrimary: '#0a2b45',
textSecondary: '#3a5d7d',
},
{
test: /\b(rose|blossom|peach|pink|warm|romantic|gentle)\b/i,
name: 'rose',
accent: '#ec4899',
surface: '#fff5fa',
surfaceCard: '#ffffff',
surfaceMuted: '#fbd5e6',
textPrimary: '#46123a',
textSecondary: '#7b3f6b',
},
{
test: /\b(graphite|monochrome|brutalist|stark|grayscale|minimal)\b/i,
name: 'graphite',
accent: '#374151',
surface: '#f5f6f8',
surfaceCard: '#ffffff',
surfaceMuted: '#dcdfe5',
textPrimary: '#0d1219',
textSecondary: '#4b5563',
},
{
test: /\b(violet|purple|cosmos|cosmic|mystic|nebula|lavender)\b/i,
name: 'violet',
accent: '#8b5cf6',
surface: '#faf7ff',
surfaceCard: '#ffffff',
surfaceMuted: '#e5d8ff',
textPrimary: '#1f0d49',
textSecondary: '#5a4690',
},
];
const DEFAULT_PALETTE: ThemeProposal = {
name: 'default',
accent: '#6366f1',
surface: '#ffffff',
surfaceCard: '#ffffff',
surfaceMuted: '#f5f5f8',
textPrimary: '#0f172a',
textSecondary: '#475569',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#0ea5e9',
};
/**
* Generate (and contrast-check) a theme from a brand prompt.
*
* Defaults to a local deterministic mapper; pass `opts.generate` to
* swap in a real LLM. AA contrast is enforced by default; pass
* `enforce: 'aaa'` to lock the higher tier, or `'off'` to disable.
*/
export async function generateThemeFromPrompt(
prompt: string,
opts: GenerationOptions = {},
): Promise<ThemeProposal> {
const raw = await (opts.generate ? opts.generate(prompt) : localGenerate(prompt));
const enforce = opts.enforce ?? 'aa';
return enforce === 'off' ? raw : enforceContrast(raw, enforce);
}
/** Synchronous local generator — exposed for tests + previews. */
export function localGenerate(prompt: string): ThemeProposal {
for (const p of PALETTES) {
if (p.test.test(prompt)) {
return {
...DEFAULT_PALETTE,
name: p.name,
accent: p.accent,
surface: p.surface,
surfaceCard: p.surfaceCard,
surfaceMuted: p.surfaceMuted,
textPrimary: p.textPrimary,
textSecondary: p.textSecondary,
};
}
}
return { ...DEFAULT_PALETTE, name: 'default' };
}

View File

@ -0,0 +1,33 @@
/**
* @bytelyst/generative-theme brand-prompt Tier-2 token overrides.
*
* Wave 13.E. Defaults to a local deterministic generator (7 palette
* families: midnight · citrus · forest · ocean · rose · graphite ·
* violet); host can plug a real LLM via `GenerationOptions.generate`.
* Output is contrast-checked + AA / AAA-locked before return.
*/
export {
generateThemeFromPrompt,
localGenerate,
} from './generate.js';
export {
parseHex,
toHex,
relativeLuminance,
contrast,
report,
auditTheme,
adjustForContrast,
enforceContrast,
} from './contrast.js';
export { applyTheme } from './apply.js';
export type {
ContrastCheck,
ContrastReport,
GenerationOptions,
ThemeProposal,
} from './types.js';

View File

@ -0,0 +1,60 @@
/** A complete token proposal Tier-2 override of the canonical
* `var(--bl-*)` palette. All values are hex strings (#rrggbb).
*
* This is intentionally a flat, JSON-serialisable shape so the
* proposal can be persisted, transported via SSE, or stored in the
* `platform-service` per-product theme document.
*/
export interface ThemeProposal {
/** Optional human-readable name (e.g. `'midnight-citrus'`). */
name?: string;
/** Required: primary brand accent. */
accent: string;
/** Required: page background — paired against `text` for contrast. */
surface: string;
/** Required: card / elevated surface. */
surfaceCard: string;
/** Required: muted/divider background. */
surfaceMuted: string;
/** Required: primary text. Should pass AA against `surface`. */
textPrimary: string;
/** Required: secondary / muted text. */
textSecondary: string;
/** Optional semantic colours. */
success?: string;
warning?: string;
danger?: string;
info?: string;
/** Optional border colour (default derived from `surfaceMuted`). */
border?: string;
}
export interface ContrastReport {
/** WCAG ratio `(L1+0.05)/(L2+0.05)` — higher is better. */
ratio: number;
/** Passes WCAG AA (≥ 4.5 for normal text). */
aa: boolean;
/** Passes WCAG AAA (≥ 7 for normal text). */
aaa: boolean;
}
export interface ContrastCheck {
textPrimaryOnSurface: ContrastReport;
textSecondaryOnSurface: ContrastReport;
textPrimaryOnSurfaceCard: ContrastReport;
accentOnSurface: ContrastReport;
}
export interface GenerationOptions {
/**
* Optional async hook host wires its own LLM here. Falls back to
* the local deterministic generator when absent.
*/
generate?: (prompt: string) => Promise<ThemeProposal>;
/**
* Enforce AAA on text pairings. When violated, the contrast helper
* darkens / lightens the affected colour until AAA passes (or until
* we hit the rail at black / white).
*/
enforce?: 'aa' | 'aaa' | 'off';
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });