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:
parent
2eaec32849
commit
4af06f732b
34
packages/generative-theme/package.json
Normal file
34
packages/generative-theme/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
150
packages/generative-theme/src/__tests__/theme.test.ts
Normal file
150
packages/generative-theme/src/__tests__/theme.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
packages/generative-theme/src/apply.ts
Normal file
45
packages/generative-theme/src/apply.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
97
packages/generative-theme/src/contrast.ts
Normal file
97
packages/generative-theme/src/contrast.ts
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
142
packages/generative-theme/src/generate.ts
Normal file
142
packages/generative-theme/src/generate.ts
Normal 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' };
|
||||||
|
}
|
||||||
33
packages/generative-theme/src/index.ts
Normal file
33
packages/generative-theme/src/index.ts
Normal 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';
|
||||||
60
packages/generative-theme/src/types.ts
Normal file
60
packages/generative-theme/src/types.ts
Normal 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';
|
||||||
|
}
|
||||||
11
packages/generative-theme/tsconfig.json
Normal file
11
packages/generative-theme/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
2
packages/generative-theme/vitest.config.ts
Normal file
2
packages/generative-theme/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||||
Loading…
Reference in New Issue
Block a user