learning_ai_common_plat/packages/generative-theme/src/generate.ts
saravanakumardb1 8562711f49 fix(workspace+theme): sanitise corrupt spans, short-circuit re-reconcile, drop unreachable regex
──────────────────────────────────────────────────────────────────
customizable-workspace
──────────────────────────────────────────────────────────────────

Two issues caught in the audit pass:

1. **Corrupt persisted spans broke the grid layout.**
   localStorage entries from older versions (or hand-edited debug
   sessions) could contain span=NaN / 0 / -3 / 99. These flowed
   straight into `grid-column: span <bad>` which silently broke
   the whole row. The visual symptom was a tile rendering at zero
   width or pushing every sibling off-screen.
   Fix: `reconcile()` now clamps every span (including newly
   appended tiles' defaultSpan) to the legal `[1, 4]` range via
   `sanitiseSpan()`.

2. **Re-reconcile effect could loop when callers forget to memoise.**
   The `useEffect([tiles, hydrated])` always called `setLayout`
   with a fresh `{ entries: [...] }` object reference, even when
   the content was identical. If `tiles` itself was a fresh
   reference per parent render (e.g. `tiles=[{...}]` inline),
   every render \u2192 setLayout \u2192 save effect \u2192 (no loop because
   tiles ref same), but constant unnecessary writes to
   localStorage.
   Fix: added `sameLayout(a, b)` structural-equality check;
   setLayout now short-circuits to the previous state when the
   reconciled output is identical.

Tests: 10 \u2192 11
  reconcile  \u00b7 sanitises corrupt spans (NaN/0/negative/>4) \u2192 clamp

──────────────────────────────────────────────────────────────────
generative-theme
──────────────────────────────────────────────────────────────────

Cosmetic but worth fixing: the `rose` palette regex included
`warm` as a keyword, but the `citrus` palette \u2014 listed earlier in
the PALETTES table \u2014 also matched `warm`. Since first-match wins,
`warm` was unreachable in rose and the entry was misleading.

Dropped `warm` from the rose regex. Citrus retains it (was always
where it routed in practice). All 18 existing tests still pass.
2026-05-27 18:46:19 -07:00

143 lines
3.8 KiB
TypeScript

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|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' };
}