From 4af06f732bd5f1b7c66c820590f0784d60b076f1 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 17:23:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(generative-theme):=20@bytelyst/generative-?= =?UTF-8?q?theme@0.1.0=20=E2=80=94=20Wave=2013.E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/generative-theme/package.json | 34 ++++ .../src/__tests__/theme.test.ts | 150 ++++++++++++++++++ packages/generative-theme/src/apply.ts | 45 ++++++ packages/generative-theme/src/contrast.ts | 97 +++++++++++ packages/generative-theme/src/generate.ts | 142 +++++++++++++++++ packages/generative-theme/src/index.ts | 33 ++++ packages/generative-theme/src/types.ts | 60 +++++++ packages/generative-theme/tsconfig.json | 11 ++ packages/generative-theme/vitest.config.ts | 2 + 9 files changed, 574 insertions(+) create mode 100644 packages/generative-theme/package.json create mode 100644 packages/generative-theme/src/__tests__/theme.test.ts create mode 100644 packages/generative-theme/src/apply.ts create mode 100644 packages/generative-theme/src/contrast.ts create mode 100644 packages/generative-theme/src/generate.ts create mode 100644 packages/generative-theme/src/index.ts create mode 100644 packages/generative-theme/src/types.ts create mode 100644 packages/generative-theme/tsconfig.json create mode 100644 packages/generative-theme/vitest.config.ts diff --git a/packages/generative-theme/package.json b/packages/generative-theme/package.json new file mode 100644 index 00000000..21c62dc8 --- /dev/null +++ b/packages/generative-theme/package.json @@ -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" + } +} diff --git a/packages/generative-theme/src/__tests__/theme.test.ts b/packages/generative-theme/src/__tests__/theme.test.ts new file mode 100644 index 00000000..295a2db3 --- /dev/null +++ b/packages/generative-theme/src/__tests__/theme.test.ts @@ -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 => ({ + 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(''); + }); +}); diff --git a/packages/generative-theme/src/apply.ts b/packages/generative-theme/src/apply.ts new file mode 100644 index 00000000..a903f6e6 --- /dev/null +++ b/packages/generative-theme/src/apply.ts @@ -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); + } + }; +} diff --git a/packages/generative-theme/src/contrast.ts b/packages/generative-theme/src/contrast.ts new file mode 100644 index 00000000..1ec753ad --- /dev/null +++ b/packages/generative-theme/src/contrast.ts @@ -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)), + }; +} diff --git a/packages/generative-theme/src/generate.ts b/packages/generative-theme/src/generate.ts new file mode 100644 index 00000000..8ae1a040 --- /dev/null +++ b/packages/generative-theme/src/generate.ts @@ -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 { + 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' }; +} diff --git a/packages/generative-theme/src/index.ts b/packages/generative-theme/src/index.ts new file mode 100644 index 00000000..b73f0aac --- /dev/null +++ b/packages/generative-theme/src/index.ts @@ -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'; diff --git a/packages/generative-theme/src/types.ts b/packages/generative-theme/src/types.ts new file mode 100644 index 00000000..81049593 --- /dev/null +++ b/packages/generative-theme/src/types.ts @@ -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; + /** + * 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'; +} diff --git a/packages/generative-theme/tsconfig.json b/packages/generative-theme/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/generative-theme/tsconfig.json @@ -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"] +} diff --git a/packages/generative-theme/vitest.config.ts b/packages/generative-theme/vitest.config.ts new file mode 100644 index 00000000..73b69c6f --- /dev/null +++ b/packages/generative-theme/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });