──────────────────────────────────────────────────────────────────
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.
143 lines
3.8 KiB
TypeScript
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' };
|
|
}
|