summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
committerChristian Cleberg <[email protected]>2026-04-18 22:32:53 -0500
commit580e798e16346eb894eb899edac82d2efdf40adc (patch)
tree6dec3b7db678f781138b40d34da37d21ac8fbe7d /src
downloadbrand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.gz
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.bz2
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.zip
initial commit
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx130
-rw-r--r--src/components/BrandDoc.tsx518
-rw-r--r--src/components/CopyButton.tsx31
-rw-r--r--src/components/InputPanel.tsx133
-rw-r--r--src/components/PackageSwitcher.tsx125
-rw-r--r--src/components/PreviewPanel.tsx116
-rw-r--r--src/components/SettingsPanel.tsx158
-rw-r--r--src/components/TokenInput.tsx85
-rw-r--r--src/engine/aiGenerator.ts138
-rw-r--r--src/engine/generator.ts957
-rw-r--r--src/hooks/usePackages.ts112
-rw-r--r--src/hooks/useSettings.ts41
-rw-r--r--src/hooks/useWorkspace.ts225
-rw-r--r--src/lib/clipboard.ts17
-rw-r--r--src/lib/export.ts326
-rw-r--r--src/lib/sanitize.ts141
-rw-r--r--src/main.tsx10
-rw-r--r--src/styles/globals.css1594
-rw-r--r--src/types.ts100
19 files changed, 4957 insertions, 0 deletions
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..bd43726
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,130 @@
+import { useState, useEffect } from 'react';
+import { useWorkspace } from './hooks/useWorkspace';
+import { usePackages } from './hooks/usePackages';
+import { useSettings } from './hooks/useSettings';
+import type { PackageSlot } from './hooks/usePackages';
+import { InputPanel } from './components/InputPanel';
+import { PreviewPanel } from './components/PreviewPanel';
+import { PackageSwitcher } from './components/PackageSwitcher';
+import { SettingsPanel } from './components/SettingsPanel';
+import type { BrandOutputs } from './types';
+
+interface WorkspaceShellProps {
+ storageKey: string;
+ slots: PackageSlot[];
+ activeId: string;
+ onSwitch: (id: string) => void;
+ onCreate: () => void;
+ onRemove: (id: string) => void;
+ onRename: (id: string, name: string) => void;
+ onDuplicate: (id: string) => void;
+ onOpenSettings: () => void;
+ aiEnabled: boolean;
+}
+
+function WorkspaceShell({
+ storageKey, slots, activeId,
+ onSwitch, onCreate, onRemove, onRename, onDuplicate,
+ onOpenSettings, aiEnabled,
+}: WorkspaceShellProps) {
+ const {
+ inputs, setInputs, outputs, isGenerating, generateMode, generateError,
+ locked, runGenerate, cancelGenerate, updateEdit, toggleLock,
+ undo, redo, canUndo, canRedo,
+ } = useWorkspace(storageKey);
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (!(e.metaKey || e.ctrlKey) || e.key !== 'z') return;
+ e.preventDefault();
+ if (e.shiftKey) redo();
+ else undo();
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [undo, redo]);
+
+ const handleEdit = <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) =>
+ updateEdit(key, value);
+
+ const generateLabel = isGenerating
+ ? 'Cancel'
+ : outputs
+ ? (aiEnabled ? `Regenerate` : 'Regenerate')
+ : (aiEnabled ? 'Generate with AI' : 'Generate');
+
+ return (
+ <>
+ <header className="app-header">
+ <div className="app-header-left">
+ <span className="app-wordmark">Brand<span>bench</span></span>
+ <PackageSwitcher
+ slots={slots} activeId={activeId}
+ onSwitch={onSwitch} onCreate={onCreate}
+ onRemove={onRemove} onRename={onRename} onDuplicate={onDuplicate}
+ />
+ </div>
+ <div className="app-header-right">
+ {outputs && (
+ <>
+ <button className="btn btn-ghost" onClick={undo} disabled={!canUndo} title="Undo (⌘Z)">Undo</button>
+ <button className="btn btn-ghost" onClick={redo} disabled={!canRedo} title="Redo (⌘⇧Z)">Redo</button>
+ </>
+ )}
+ {aiEnabled && (
+ <span className="ai-badge" title={`AI mode: ${generateMode}`}>AI</span>
+ )}
+ <button className="btn btn-ghost settings-open-btn" onClick={onOpenSettings} title="Settings">⚙</button>
+ </div>
+ </header>
+
+ <div className="app-shell">
+ <InputPanel
+ inputs={inputs}
+ onChange={setInputs}
+ onGenerate={isGenerating ? cancelGenerate : runGenerate}
+ isGenerating={isGenerating}
+ generateLabel={generateLabel}
+ />
+ <PreviewPanel
+ inputs={inputs}
+ outputs={outputs}
+ locked={locked}
+ onToggleLock={toggleLock}
+ onEdit={handleEdit}
+ isGenerating={isGenerating}
+ generateMode={generateMode}
+ generateError={generateError}
+ onGenerate={runGenerate}
+ />
+ </div>
+ </>
+ );
+}
+
+export default function App() {
+ const { slots, activeId, activeSlot, switchTo, createNew, remove, rename, duplicate } = usePackages();
+ const { settings, setSettings } = useSettings();
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ return (
+ <>
+ <WorkspaceShell
+ key={activeId}
+ storageKey={activeSlot.storageKey}
+ slots={slots} activeId={activeId}
+ onSwitch={switchTo} onCreate={createNew}
+ onRemove={remove} onRename={rename} onDuplicate={duplicate}
+ onOpenSettings={() => setSettingsOpen(true)}
+ aiEnabled={settings.enabled}
+ />
+ {settingsOpen && (
+ <SettingsPanel
+ settings={settings}
+ onChange={setSettings}
+ onClose={() => setSettingsOpen(false)}
+ />
+ )}
+ </>
+ );
+}
diff --git a/src/components/BrandDoc.tsx b/src/components/BrandDoc.tsx
new file mode 100644
index 0000000..fffea97
--- /dev/null
+++ b/src/components/BrandDoc.tsx
@@ -0,0 +1,518 @@
+import { useRef, useState } from 'react';
+import type { BrandInputs, BrandOutputs, ColorSwatch, LockedSections, Typography } from '../types';
+import { CopyButton } from './CopyButton';
+
+// ── Editable text ─────────────────────────────────────────────────────────────
+
+interface EditableProps {
+ value: string;
+ onChange: (v: string) => void;
+ multiline?: boolean;
+}
+
+function Editable({ value, onChange, multiline = false }: EditableProps) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+ const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
+
+ const commit = () => {
+ setEditing(false);
+ if (draft !== value) onChange(draft);
+ };
+
+ if (!editing) {
+ return (
+ <div
+ className="editable-text"
+ onClick={() => { setDraft(value); setEditing(true); }}
+ title="Click to edit"
+ style={{ whiteSpace: multiline ? 'pre-wrap' : 'normal', padding: '2px 4px', margin: '-2px -4px' }}
+ >
+ {value}
+ </div>
+ );
+ }
+
+ if (multiline) {
+ return (
+ <textarea
+ ref={ref as React.RefObject<HTMLTextAreaElement>}
+ autoFocus
+ className="editable-text editing"
+ value={draft}
+ onChange={e => setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setDraft(value); } }}
+ style={{
+ width: '100%',
+ resize: 'vertical',
+ minHeight: 80,
+ padding: '4px 6px',
+ fontFamily: 'inherit',
+ fontSize: 'inherit',
+ lineHeight: 1.65,
+ background: 'var(--bg-2)',
+ border: '1px solid var(--border-3)',
+ borderRadius: 3,
+ color: 'var(--text)',
+ outline: 'none',
+ }}
+ />
+ );
+ }
+
+ return (
+ <input
+ ref={ref as React.RefObject<HTMLInputElement>}
+ autoFocus
+ className="editable-text editing"
+ value={draft}
+ onChange={e => setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commit();
+ if (e.key === 'Escape') { setEditing(false); setDraft(value); }
+ }}
+ style={{
+ width: '100%',
+ padding: '2px 6px',
+ fontFamily: 'inherit',
+ fontSize: 'inherit',
+ background: 'var(--bg-2)',
+ border: '1px solid var(--border-3)',
+ borderRadius: 3,
+ color: 'var(--text)',
+ outline: 'none',
+ }}
+ />
+ );
+}
+
+// ── Section wrapper ───────────────────────────────────────────────────────────
+
+interface SectionProps {
+ title: string;
+ locked: boolean;
+ onToggleLock: () => void;
+ copyText?: string;
+ children: React.ReactNode;
+}
+
+function Section({ title, locked, onToggleLock, copyText, children }: SectionProps) {
+ return (
+ <div className={`doc-section${locked ? ' is-locked' : ''}`}>
+ <div className="doc-section-header">
+ <div className="doc-section-title">{title}</div>
+ <div className="doc-section-actions">
+ {copyText && <CopyButton text={copyText} label="Copy" />}
+ <button
+ type="button"
+ className={`section-lock${locked ? ' locked' : ''}`}
+ onClick={onToggleLock}
+ title={locked ? 'Locked — click to unlock' : 'Lock to preserve on regenerate'}
+ >
+ {locked ? '● locked' : '○ lock'}
+ </button>
+ </div>
+ </div>
+ <div className="doc-section-body">
+ {children}
+ </div>
+ </div>
+ );
+}
+
+// ── Editable list item ────────────────────────────────────────────────────────
+
+interface EditableListProps {
+ items: string[];
+ onChange: (items: string[]) => void;
+ numbered?: boolean;
+}
+
+function EditableList({ items, onChange, numbered = true }: EditableListProps) {
+ const updateAt = (i: number, v: string) => {
+ const next = [...items];
+ next[i] = v;
+ onChange(next);
+ };
+
+ if (numbered) {
+ return (
+ <div className="numbered-list">
+ {items.map((item, i) => (
+ <div key={i} className="numbered-item">
+ <span className="numbered-item-num">{i + 1}.</span>
+ <div
+ className="numbered-item-text"
+ contentEditable
+ suppressContentEditableWarning
+ onBlur={e => updateAt(i, e.currentTarget.textContent ?? '')}
+ onKeyDown={e => { if (e.key === 'Escape') e.currentTarget.blur(); }}
+ >
+ {item}
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+ }
+
+ return (
+ <div className="bullet-list">
+ {items.map((item, i) => (
+ <div key={i} className="bullet-item">
+ {item}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+// ── Typography section ────────────────────────────────────────────────────────
+
+function TypographySection({ typography }: { typography: Typography }) {
+ return (
+ <div className="type-section">
+ <div className="type-fonts">
+ <div className="type-font-row">
+ <span className="type-font-role">Primary</span>
+ <span className="type-font-name">{typography.primary}</span>
+ </div>
+ {typography.secondary !== typography.primary && (
+ <div className="type-font-row">
+ <span className="type-font-role">Secondary</span>
+ <span className="type-font-name">{typography.secondary}</span>
+ </div>
+ )}
+ <div className="type-font-row">
+ <span className="type-font-role">Monospace</span>
+ <span className="type-font-name" style={{ fontFamily: 'var(--font-mono)' }}>{typography.mono}</span>
+ </div>
+ <div className="type-pair-note">{typography.pairNote}</div>
+ </div>
+
+ <div className="type-scale">
+ <div className="type-scale-header">
+ <span>Style</span>
+ <span>Size</span>
+ <span>Weight</span>
+ <span className="type-scale-usage">Usage</span>
+ </div>
+ {typography.scale.map(token => (
+ <div key={token.label} className="type-scale-row">
+ <span className="type-scale-label">{token.label}</span>
+ <span className="type-scale-size">{token.size}</span>
+ <span className="type-scale-weight">{token.weight}</span>
+ <span className="type-scale-usage">{token.usage}</span>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+// ── Color swatch card ─────────────────────────────────────────────────────────
+
+interface ColorSwatchCardProps {
+ swatch: ColorSwatch;
+ onChange: (s: ColorSwatch) => void;
+ onRemove: () => void;
+}
+
+function ColorSwatchCard({ swatch, onChange, onRemove }: ColorSwatchCardProps) {
+ const [nameEditing, setNameEditing] = useState(false);
+ const [nameDraft, setNameDraft] = useState(swatch.name);
+ const [hexEditing, setHexEditing] = useState(false);
+ const [hexDraft, setHexDraft] = useState(swatch.hex);
+
+ const commitName = () => {
+ setNameEditing(false);
+ const v = nameDraft.trim();
+ if (v && v !== swatch.name) onChange({ ...swatch, name: v });
+ };
+
+ const commitHex = () => {
+ setHexEditing(false);
+ const v = hexDraft.trim().toLowerCase();
+ const normalized = v.startsWith('#') ? v : `#${v}`;
+ if (/^#[0-9a-f]{6}$/.test(normalized)) {
+ onChange({ ...swatch, hex: normalized });
+ } else {
+ setHexDraft(swatch.hex);
+ }
+ };
+
+ return (
+ <div className="color-swatch-card">
+ <div className="color-swatch-preview" style={{ background: swatch.hex }}>
+ <input
+ type="color"
+ className="color-swatch-picker"
+ value={swatch.hex}
+ onChange={e => onChange({ ...swatch, hex: e.target.value })}
+ title="Pick color"
+ />
+ <button className="color-swatch-remove" onClick={onRemove} title="Remove">×</button>
+ </div>
+ <div className="color-swatch-info">
+ {nameEditing ? (
+ <input
+ autoFocus
+ className="color-swatch-field-input"
+ value={nameDraft}
+ onChange={e => setNameDraft(e.target.value)}
+ onBlur={commitName}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitName();
+ if (e.key === 'Escape') { setNameEditing(false); setNameDraft(swatch.name); }
+ }}
+ />
+ ) : (
+ <div className="color-swatch-name" onClick={() => { setNameDraft(swatch.name); setNameEditing(true); }} title="Click to edit">
+ {swatch.name}
+ </div>
+ )}
+ {hexEditing ? (
+ <input
+ autoFocus
+ className="color-swatch-field-input color-swatch-hex-input"
+ value={hexDraft}
+ onChange={e => setHexDraft(e.target.value)}
+ onBlur={commitHex}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitHex();
+ if (e.key === 'Escape') { setHexEditing(false); setHexDraft(swatch.hex); }
+ }}
+ />
+ ) : (
+ <div className="color-swatch-hex" onClick={() => { setHexDraft(swatch.hex); setHexEditing(true); }} title="Click to edit">
+ {swatch.hex}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// ── Main BrandDoc ─────────────────────────────────────────────────────────────
+
+interface Props {
+ inputs: BrandInputs;
+ outputs: BrandOutputs;
+ locked: LockedSections;
+ onToggleLock: (section: keyof LockedSections) => void;
+ onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void;
+}
+
+export function BrandDoc({ inputs, outputs, locked, onToggleLock, onEdit }: Props) {
+ const paletteCopy = outputs.palette.swatches
+ .map(s => `${s.name}: ${s.hex}`)
+ .join('\n');
+
+ const updateSwatch = (index: number, updated: ColorSwatch) => {
+ const swatches = [...outputs.palette.swatches];
+ swatches[index] = updated;
+ onEdit('palette', { swatches });
+ };
+
+ const removeSwatch = (index: number) => {
+ const swatches = outputs.palette.swatches.filter((_, i) => i !== index);
+ onEdit('palette', { swatches });
+ };
+
+ const addSwatch = () => {
+ const id = `s${Date.now()}`;
+ onEdit('palette', {
+ swatches: [...outputs.palette.swatches, { id, name: 'New Color', hex: '#888888', role: 'accent' }],
+ });
+ };
+
+ const messagingCopy = [
+ 'Titles:',
+ ...outputs.titles.map((t, i) => `${i + 1}. ${t}`),
+ '',
+ 'Taglines:',
+ ...outputs.taglines.map((t, i) => `${i + 1}. ${t}`),
+ ].join('\n');
+
+ return (
+ <div className="brand-doc">
+ <div className="brand-doc-header">
+ <div className="brand-doc-title">{inputs.name || 'Brand Package'}</div>
+ <div className="brand-doc-meta">
+ {inputs.category && (
+ <span className="brand-doc-meta-item">
+ <span className="brand-doc-meta-label">category</span>
+ {inputs.category}
+ </span>
+ )}
+ {inputs.audience && (
+ <span className="brand-doc-meta-item">
+ <span className="brand-doc-meta-label">audience</span>
+ {inputs.audience}
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Overview */}
+ <Section title="Overview" locked={locked.overview} onToggleLock={() => onToggleLock('overview')} copyText={outputs.overview}>
+ <Editable value={outputs.overview} onChange={v => onEdit('overview', v)} multiline />
+ </Section>
+
+ {/* Positioning */}
+ <Section title="Positioning" locked={locked.positioning} onToggleLock={() => onToggleLock('positioning')} copyText={outputs.positioning}>
+ <Editable value={outputs.positioning} onChange={v => onEdit('positioning', v)} multiline />
+ </Section>
+
+ {/* Tone */}
+ <Section title="Tone & Voice" locked={locked.tone} onToggleLock={() => onToggleLock('tone')}>
+ <div className="tone-grid">
+ <div className="tone-row">
+ <div className="tone-row-label">Attributes</div>
+ <div className="tone-tags">
+ {outputs.tone.attributes.map(a => (
+ <span key={a} className="tone-tag">{a}</span>
+ ))}
+ </div>
+ </div>
+ <div className="tone-row">
+ <div className="tone-row-label">Voice</div>
+ <div className="tone-row-value">{outputs.tone.voiceNotes}</div>
+ </div>
+ {outputs.tone.avoidList.length > 0 && (
+ <div className="tone-row">
+ <div className="tone-row-label">Avoid</div>
+ <div className="tone-row-value">{outputs.tone.avoidList.join(', ')}</div>
+ </div>
+ )}
+ {outputs.tone.examplePhrases.length > 0 && (
+ <div className="tone-row">
+ <div className="tone-row-label">Example phrases</div>
+ <div className="tone-phrases">
+ {outputs.tone.examplePhrases.map((p, i) => (
+ <div key={i} className="tone-phrase">{p}</div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </Section>
+
+ {/* Messaging */}
+ <Section title="Messaging" locked={locked.messaging} onToggleLock={() => onToggleLock('messaging')} copyText={messagingCopy}>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
+ <div>
+ <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Titles</div>
+ <EditableList items={outputs.titles} onChange={v => onEdit('titles', v)} />
+ </div>
+ <div>
+ <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Subtitles</div>
+ <EditableList items={outputs.subtitles} onChange={v => onEdit('subtitles', v)} />
+ </div>
+ <div>
+ <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Taglines</div>
+ <EditableList items={outputs.taglines} onChange={v => onEdit('taglines', v)} />
+ </div>
+ </div>
+ </Section>
+
+ {/* Visual Directions */}
+ <Section title="Visual Direction" locked={locked.visual} onToggleLock={() => onToggleLock('visual')}>
+ <div className="directions-grid">
+ {outputs.visualDirections.map(dir => (
+ <div key={dir.id} className="direction-card">
+ <div className="direction-name">{dir.name}</div>
+ <div className="direction-desc">{dir.description}</div>
+ <div className="direction-attrs">
+ <div className="direction-attr">
+ <span className="direction-attr-label">Palette</span>
+ <span className="direction-attr-value">{dir.palette}</span>
+ </div>
+ <div className="direction-attr">
+ <span className="direction-attr-label">Typography</span>
+ <span className="direction-attr-value">{dir.typography}</span>
+ </div>
+ <div className="direction-attr">
+ <span className="direction-attr-label">References</span>
+ <span className="direction-attr-value">{dir.references}</span>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </Section>
+
+ {/* Color Palette */}
+ <Section title="Color Palette" locked={locked.palette} onToggleLock={() => onToggleLock('palette')} copyText={paletteCopy}>
+ <div className="color-palette">
+ {outputs.palette.swatches.map((swatch, i) => (
+ <ColorSwatchCard
+ key={swatch.id}
+ swatch={swatch}
+ onChange={updated => updateSwatch(i, updated)}
+ onRemove={() => removeSwatch(i)}
+ />
+ ))}
+ <button className="color-swatch-add" onClick={addSwatch} title="Add color">
+ +
+ </button>
+ </div>
+ </Section>
+
+ {/* Typography */}
+ <Section title="Typography" locked={locked.typography} onToggleLock={() => onToggleLock('typography')}>
+ <TypographySection typography={outputs.typography} />
+ </Section>
+
+ {/* Logo Concepts */}
+ <Section title="Logo Concepts" locked={locked.logo} onToggleLock={() => onToggleLock('logo')}>
+ <div className="logo-grid">
+ {outputs.logoConcepts.map(lc => (
+ <div key={lc.id} className="logo-card">
+ <div className="logo-card-title">{lc.title}</div>
+ <div className="logo-card-concept">{lc.concept}</div>
+ <div className="logo-card-attrs">
+ <div className="logo-card-attr">
+ <span className="logo-card-attr-label">Mark</span>
+ <span className="logo-card-attr-value">{lc.mark}</span>
+ </div>
+ <div className="logo-card-attr">
+ <span className="logo-card-attr-label">Execution</span>
+ <span className="logo-card-attr-value">{lc.execution}</span>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </Section>
+
+ {/* Usage Examples */}
+ <Section title="Usage Examples" locked={locked.usage} onToggleLock={() => onToggleLock('usage')}>
+ <div className="usage-grid">
+ {outputs.usageExamples.map((ex, i) => (
+ <div key={i} className="usage-item">
+ <div className="usage-context">{ex.context}</div>
+ <div className="usage-text" style={{ position: 'relative' }}>
+ {ex.text}
+ <div className="usage-copy">
+ <CopyButton text={ex.text} label="Copy" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </Section>
+
+ {/* Constraints */}
+ <Section title="Constraints" locked={locked.constraints} onToggleLock={() => onToggleLock('constraints')}>
+ <div className="constraints-list">
+ {outputs.constraints.map((c, i) => (
+ <div key={i} className="constraint-item">{c}</div>
+ ))}
+ </div>
+ </Section>
+ </div>
+ );
+}
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx
new file mode 100644
index 0000000..18a7433
--- /dev/null
+++ b/src/components/CopyButton.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { copyToClipboard } from '../lib/clipboard';
+
+interface Props {
+ text: string;
+ label?: string;
+ className?: string;
+}
+
+export function CopyButton({ text, label = 'Copy', className = '' }: Props) {
+ const [copied, setCopied] = useState(false);
+
+ const handle = async () => {
+ const ok = await copyToClipboard(text);
+ if (ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }
+ };
+
+ return (
+ <button
+ type="button"
+ className={`copy-btn${copied ? ' copied' : ''} ${className}`}
+ onClick={handle}
+ title={label}
+ >
+ {copied ? '✓ Copied' : label}
+ </button>
+ );
+}
diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx
new file mode 100644
index 0000000..c4ad902
--- /dev/null
+++ b/src/components/InputPanel.tsx
@@ -0,0 +1,133 @@
+import type { BrandInputs } from '../types';
+import { TokenInput } from './TokenInput';
+
+const TONE_SUGGESTIONS = ['minimal', 'technical', 'calm', 'bold', 'warm', 'dry', 'focused', 'sharp'];
+const AVOID_SUGGESTIONS = ['buzzwords', 'hype', 'startup language', 'passive voice', 'corporate tone', 'exclamation points', 'superlatives'];
+
+interface Props {
+ inputs: BrandInputs;
+ onChange: (inputs: BrandInputs) => void;
+ onGenerate: () => void;
+ isGenerating: boolean;
+ generateLabel?: string;
+}
+
+export function InputPanel({ inputs, onChange, onGenerate, isGenerating, generateLabel }: Props) {
+ const set = <K extends keyof BrandInputs>(key: K) =>
+ (value: BrandInputs[K]) => onChange({ ...inputs, [key]: value });
+
+ const handleKey = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ onGenerate();
+ }
+ };
+
+ const canGenerate = inputs.name.trim().length > 0 && inputs.purpose.trim().length > 0;
+
+ return (
+ <div className="input-panel" onKeyDown={handleKey}>
+ <div className="input-section">
+ <div className="input-section-label">Project</div>
+ <div className="field">
+ <label className="field-label" htmlFor="f-name">Name</label>
+ <input
+ id="f-name"
+ className="field-input"
+ placeholder="e.g. Foundry"
+ value={inputs.name}
+ onChange={e => set('name')(e.target.value)}
+ />
+ </div>
+ <div className="field">
+ <label className="field-label" htmlFor="f-category">Category</label>
+ <input
+ id="f-category"
+ className="field-input"
+ placeholder="e.g. developer tool, design studio"
+ value={inputs.category}
+ onChange={e => set('category')(e.target.value)}
+ />
+ </div>
+ <div className="field">
+ <label className="field-label" htmlFor="f-purpose">One-line purpose</label>
+ <input
+ id="f-purpose"
+ className="field-input"
+ placeholder="e.g. deploy microservices without boilerplate"
+ value={inputs.purpose}
+ onChange={e => set('purpose')(e.target.value)}
+ />
+ </div>
+ <div className="field">
+ <label className="field-label" htmlFor="f-audience">Audience</label>
+ <input
+ id="f-audience"
+ className="field-input"
+ placeholder="e.g. backend engineers"
+ value={inputs.audience}
+ onChange={e => set('audience')(e.target.value)}
+ />
+ </div>
+ </div>
+
+ <div className="input-section">
+ <div className="input-section-label">Voice</div>
+ <div className="field">
+ <TokenInput
+ label="Tone attributes"
+ values={inputs.tone}
+ onChange={set('tone')}
+ suggestions={TONE_SUGGESTIONS}
+ placeholder="Add tone..."
+ />
+ </div>
+ <div className="field" style={{ marginTop: 8 }}>
+ <TokenInput
+ label="Avoid"
+ values={inputs.avoid}
+ onChange={set('avoid')}
+ suggestions={AVOID_SUGGESTIONS}
+ placeholder="Add avoid..."
+ />
+ </div>
+ </div>
+
+ <div className="input-section">
+ <div className="input-section-label">Notes</div>
+ <div className="field">
+ <label className="field-label sr-only" htmlFor="f-notes">Notes & constraints</label>
+ <textarea
+ id="f-notes"
+ className="field-textarea"
+ placeholder="Optional: constraints, context, inspirations, or anything the brand package should reflect."
+ value={inputs.notes}
+ onChange={e => set('notes')(e.target.value)}
+ rows={4}
+ />
+ </div>
+ </div>
+
+ <div className="generate-area">
+ <button
+ className={`btn-generate${isGenerating ? ' generating' : ''}`}
+ onClick={onGenerate}
+ disabled={!isGenerating && !canGenerate}
+ title={!canGenerate ? 'Enter a name and purpose to generate' : 'Generate brand package (⌘Enter)'}
+ >
+ {generateLabel ?? (isGenerating ? 'Generating…' : 'Generate brand package')}
+ </button>
+ {!canGenerate && (
+ <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}>
+ Name and purpose required
+ </div>
+ )}
+ {canGenerate && !isGenerating && (
+ <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}>
+ ⌘ Enter to generate
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/PackageSwitcher.tsx b/src/components/PackageSwitcher.tsx
new file mode 100644
index 0000000..38c1967
--- /dev/null
+++ b/src/components/PackageSwitcher.tsx
@@ -0,0 +1,125 @@
+import { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import type { PackageSlot } from '../hooks/usePackages';
+
+interface Props {
+ slots: PackageSlot[];
+ activeId: string;
+ onSwitch: (id: string) => void;
+ onCreate: () => void;
+ onRemove: (id: string) => void;
+ onRename: (id: string, name: string) => void;
+ onDuplicate: (id: string) => void;
+}
+
+interface MenuState {
+ id: string;
+ top: number;
+ left: number;
+}
+
+export function PackageSwitcher({ slots, activeId, onSwitch, onCreate, onRemove, onRename, onDuplicate }: Props) {
+ const [editingId, setEditingId] = useState<string | null>(null);
+ const [draft, setDraft] = useState('');
+ const [menu, setMenu] = useState<MenuState | null>(null);
+
+ // Close dropdown on any outside click
+ useEffect(() => {
+ if (!menu) return;
+ const handler = () => setMenu(null);
+ document.addEventListener('click', handler);
+ return () => document.removeEventListener('click', handler);
+ }, [menu]);
+
+ const openMenu = (id: string, e: React.MouseEvent<HTMLButtonElement>) => {
+ e.stopPropagation();
+ if (menu?.id === id) { setMenu(null); return; }
+ const rect = e.currentTarget.getBoundingClientRect();
+ setMenu({ id, top: rect.bottom + 4, left: rect.left });
+ };
+
+ const commitRename = (id: string) => {
+ const name = draft.trim();
+ if (name) onRename(id, name);
+ setEditingId(null);
+ };
+
+ return (
+ <div className="pkg-switcher">
+ {slots.map(slot => (
+ <div
+ key={slot.id}
+ className={`pkg-tab${slot.id === activeId ? ' active' : ''}`}
+ onClick={() => slot.id !== activeId && onSwitch(slot.id)}
+ >
+ {editingId === slot.id ? (
+ <input
+ autoFocus
+ className="pkg-tab-input"
+ value={draft}
+ onChange={e => setDraft(e.target.value)}
+ onBlur={() => commitRename(slot.id)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitRename(slot.id);
+ if (e.key === 'Escape') setEditingId(null);
+ }}
+ onClick={e => e.stopPropagation()}
+ />
+ ) : (
+ <span
+ className="pkg-tab-name"
+ onDoubleClick={e => {
+ e.stopPropagation();
+ setDraft(slot.name);
+ setEditingId(slot.id);
+ }}
+ >
+ {slot.name}
+ </span>
+ )}
+
+ <div className="pkg-tab-actions">
+ <button
+ className="pkg-tab-menu-btn"
+ title="Options"
+ onClick={e => openMenu(slot.id, e)}
+ >
+ ···
+ </button>
+ {slots.length > 1 && (
+ <button
+ className="pkg-tab-close"
+ title="Remove"
+ onClick={e => { e.stopPropagation(); onRemove(slot.id); }}
+ >
+ ×
+ </button>
+ )}
+ </div>
+ </div>
+ ))}
+
+ <button className="pkg-new-btn" onClick={onCreate} title="New package">+</button>
+
+ {/* Dropdown rendered in a portal to escape overflow clipping */}
+ {menu && createPortal(
+ <div
+ className="pkg-tab-dropdown"
+ style={{ position: 'fixed', top: menu.top, left: menu.left }}
+ onClick={e => e.stopPropagation()}
+ >
+ <button onClick={() => {
+ const slot = slots.find(s => s.id === menu.id);
+ if (slot) { setDraft(slot.name); setEditingId(slot.id); }
+ setMenu(null);
+ }}>Rename</button>
+ <button onClick={() => { onDuplicate(menu.id); setMenu(null); }}>Duplicate</button>
+ {slots.length > 1 && (
+ <button className="danger" onClick={() => { onRemove(menu.id); setMenu(null); }}>Delete</button>
+ )}
+ </div>,
+ document.body
+ )}
+ </div>
+ );
+}
diff --git a/src/components/PreviewPanel.tsx b/src/components/PreviewPanel.tsx
new file mode 100644
index 0000000..695698a
--- /dev/null
+++ b/src/components/PreviewPanel.tsx
@@ -0,0 +1,116 @@
+import type { BrandInputs, BrandOutputs, LockedSections } from '../types';
+import { BrandDoc } from './BrandDoc';
+import { CopyButton } from './CopyButton';
+import { toMarkdown, toJSON, toHTML, downloadFile } from '../lib/export';
+
+interface Props {
+ inputs: BrandInputs;
+ outputs: BrandOutputs | null;
+ locked: LockedSections;
+ onToggleLock: (section: keyof LockedSections) => void;
+ onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void;
+ isGenerating: boolean;
+ generateMode?: 'template' | string;
+ generateError?: string | null;
+ onGenerate?: () => void;
+}
+
+export function PreviewPanel({ inputs, outputs, locked, onToggleLock, onEdit, isGenerating, generateMode, generateError, onGenerate }: Props) {
+ const handleExportMd = () => {
+ if (!outputs) return;
+ downloadFile(toMarkdown(inputs, outputs), `${inputs.name || 'brand'}-package.md`, 'text/markdown');
+ };
+
+ const handleExportJson = () => {
+ if (!outputs) return;
+ downloadFile(toJSON(inputs, outputs), `${inputs.name || 'brand'}-package.json`, 'application/json');
+ };
+
+ const handleExportHtml = () => {
+ if (!outputs) return;
+ downloadFile(toHTML(inputs, outputs), `${inputs.name || 'brand'}-guidelines.html`, 'text/html');
+ };
+
+ const fullMarkdown = outputs ? toMarkdown(inputs, outputs) : '';
+
+ return (
+ <div className="preview-panel">
+ <div className="preview-toolbar">
+ <div className="preview-toolbar-left">
+ <span className="preview-doc-label">
+ {outputs ? 'Brand Package' : 'Preview'}
+ </span>
+ {outputs && (
+ <span style={{ fontSize: 11, color: 'var(--text-4)' }}>
+ · {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+ </span>
+ )}
+ </div>
+ {outputs && (
+ <div className="preview-toolbar-right">
+ <CopyButton text={fullMarkdown} label="Copy all" />
+ </div>
+ )}
+ </div>
+
+ {generateError && (
+ <div className="generate-error">
+ <span className="generate-error-icon">⚠</span>
+ <span className="generate-error-msg">{generateError}</span>
+ {onGenerate && (
+ <button className="generate-error-retry btn btn-ghost" onClick={onGenerate}>
+ Try template instead
+ </button>
+ )}
+ </div>
+ )}
+
+ <div className="preview-scroll">
+ {isGenerating ? (
+ <div className="empty-state">
+ <div className="empty-state-title" style={{ color: 'var(--text-3)' }}>
+ {generateMode && generateMode !== 'template'
+ ? `Thinking with ${generateMode}…`
+ : 'Generating…'}
+ </div>
+ {generateMode && generateMode !== 'template' && (
+ <div className="empty-state-sub">This may take 15–60 seconds depending on your hardware</div>
+ )}
+ </div>
+ ) : outputs ? (
+ <BrandDoc
+ inputs={inputs}
+ outputs={outputs}
+ locked={locked}
+ onToggleLock={onToggleLock}
+ onEdit={onEdit}
+ />
+ ) : (
+ <div className="empty-state">
+ <div className="empty-state-title">No package generated yet</div>
+ <div className="empty-state-sub">Fill in the project details and click Generate</div>
+ </div>
+ )}
+ </div>
+
+ {outputs && (
+ <div className="export-bar">
+ <span className="export-bar-left">
+ Export
+ </span>
+ <div className="export-bar-right">
+ <button className="btn" onClick={handleExportMd}>
+ Export Markdown
+ </button>
+ <button className="btn" onClick={handleExportJson}>
+ Export JSON
+ </button>
+ <button className="btn" onClick={handleExportHtml}>
+ Export HTML
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
new file mode 100644
index 0000000..bb44c74
--- /dev/null
+++ b/src/components/SettingsPanel.tsx
@@ -0,0 +1,158 @@
+import { useState, useEffect } from 'react';
+import type { OllamaSettings } from '../hooks/useSettings';
+import { testOllamaConnection } from '../engine/aiGenerator';
+
+interface Props {
+ settings: OllamaSettings;
+ onChange: (next: Partial<OllamaSettings>) => void;
+ onClose: () => void;
+}
+
+type TestState =
+ | { status: 'idle' }
+ | { status: 'testing' }
+ | { status: 'ok'; models: string[] }
+ | { status: 'error'; message: string };
+
+export function SettingsPanel({ settings, onChange, onClose }: Props) {
+ const [url, setUrl] = useState(settings.baseUrl);
+ const [model, setModel] = useState(settings.model);
+ const [test, setTest] = useState<TestState>({ status: 'idle' });
+
+ // Commit field on blur/enter
+ const commitUrl = () => onChange({ baseUrl: url });
+ const commitModel = (m: string) => { setModel(m); onChange({ model: m }); };
+
+ const runTest = async () => {
+ onChange({ baseUrl: url }); // save current URL first
+ setTest({ status: 'testing' });
+ try {
+ const models = await testOllamaConnection(url);
+ setTest({ status: 'ok', models });
+ } catch (err) {
+ setTest({ status: 'error', message: (err as Error).message });
+ }
+ };
+
+ // Close on Escape
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ return (
+ <div className="settings-overlay" onClick={onClose}>
+ <div className="settings-panel" onClick={e => e.stopPropagation()}>
+ <div className="settings-header">
+ <span className="settings-title">Settings</span>
+ <button className="settings-close" onClick={onClose}>×</button>
+ </div>
+
+ <div className="settings-body">
+ {/* AI toggle */}
+ <div className="settings-section">
+ <div className="settings-section-label">AI Generation</div>
+ <label className="settings-toggle-row">
+ <div className="settings-toggle-info">
+ <span className="settings-toggle-name">Use Ollama</span>
+ <span className="settings-toggle-desc">
+ Replace template generation with a local AI model
+ </span>
+ </div>
+ <button
+ className={`toggle${settings.enabled ? ' on' : ''}`}
+ onClick={() => onChange({ enabled: !settings.enabled })}
+ role="switch"
+ aria-checked={settings.enabled}
+ >
+ <span className="toggle-thumb" />
+ </button>
+ </label>
+ </div>
+
+ {/* Ollama config */}
+ {settings.enabled && (
+ <div className="settings-section">
+ <div className="settings-section-label">Ollama</div>
+
+ <div className="settings-field">
+ <label className="settings-field-label">Server URL</label>
+ <div className="settings-field-row">
+ <input
+ className="settings-input"
+ type="url"
+ value={url}
+ onChange={e => setUrl(e.target.value)}
+ onBlur={commitUrl}
+ onKeyDown={e => e.key === 'Enter' && commitUrl()}
+ placeholder="http://localhost:11434"
+ spellCheck={false}
+ />
+ <button
+ className={`btn settings-test-btn${test.status === 'testing' ? ' testing' : ''}`}
+ onClick={runTest}
+ disabled={test.status === 'testing'}
+ >
+ {test.status === 'testing' ? 'Testing…' : 'Test'}
+ </button>
+ </div>
+
+ {test.status === 'ok' && (
+ <div className="settings-status ok">
+ Connected · {test.models.length} model{test.models.length !== 1 ? 's' : ''} available
+ </div>
+ )}
+ {test.status === 'error' && (
+ <div className="settings-status error">{test.message}</div>
+ )}
+ </div>
+
+ <div className="settings-field">
+ <label className="settings-field-label">Model</label>
+ <input
+ className="settings-input"
+ value={model}
+ onChange={e => setModel(e.target.value)}
+ onBlur={() => commitModel(model)}
+ onKeyDown={e => e.key === 'Enter' && commitModel(model)}
+ placeholder="llama3.2"
+ spellCheck={false}
+ />
+ {test.status === 'ok' && test.models.length > 0 && (
+ <div className="settings-model-list">
+ {test.models.map(m => (
+ <button
+ key={m}
+ className={`settings-model-item${m === model ? ' active' : ''}`}
+ onClick={() => commitModel(m)}
+ >
+ {m}
+ </button>
+ ))}
+ </div>
+ )}
+ <div className="settings-field-hint">
+ Pull a model first: <code>ollama pull llama3.2</code>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Help */}
+ <div className="settings-section settings-help">
+ <div className="settings-section-label">About Ollama</div>
+ <p>
+ Ollama runs AI models locally on your machine — no API key, no data sent externally.
+ Install from <strong>ollama.com</strong>, then pull any model to get started.
+ </p>
+ <p>
+ If connection fails, ensure Ollama is running and CORS is open:<br />
+ <code>OLLAMA_ORIGINS=* ollama serve</code>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/TokenInput.tsx b/src/components/TokenInput.tsx
new file mode 100644
index 0000000..c0e23de
--- /dev/null
+++ b/src/components/TokenInput.tsx
@@ -0,0 +1,85 @@
+import { useState, useRef, KeyboardEvent } from 'react';
+
+interface Props {
+ label: string;
+ values: string[];
+ onChange: (values: string[]) => void;
+ suggestions?: string[];
+ placeholder?: string;
+}
+
+export function TokenInput({ label, values, onChange, suggestions = [], placeholder = 'Type and press Enter' }: Props) {
+ const [draft, setDraft] = useState('');
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ const add = (val: string) => {
+ const trimmed = val.trim().toLowerCase();
+ if (trimmed && !values.includes(trimmed)) {
+ onChange([...values, trimmed]);
+ }
+ setDraft('');
+ };
+
+ const remove = (val: string) => {
+ onChange(values.filter(v => v !== val));
+ };
+
+ const handleKey = (e: KeyboardEvent<HTMLInputElement>) => {
+ if ((e.key === 'Enter' || e.key === ',') && draft.trim()) {
+ e.preventDefault();
+ add(draft);
+ } else if (e.key === 'Backspace' && !draft && values.length > 0) {
+ remove(values[values.length - 1]);
+ }
+ };
+
+ const availableSuggestions = suggestions.filter(s => !values.includes(s));
+
+ return (
+ <div className="token-field">
+ <label className="field-label">{label}</label>
+ <div
+ className="token-container"
+ onClick={() => inputRef.current?.focus()}
+ >
+ {values.map(v => (
+ <span key={v} className="token">
+ {v}
+ <button
+ type="button"
+ className="token-remove"
+ onClick={e => { e.stopPropagation(); remove(v); }}
+ aria-label={`Remove ${v}`}
+ >
+ ×
+ </button>
+ </span>
+ ))}
+ <input
+ ref={inputRef}
+ className="token-input"
+ value={draft}
+ onChange={e => setDraft(e.target.value)}
+ onKeyDown={handleKey}
+ onBlur={() => { if (draft.trim()) add(draft); }}
+ placeholder={values.length === 0 ? placeholder : ''}
+ aria-label={label}
+ />
+ </div>
+ {availableSuggestions.length > 0 && (
+ <div className="token-suggestions">
+ {availableSuggestions.map(s => (
+ <button
+ key={s}
+ type="button"
+ className={`token-suggestion${values.includes(s) ? ' active' : ''}`}
+ onClick={() => add(s)}
+ >
+ {s}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/engine/aiGenerator.ts b/src/engine/aiGenerator.ts
new file mode 100644
index 0000000..3cd9d5e
--- /dev/null
+++ b/src/engine/aiGenerator.ts
@@ -0,0 +1,138 @@
+import type { BrandInputs, BrandOutputs } from '../types';
+import type { OllamaSettings } from '../hooks/useSettings';
+import { sanitizeOutputs } from '../lib/sanitize';
+
+const SYSTEM_PROMPT = `You are an expert brand strategist and copywriter. Generate a complete brand identity package as a JSON object. Be specific and creative — every output must feel crafted for the exact brand described, not generic. Use the project details to inform all choices: tone, color palette, typography, and messaging.`;
+
+function buildUserPrompt(inputs: BrandInputs): string {
+ const lines = [
+ 'Generate a brand package for this project.',
+ '',
+ 'PROJECT DETAILS:',
+ `Name: ${inputs.name || 'Untitled'}`,
+ `Category: ${inputs.category || 'general'}`,
+ `Purpose: ${inputs.purpose || 'not specified'}`,
+ `Target audience: ${inputs.audience || 'not specified'}`,
+ inputs.tone.length ? `Tone words: ${inputs.tone.join(', ')}` : '',
+ inputs.avoid.length ? `Words/phrases to avoid: ${inputs.avoid.join(', ')}` : '',
+ inputs.notes ? `Additional notes: ${inputs.notes}` : '',
+ '',
+ 'Return ONLY a valid JSON object with exactly this structure:',
+ '',
+ JSON.stringify({
+ overview: 'One crisp sentence: what this project is and who it helps',
+ positioning: '2–3 sentence strategic positioning statement — what makes this brand distinct',
+ tone: {
+ attributes: ['attribute1', 'attribute2', 'attribute3', 'attribute4'],
+ voiceNotes: '2–3 sentences describing how the brand writes and speaks',
+ avoidList: ['thing to never say or do', 'another thing to avoid'],
+ examplePhrases: ['A phrase that shows the brand voice', 'Another example', 'A third example'],
+ },
+ titles: ['Title option 1', 'Title option 2', 'Title option 3'],
+ subtitles: ['Subtitle option 1', 'Subtitle option 2', 'Subtitle option 3'],
+ taglines: ['Short punchy tagline', 'Alternative tagline', 'Third tagline option'],
+ palette: {
+ swatches: [
+ { id: 's0', name: 'Background', hex: '#hexcode', role: 'background' },
+ { id: 's1', name: 'Surface', hex: '#hexcode', role: 'neutral' },
+ { id: 's2', name: 'Primary', hex: '#hexcode', role: 'primary' },
+ { id: 's3', name: 'Accent', hex: '#hexcode', role: 'accent' },
+ { id: 's4', name: 'Text', hex: '#hexcode', role: 'text' },
+ ],
+ },
+ typography: {
+ primary: 'Primary font name (e.g. Inter, Playfair Display)',
+ secondary: 'Secondary font name or same as primary',
+ mono: 'Monospace font name (e.g. JetBrains Mono, Fira Code)',
+ pairNote: 'One sentence on why this pairing fits the brand',
+ },
+ visualDirections: [
+ { id: 'v1', name: 'Direction name', description: '2–3 sentence visual direction description', palette: 'Color mood description', typography: 'Type style description', references: 'Visual references and inspirations' },
+ { id: 'v2', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
+ { id: 'v3', name: 'Direction name', description: '2–3 sentence description', palette: 'Color mood', typography: 'Type style', references: 'Visual references' },
+ ],
+ logoConcepts: [
+ { id: 'l1', title: 'Logo concept name', concept: 'Concept description and rationale', mark: 'Mark/symbol description', execution: 'Execution and usage guidelines' },
+ { id: 'l2', title: 'Second concept name', concept: 'Concept description', mark: 'Mark description', execution: 'Execution guidelines' },
+ ],
+ usageExamples: [
+ { context: 'README headline', text: 'Actual example copy here' },
+ { context: 'Landing page hero', text: 'Actual example copy here' },
+ { context: 'Social bio', text: 'Actual example copy here' },
+ { context: 'Email subject line', text: 'Actual example copy here' },
+ ],
+ constraints: [
+ 'A specific copy rule',
+ 'Another brand constraint',
+ 'A third constraint',
+ 'A fourth constraint',
+ ],
+ }, null, 2),
+ ];
+
+ return lines.filter(l => l !== null).join('\n');
+}
+
+function validate(raw: unknown): BrandOutputs {
+ const result = sanitizeOutputs(raw);
+ if (!result) throw new Error('Response is not a valid brand output object');
+ return result;
+}
+
+export async function generateWithAI(
+ inputs: BrandInputs,
+ settings: OllamaSettings,
+ signal?: AbortSignal,
+): Promise<BrandOutputs> {
+ const url = `${settings.baseUrl.replace(/\/$/, '')}/api/chat`;
+
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal,
+ body: JSON.stringify({
+ model: settings.model,
+ messages: [
+ { role: 'system', content: SYSTEM_PROMPT },
+ { role: 'user', content: buildUserPrompt(inputs) },
+ ],
+ format: 'json',
+ stream: false,
+ }),
+ });
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') throw err;
+ throw new Error(`Cannot reach Ollama at ${settings.baseUrl}. Is it running?`);
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ if (res.status === 404) throw new Error(`Model "${settings.model}" not found. Pull it first: ollama pull ${settings.model}`);
+ throw new Error(`Ollama error ${res.status}: ${text.slice(0, 120)}`);
+ }
+
+ const data = await res.json() as { message?: { content?: string } };
+ const content = data?.message?.content;
+ if (!content) throw new Error('Empty response from Ollama');
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ throw new Error('Ollama returned invalid JSON. Try a larger model.');
+ }
+
+ // validate() calls sanitizeOutputs(), which injects TYPE_SCALE for fresh AI output
+ return validate(parsed);
+}
+
+// Test connectivity and return available model names
+export async function testOllamaConnection(baseUrl: string): Promise<string[]> {
+ const url = `${baseUrl.replace(/\/$/, '')}/api/tags`;
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+ if (!res.ok) throw new Error(`Ollama responded with ${res.status}`);
+ const data = await res.json() as { models?: Array<{ name: string }> };
+ return (data.models ?? []).map(m => m.name.replace(/:latest$/, ''));
+}
diff --git a/src/engine/generator.ts b/src/engine/generator.ts
new file mode 100644
index 0000000..46ddaf7
--- /dev/null
+++ b/src/engine/generator.ts
@@ -0,0 +1,957 @@
+import type {
+ BrandInputs,
+ BrandOutputs,
+ ToneGuidance,
+ VisualDirection,
+ LogoConcept,
+ UsageExample,
+ ColorPalette,
+ ColorSwatch,
+ Typography,
+ TypographyToken,
+} from '../types';
+
+type CategoryType = 'developer' | 'creative' | 'product' | 'services' | 'personal' | 'general';
+
+interface GenContext {
+ name: string;
+ category: string;
+ purpose: string;
+ audience: string;
+ tone: string[];
+ avoid: string[];
+ notes: string;
+ catType: CategoryType;
+ hasTone: (t: string) => boolean;
+ hasAvoid: (a: string) => boolean;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function detectCategory(raw: string): CategoryType {
+ const c = raw.toLowerCase();
+ if (/\b(dev|developer|tool|cli|sdk|api|library|lib|framework|plugin|compiler|linter|utility)\b/.test(c))
+ return 'developer';
+ if (/\b(design|creative|studio|agency|art|visual|branding|illustration|photography)\b/.test(c))
+ return 'creative';
+ if (/\b(saas|platform|service|app|software|product|startup|dashboard)\b/.test(c))
+ return 'product';
+ if (/\b(consult|advisory|freelance|firm|practice|coach|training)\b/.test(c))
+ return 'services';
+ if (/\b(personal|portfolio|blog|brand|me|writer|designer|maker|creator)\b/.test(c))
+ return 'personal';
+ return 'general';
+}
+
+function buildContext(inputs: BrandInputs): GenContext {
+ const catType = detectCategory(inputs.category);
+ const hasTone = (t: string) =>
+ inputs.tone.some(x => x.toLowerCase().includes(t.toLowerCase()));
+ const hasAvoid = (a: string) =>
+ inputs.avoid.some(x => x.toLowerCase().includes(a.toLowerCase()));
+
+ return {
+ name: inputs.name.trim() || 'Untitled',
+ category: inputs.category.trim() || 'project',
+ purpose: inputs.purpose.trim() || 'solve a specific problem',
+ audience: inputs.audience.trim() || 'its intended users',
+ tone: inputs.tone,
+ avoid: inputs.avoid,
+ notes: inputs.notes.trim(),
+ catType,
+ hasTone,
+ hasAvoid,
+ };
+}
+
+function pick<T>(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function pickN<T>(arr: T[], n: number): T[] {
+ const copy = [...arr];
+ const result: T[] = [];
+ while (result.length < n && copy.length > 0) {
+ const i = Math.floor(Math.random() * copy.length);
+ result.push(copy.splice(i, 1)[0]);
+ }
+ return result;
+}
+
+function cap(s: string): string {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+// ── Overview ─────────────────────────────────────────────────────────────────
+
+function generateOverview(ctx: GenContext): string {
+ const { name, category, purpose, audience, catType } = ctx;
+
+ const base = pick([
+ `${name} is a ${category} for ${audience}. ${cap(purpose)}.`,
+ `A ${category} built for ${audience}. ${name} is built to ${purpose}.`,
+ `${name} helps ${audience} ${purpose}.`,
+ `${name} is a ${category} built to ${purpose}. Made for ${audience}.`,
+ ]);
+
+ let suffix = '';
+ if (ctx.hasTone('minimal')) suffix = ' Nothing more.';
+ else if (ctx.hasTone('technical')) suffix = ' Stays out of the way while doing its job.';
+ else if (ctx.hasTone('calm')) suffix = ' Designed to reduce friction, not add to it.';
+ else if (ctx.hasTone('bold')) suffix = ' No compromises.';
+ else if (ctx.hasTone('warm')) suffix = ' Made with care.';
+ else if (catType === 'developer') suffix = ' Built to stay composable and predictable.';
+ else if (catType === 'creative') suffix = ' Work that speaks before the introduction.';
+
+ return base + suffix;
+}
+
+// ── Positioning ───────────────────────────────────────────────────────────────
+
+function generatePositioning(ctx: GenContext): string {
+ const { name, catType } = ctx;
+
+ const templates: Record<CategoryType, string[]> = {
+ developer: [
+ `${name} sits in the space between "build it yourself" and "platform lock-in." It's a workflow layer, not an abstraction. You keep control; ${name} handles the repetitive parts.`,
+ `Most tools in this space try to do too much. ${name} doesn't. It handles exactly what it promises — then stays out of your way.`,
+ `${name} is not a platform. It's a tool. You own the infrastructure; ${name} owns the workflow. That distinction matters.`,
+ ],
+ creative: [
+ `${name} is defined by its process as much as its output. The work should be recognizable without a logo — that's the goal.`,
+ `${name} occupies a deliberate position: between personal and professional, between output and craft. Not trying to be everything. Trying to be specific.`,
+ `There's no shortage of creative work. ${name} earns attention through quality and consistency, not volume or novelty.`,
+ ],
+ product: [
+ `${name} doesn't try to replace your existing workflow. It fits into it. The goal is to reduce friction in a specific, measurable place.`,
+ `The space ${name} plays in has no shortage of tools. What it offers is focus — one problem, done well, with clear boundaries.`,
+ `${name} is built for people who've tried the alternatives and found them too complicated, too expensive, or too broad.`,
+ ],
+ services: [
+ `${name} is not a generalist practice. It works on a specific type of problem with a specific type of client. That specificity is the positioning.`,
+ `Clients come to ${name} when they need someone who has solved this problem before. The brand should communicate experience, not aspiration.`,
+ `${name} does one thing well and charges accordingly. Clarity of scope is the offer.`,
+ ],
+ personal: [
+ `${name} is a deliberate presence — not a portfolio, not a platform, not a personal brand in the marketing sense. A clear point of view, consistently expressed.`,
+ `${name} doesn't try to appeal to everyone. It's built around a specific set of interests and a specific way of working.`,
+ `There are a lot of personal sites. ${name} is distinct by being specific — not broad, not aspirational, not trying to cover every base.`,
+ ],
+ general: [
+ `${name} occupies a clear position: built for a specific audience with a specific need. Not trying to be everything.`,
+ `${name} is not for everyone. That's intentional. Clarity of purpose is more valuable than breadth of appeal.`,
+ ],
+ };
+
+ return pick(templates[catType] ?? templates.general);
+}
+
+// ── Tone Guidance ─────────────────────────────────────────────────────────────
+
+function generateToneGuidance(ctx: GenContext): ToneGuidance {
+ const { name, purpose, tone, avoid, catType, hasTone } = ctx;
+
+ const voiceMap: Record<string, string> = {
+ minimal: 'Short sentences. No adjectives unless load-bearing. Direct.',
+ technical: 'Precise nouns, specific verbs. Write for experts. Avoid explaining what the reader already knows.',
+ calm: 'Measured pace. Confident statements. No urgency cues. Let the work speak.',
+ bold: 'Strong verbs. Active voice. Make a claim and stand behind it.',
+ warm: 'Approachable but not casual. Human without being informal.',
+ dry: 'Deadpan. Understate. Trust the reader to get it.',
+ focused: 'Stay on topic. One idea per sentence. Cut the rest.',
+ };
+
+ const catVoiceDefaults: Record<CategoryType, string> = {
+ developer: 'Write for engineers. Assume technical literacy. Specificity earns trust.',
+ creative: 'Lead with the work. Copy should serve the visual, not explain it.',
+ product: 'Clear over clever. Features earn their mention by solving something real.',
+ services: "Experience over enthusiasm. What you've done, not what you'll do.",
+ personal: 'First person where appropriate. Honest and considered.',
+ general: 'Clear, direct, grounded. Earn attention with specificity.',
+ };
+
+ const toneVoiceParts = tone.map(t => voiceMap[t.toLowerCase()]).filter(Boolean);
+ const voiceNotes =
+ toneVoiceParts.length > 0
+ ? toneVoiceParts.join(' ')
+ : catVoiceDefaults[catType] ?? catVoiceDefaults.general;
+
+ const avoidDefaults: Record<CategoryType, string[]> = {
+ developer: ['"seamlessly"', '"game-changing"', '"powerful" as an adjective', 'passive voice'],
+ creative: ['"unique"', '"innovative"', '"passion-driven"', 'agency-speak'],
+ product: ['"revolutionary"', '"disrupts"', '"leverage"', '"synergy"'],
+ services: ['"partner"', '"solutions"', '"holistic"', '"best-in-class"'],
+ personal: ['"journey"', '"passionate about"', '"thought leader"', '"excited to announce"'],
+ general: ['"world-class"', '"cutting-edge"', '"innovative"', '"paradigm"'],
+ };
+
+ const avoidList = [
+ ...avoid,
+ ...(avoidDefaults[catType] ?? avoidDefaults.general).filter(
+ a => !avoid.some(ua => ua.toLowerCase().includes(a.toLowerCase().replace(/"/g, '')))
+ ),
+ ];
+
+ const phrasesByTone: string[] = [];
+ if (hasTone('minimal')) phrasesByTone.push(`"${name}. ${cap(purpose)}."`);
+ if (hasTone('technical'))
+ phrasesByTone.push('"Typed, composable, deterministic."');
+ if (hasTone('calm'))
+ phrasesByTone.push('"Reliable tools, clearly documented, quietly maintained."');
+ if (hasTone('bold')) phrasesByTone.push('"Pick it up. It works. Put it down."');
+ if (hasTone('warm'))
+ phrasesByTone.push('"Made for people who care about their tools."');
+
+ const catPhrases: Record<CategoryType, string[]> = {
+ developer: [
+ '"One command. Done."',
+ '"Less ceremony. More output."',
+ '"Configure once. Forget about it."',
+ ],
+ creative: [
+ '"The work is the argument."',
+ '"No portfolio lorem ipsum."',
+ '"Good work, clearly presented."',
+ ],
+ product: [
+ '"It fits where you already work."',
+ '"No setup tax."',
+ '"Does one thing. Does it well."',
+ ],
+ services: [
+ '"You\'ve seen this problem before. So have we."',
+ '"Specific outcomes, clear process."',
+ '"We don\'t do vague."',
+ ],
+ personal: [
+ '"This is what I work on."',
+ '"Specific interests, honest opinions."',
+ '"No personal brand. Just work."',
+ ],
+ general: [
+ '"Built for a reason."',
+ '"Useful before impressive."',
+ '"Does what it says."',
+ ],
+ };
+
+ const allPhrases = [
+ ...phrasesByTone,
+ ...(catPhrases[catType] ?? catPhrases.general),
+ ];
+
+ return {
+ attributes: tone.length > 0 ? tone : ['direct', 'clear'],
+ voiceNotes,
+ avoidList,
+ examplePhrases: pickN(allPhrases, Math.min(4, allPhrases.length)),
+ };
+}
+
+// ── Titles ────────────────────────────────────────────────────────────────────
+
+function generateTitles(ctx: GenContext): string[] {
+ const { name, category, purpose, audience, hasTone } = ctx;
+
+ const purposeWords = purpose.split(/\s+/);
+ const coreVerb = purposeWords[0] ?? 'build';
+ const audienceShort = audience.split(/\s+/).slice(0, 3).join(' ');
+
+ // Distinct structural patterns — each must look meaningfully different
+ const variants: string[] = [
+ `${name} — ${category} for ${audienceShort}`,
+ `${name} / ${audience}`,
+ `${name}: ${cap(coreVerb)} without ceremony`,
+ `${name} for ${audienceShort}`,
+ `${name} — built to ${coreVerb}`,
+ ];
+
+ // Tone-specific variants
+ if (hasTone('minimal')) variants.push(`${name}.`);
+ if (hasTone('technical')) {
+ variants.push(`${name} — ${category} utility`);
+ variants.push(`${name} — ${coreVerb}, ship, repeat`);
+ }
+ if (hasTone('bold')) {
+ variants.push(`${name}. Built for ${audienceShort}.`);
+ variants.push(`${name}. No ceremony.`);
+ }
+ if (hasTone('calm')) variants.push(`${name} — a ${category} for ${audienceShort}`);
+
+ // Deduplicate and ensure first is always bare name
+ const pool = variants.filter(v => v !== name);
+ const extras = pickN(pool, 2);
+ return [name, ...extras];
+}
+
+// ── Subtitles ─────────────────────────────────────────────────────────────────
+
+function generateSubtitles(ctx: GenContext): string[] {
+ const { name, category, purpose, audience } = ctx;
+ const purposeShort = purpose.split(/\s+/).slice(0, 6).join(' ');
+
+ const all: string[] = [
+ `${cap(category)} for ${audience}.`,
+ `${cap(purpose)}.`,
+ `A ${category} built to ${purpose}.`,
+ `Built for ${audience} who need to ${purposeShort}.`,
+ `${name}: the ${category} for ${audience}.`,
+ `${cap(category)}. For ${audience}.`,
+ `${cap(purpose)}. No overhead.`,
+ `The ${category} for ${audience} who know what they need.`,
+ ];
+
+ return pickN(all, 3);
+}
+
+// ── Taglines ──────────────────────────────────────────────────────────────────
+
+function generateTaglines(ctx: GenContext): string[] {
+ const { category, purpose, audience, catType, hasTone } = ctx;
+
+ const purposeWords = purpose.split(/\s+/);
+ const coreVerb = purposeWords[0] ?? 'build';
+ // Take only the object noun (skip prepositions like "without", "for", "with")
+ const stopWords = new Set(['without', 'with', 'for', 'and', 'or', 'the', 'a', 'an']);
+ const coreNounWords = purposeWords.slice(1).filter(w => !stopWords.has(w.toLowerCase()));
+ const coreNoun = coreNounWords[0] || 'your work';
+ const audienceShort = audience.split(/\s+/).slice(0, 2).join(' ');
+
+ const catTaglines: Record<CategoryType, string[]> = {
+ developer: [
+ `${cap(coreVerb)}, ship, move on.`,
+ `Less boilerplate. More control.`,
+ `${cap(category)} that stays out of your way.`,
+ `One command. Done.`,
+ `Configure once. Forget about it.`,
+ `Less ceremony. More output.`,
+ `Built for ${audienceShort} who ship.`,
+ `${cap(coreNoun)}, no overhead.`,
+ ],
+ creative: [
+ `The work is the argument.`,
+ `Good work, clearly presented.`,
+ `No introduction needed.`,
+ `${cap(coreNoun)}, done properly.`,
+ `Craft over noise.`,
+ `Say less. Show more.`,
+ `Quality, consistently.`,
+ ],
+ product: [
+ `Does one thing. Does it well.`,
+ `Fits where you already work.`,
+ `No setup tax.`,
+ `Built for ${audienceShort}.`,
+ `${cap(coreVerb)} without the friction.`,
+ `One less problem.`,
+ `Useful before impressive.`,
+ ],
+ services: [
+ `You've seen this problem before. So have we.`,
+ `Specific outcomes. Clear process.`,
+ `Experience, not enthusiasm.`,
+ `We don't do vague.`,
+ `The result is the product.`,
+ `Built for the problem you actually have.`,
+ ],
+ personal: [
+ `This is what I work on.`,
+ `Specific interests. Honest opinions.`,
+ `Work, not performance.`,
+ `Making things that matter.`,
+ `No brand. Just work.`,
+ ],
+ general: [
+ `Built for a reason.`,
+ `Useful before impressive.`,
+ `Does what it says.`,
+ `${cap(coreVerb)} without the noise.`,
+ `For ${audienceShort} who know what they want.`,
+ ],
+ };
+
+ const toneTaglines: string[] = [];
+ if (hasTone('minimal')) toneTaglines.push(`${cap(coreVerb)}. Ship.`, `Simple by design.`);
+ if (hasTone('calm'))
+ toneTaglines.push(`Reliable tools, quietly maintained.`, `Steady. Dependable. Yours.`);
+ if (hasTone('bold'))
+ toneTaglines.push(`Pick it up. It works.`, `No compromises.`, `Built to be used.`);
+ if (hasTone('technical'))
+ toneTaglines.push(`Typed. Composable. Predictable.`, `Deterministic by design.`);
+
+ const pool = [...(catTaglines[catType] ?? catTaglines.general), ...toneTaglines];
+ return pickN(pool, 3);
+}
+
+// ── Visual Directions ─────────────────────────────────────────────────────────
+
+function generateVisualDirections(ctx: GenContext): VisualDirection[] {
+ const { catType, hasTone } = ctx;
+
+ const allDirections: VisualDirection[] = [];
+
+ if (catType === 'developer' || hasTone('technical') || hasTone('minimal')) {
+ allDirections.push(
+ {
+ id: 'terminal-minimal',
+ name: 'Terminal Minimal',
+ description:
+ 'Dark background, monospace type throughout, no ornament. Functional and uncompromising. Every element earns its place.',
+ palette: 'Near-black ground (#0d0d0d), off-white text (#e0e0e0), single muted accent (amber or green).',
+ typography: 'Monospace primary — JetBrains Mono or Iosevka. Consistent weight. No italic.',
+ references: 'htop, k9s, the Stripe CLI, Linear issue view.',
+ },
+ {
+ id: 'technical-document',
+ name: 'Technical Document',
+ description:
+ 'Off-white ground, dense information layout, RFC/spec aesthetic. Designed for reading, not scanning. Typography does all the work.',
+ palette: 'Warm white (#f5f3ef), dark text (#1a1a1a), minimal color — one functional accent only.',
+ typography: 'Sans-serif (Inter or similar) for body, mono for code. Tight line height. Strong hierarchy through size and weight alone.',
+ references: 'Stripe docs, Oxide Computer RFCs, GNU manpages reformatted.',
+ },
+ {
+ id: 'precision-interface',
+ name: 'Precision Interface',
+ description:
+ 'Neutral mid-range palette, strong grid, engineering-tool aesthetic. Balanced between document and application. Calm but capable.',
+ palette: 'Mid-grey ground (#f0f0f0 or #1c1c1c), charcoal type, restrained use of blue or slate as action color.',
+ typography: 'Sans-serif for UI, mono for data. Clear size differentiation. No decorative weight use.',
+ references: 'Figma sidebar, Retool, Zed editor, TablePlus.',
+ }
+ );
+ }
+
+ if (catType === 'creative' || catType === 'personal') {
+ allDirections.push(
+ {
+ id: 'editorial',
+ name: 'Editorial',
+ description:
+ 'Strong typographic hierarchy, restrained palette, print-design influences. Work foreground, everything else background.',
+ palette: 'Off-white or cream (#f7f4ef), near-black type, accent used once — a single warm or cool tone.',
+ typography: 'A good serif for display, neutral sans for body. Generous leading. No decorative fonts.',
+ references: 'Are.na, Typewolf, Emigre back catalog, Letterform Archive.',
+ },
+ {
+ id: 'quiet-studio',
+ name: 'Quiet Studio',
+ description:
+ 'Neutral and considered. Nothing decorative. Space used to direct attention, not fill it.',
+ palette: 'Warm white (#fafaf8) or deep neutral (#141414), type-only color use. No gradients.',
+ typography: 'One typeface family, two weights. Let leading and spacing create rhythm.',
+ references: 'Pentagram case studies, Swiss International Style, Muji product design.',
+ },
+ {
+ id: 'contemporary-craft',
+ name: 'Contemporary Craft',
+ description:
+ 'Tactile references — paper, grain, texture — applied with restraint. Warmth without nostalgia.',
+ palette: 'Off-white base with a warm paper tone, subtle texture overlays, earthy accent.',
+ typography: 'Mix: display serif + utility sans. Comfortable reading size. Generous margins.',
+ references: 'Oak Studio, Analog, Offscreen Magazine, Present & Correct.',
+ }
+ );
+ }
+
+ if (catType === 'product' || catType === 'services') {
+ allDirections.push(
+ {
+ id: 'focused-product',
+ name: 'Focused Product',
+ description:
+ 'Clean, professional, information-forward. Looks like it was built to be used, not to be admired. Trust through clarity.',
+ palette: 'White or light grey ground, dark neutral type, one brand color used only for primary actions.',
+ typography: 'Neutral sans-serif, systematic sizing, no personality — the product is the personality.',
+ references: 'Linear, Cron (v1), Vercel dashboard, Raycast.',
+ },
+ {
+ id: 'minimal-commerce',
+ name: 'Minimal Commerce',
+ description:
+ 'Premium restraint. No decorative elements. White space signals quality. Type-driven.',
+ palette: 'White ground, black type, one warm accent for selective emphasis.',
+ typography: 'A refined sans-serif. Large display size for key claims. Small, tracked caps for labels.',
+ references: 'Stripe marketing, Basecamp, Arc browser landing page.',
+ },
+ {
+ id: 'structured-trust',
+ name: 'Structured Trust',
+ description:
+ 'Grid-heavy, methodical, legible. Communicates that things are in order. More function, less flourish.',
+ palette: 'Light neutral ground, two text weights (body + emphasis), a contained accent color.',
+ typography: 'Professional sans-serif — GT Walsheim or Plus Jakarta or similar. Tight tracking for headings.',
+ references: 'Harvest app, FreshBooks, Notion, Loom landing page.',
+ }
+ );
+ }
+
+ if (allDirections.length === 0) {
+ allDirections.push(
+ {
+ id: 'type-forward',
+ name: 'Type Forward',
+ description:
+ 'Typography as the only design element. No illustration, no photography, no pattern. Words do everything.',
+ palette: 'Black and white, one optional accent. No gradients.',
+ typography: 'One great typeface. Multiple weights. Extreme size contrast. Nothing else needed.',
+ references: 'Early Bloomberg Businessweek covers, Helvetica film poster, The Economist.',
+ },
+ {
+ id: 'system-neutral',
+ name: 'System Neutral',
+ description:
+ 'Invisible design — system fonts, default spacing, no signature. The brand is in the content, not the container.',
+ palette: 'System defaults. One custom color — the brand color. Everything else inherited.',
+ typography: 'System UI stack. Optimized for the OS it runs on.',
+ references: 'HN, iA Writer, Pinboard, older Stripe.',
+ }
+ );
+ }
+
+ return pickN(allDirections, Math.min(3, allDirections.length));
+}
+
+// ── Logo Concepts ─────────────────────────────────────────────────────────────
+
+function generateLogoConcepts(ctx: GenContext): LogoConcept[] {
+ const { name, catType } = ctx;
+ const initial = name.charAt(0).toUpperCase();
+ const initials =
+ name
+ .split(/\s+/)
+ .slice(0, 2)
+ .map(w => w.charAt(0).toUpperCase())
+ .join('') || initial;
+
+ const concepts: LogoConcept[] = [];
+
+ if (catType === 'developer') {
+ concepts.push(
+ {
+ id: 'geometric-letterform',
+ title: 'Geometric Letterform',
+ concept: `The letter "${initial}" treated as a structural element — not styled, just precise. Think grid construction, not calligraphy.`,
+ mark: 'Monoweight geometric construction. Works at 16px and 1600px. No gradients, no effects.',
+ execution:
+ 'Build on a strict grid. Consider negative space as intentional, not leftover. Test at 16×16 favicon size first.',
+ },
+ {
+ id: 'wordmark-mono',
+ title: 'Wordmark in Mono',
+ concept: `"${name}" set in a monospace typeface, tracked slightly loose. The choice of mono is the signal.`,
+ mark: 'Wordmark only. No icon. The name is the mark.',
+ execution:
+ 'Try JetBrains Mono, Iosevka, or Commit Mono at medium weight. Adjust tracking. Optically align.',
+ },
+ {
+ id: 'abstract-structure',
+ title: 'Abstract Structure',
+ concept:
+ 'A geometric mark suggesting assembly, layering, or composition — aligned with the product metaphor.',
+ mark: 'Two or three simple shapes in precise relation. No ornamentation.',
+ execution:
+ 'Explore grid fragments, interlocking forms, or stacked bars. Test inversion on dark and light.',
+ }
+ );
+ } else if (catType === 'creative' || catType === 'personal') {
+ concepts.push(
+ {
+ id: 'custom-wordmark',
+ title: 'Custom Wordmark',
+ concept: `"${name}" as a custom letterform — not a font off the shelf, but drawn. The craft shows.`,
+ mark: 'Wordmark with subtle custom refinements: adjusted spacing, modified terminals, intentional details.',
+ execution:
+ 'Start with a base typeface. Modify key letterforms. The goal is invisible craft, not obvious customization.',
+ },
+ {
+ id: 'monogram',
+ title: 'Monogram',
+ concept: `"${initials}" as a tight, legible monogram. Simple enough to stamp, refined enough to scale up.`,
+ mark: 'Two letterforms in structural relation. Not overlapping decoratively — compositionally.',
+ execution:
+ 'Grid-align. Consider positive/negative figure-ground play. Must read clearly at 24px.',
+ }
+ );
+ } else {
+ concepts.push(
+ {
+ id: 'clean-wordmark',
+ title: 'Wordmark',
+ concept: `"${name}" set in a well-chosen typeface, thoughtfully spaced. No icon needed.`,
+ mark: 'Wordmark. The typeface selection and spacing carry the identity.',
+ execution:
+ 'Choose a typeface with character but not personality. Adjust tracking. Optically center.',
+ },
+ {
+ id: 'initial-mark',
+ title: `"${initial}" Mark`,
+ concept: `A standalone "${initial}" mark that works as a favicon, app icon, and small-scale identifier.`,
+ mark: 'Single letter, geometric or structured. Consistent weight with wordmark.',
+ execution:
+ 'Build on an 8-unit grid. Test at 16px, 32px, and 512px. Must work in one color.',
+ }
+ );
+ }
+
+ concepts.push({
+ id: 'symbol-plus-wordmark',
+ title: 'Symbol + Wordmark System',
+ concept:
+ 'A mark system: standalone symbol for small contexts, symbol + name for full contexts. Flexible.',
+ mark: 'Two formats: symbol alone, symbol left-aligned with wordmark right.',
+ execution:
+ 'Define the relationship (size ratio, spacing) precisely. Lock it. Never deviate. Test both formats in context.',
+ });
+
+ return pickN(concepts, 2);
+}
+
+// ── Usage Examples ────────────────────────────────────────────────────────────
+
+function generateUsageExamples(ctx: GenContext): UsageExample[] {
+ const { name, category, purpose, audience, catType } = ctx;
+ // Take a clean verb phrase for landing copy — stop before prepositions
+ const stopWords = new Set(['without', 'for', 'with', 'and', 'or', 'the', 'a', 'an', 'via', 'using']);
+ const purposeWords = purpose.split(/\s+/);
+ const heroWords: string[] = [];
+ for (const w of purposeWords) {
+ if (stopWords.has(w.toLowerCase()) && heroWords.length > 0) break;
+ heroWords.push(w);
+ }
+ const heroVerb = heroWords.join(' ') || purpose;
+
+ const examples: UsageExample[] = [
+ {
+ context: 'README header',
+ text: `# ${name}\n\n${cap(category)} for ${audience}. ${cap(purpose)}.`,
+ },
+ {
+ context: 'Landing page hero',
+ text: `${cap(heroVerb)} without ceremony.\n\n${name} is a ${category} built for ${audience} who need to ${purpose}.`,
+ },
+ {
+ context: 'Social / bio',
+ text: `Building ${name} — ${category} for ${audience}. ${cap(purpose)}, no overhead.`,
+ },
+ {
+ context: 'One-liner',
+ text: `${name}: ${category} built to ${purpose}.`,
+ },
+ ];
+
+ if (catType === 'developer') {
+ examples.push({
+ context: 'Package registry description',
+ text: `${name} is a ${category} for ${audience}. ${cap(purpose)}. No configuration required.`,
+ });
+ examples.push({
+ context: 'CLI help text intro',
+ text: `${name} — ${cap(purpose)}.`,
+ });
+ }
+
+ if (catType === 'creative' || catType === 'personal') {
+ examples.push({
+ context: 'Portfolio about line',
+ text: `${name} is the practice of ${audience.split(/\s+/).slice(0, 2).join(' ')} ${heroVerb}.`,
+ });
+ }
+
+ if (catType === 'product') {
+ examples.push({
+ context: 'App store description',
+ text: `${name} is a ${category} for ${audience}. It ${purpose} — without the complexity of larger platforms.`,
+ });
+ }
+
+ return examples.slice(0, 6);
+}
+
+// ── Constraints ───────────────────────────────────────────────────────────────
+
+function generateConstraints(ctx: GenContext): string[] {
+ const { tone, avoid, hasTone } = ctx;
+ const list: string[] = [];
+
+ if (tone.length > 0) {
+ list.push(`Tone: ${tone.join(', ')}.`);
+ }
+
+ if (avoid.length > 0) {
+ list.push(`Avoid: ${avoid.join(', ')}.`);
+ }
+
+ if (hasTone('minimal')) {
+ list.push('Headlines: 6 words maximum.');
+ list.push('Body copy: 2 sentences per paragraph maximum.');
+ }
+
+ if (hasTone('technical')) {
+ list.push('Assume reader has domain knowledge. Skip definitions.');
+ list.push('Prefer specific nouns over categorical ones (say the actual thing).');
+ }
+
+ if (hasTone('calm')) {
+ list.push('No urgency language ("act now", "limited time", "don\'t miss").');
+ list.push('No exclamation points.');
+ }
+
+ if (hasTone('bold')) {
+ list.push('Active voice always. No passive constructions.');
+ list.push('Every claim should be substantiable.');
+ }
+
+ list.push('No em dashes in casual contexts. Use a period or restructure.');
+ list.push('Spell out numbers under 10 in prose. Use numerals for data.');
+ list.push('One idea per sentence. Split if in doubt.');
+
+ return list;
+}
+
+// ── Typography ────────────────────────────────────────────────────────────────
+
+export const TYPE_SCALE: TypographyToken[] = [
+ { label: 'Display', size: '56px', weight: '700', lineHeight: '1.1', usage: 'Hero headlines, major landing sections' },
+ { label: 'Heading 1', size: '40px', weight: '700', lineHeight: '1.2', usage: 'Page titles, primary headers' },
+ { label: 'Heading 2', size: '28px', weight: '600', lineHeight: '1.25', usage: 'Section headers, card titles' },
+ { label: 'Heading 3', size: '20px', weight: '600', lineHeight: '1.3', usage: 'Sub-section headers, feature titles' },
+ { label: 'Body Large', size: '18px', weight: '400', lineHeight: '1.6', usage: 'Lead paragraphs, key descriptions' },
+ { label: 'Body', size: '16px', weight: '400', lineHeight: '1.65', usage: 'Default body copy' },
+ { label: 'Caption', size: '13px', weight: '400', lineHeight: '1.5', usage: 'Meta info, timestamps, helper text' },
+ { label: 'Label', size: '11px', weight: '600', lineHeight: '1.4', usage: 'UI labels, tags, overlines' },
+];
+
+type FontPair = { primary: string; secondary: string; mono: string; pairNote: string };
+
+const FONT_PAIRS: Record<string, FontPair[]> = {
+ developer: [
+ { primary: 'Geist', secondary: 'Geist', mono: 'Geist Mono', pairNote: 'Single-family system. Clean, neutral, interface-optimized.' },
+ { primary: 'Inter', secondary: 'Inter', mono: 'JetBrains Mono', pairNote: 'Inter for all UI copy; JetBrains Mono for code.' },
+ { primary: 'IBM Plex Sans', secondary: 'IBM Plex Sans', mono: 'IBM Plex Mono', pairNote: 'IBM Plex family — coherent, technical, widely legible.' },
+ ],
+ creative: [
+ { primary: 'Playfair Display', secondary: 'Lato', mono: 'Courier Prime', pairNote: 'High-contrast editorial serif for display; Lato for body.' },
+ { primary: 'Fraunces', secondary: 'DM Sans', mono: 'DM Mono', pairNote: 'Optical-size serif for headlines; DM Sans for body. Expressive and modern.' },
+ { primary: 'Cormorant Garamond', secondary: 'Nunito Sans', mono: 'Courier Prime', pairNote: 'Refined luxury serif for display; Nunito Sans for readable body.' },
+ ],
+ product: [
+ { primary: 'Plus Jakarta Sans', secondary: 'Plus Jakarta Sans', mono: 'DM Mono', pairNote: 'Jakarta Sans at varying weights; DM Mono for data and code.' },
+ { primary: 'Inter', secondary: 'Inter', mono: 'Fira Code', pairNote: 'Inter throughout — modern, neutral, excellent hinting.' },
+ { primary: 'Manrope', secondary: 'Manrope', mono: 'JetBrains Mono', pairNote: 'Geometric Manrope for all UI; JetBrains Mono for code blocks.' },
+ ],
+ services: [
+ { primary: 'Libre Baskerville', secondary: 'Source Sans 3', mono: 'Source Code Pro', pairNote: 'Baskerville for authority and trust; Source Sans for approachable body.' },
+ { primary: 'Merriweather', secondary: 'Open Sans', mono: 'Roboto Mono', pairNote: 'Merriweather for credibility; Open Sans keeps body warm.' },
+ { primary: 'Lora', secondary: 'Nunito Sans', mono: 'Courier Prime', pairNote: 'Lora brings warmth to headlines; Nunito Sans lightens the reading.' },
+ ],
+ personal: [
+ { primary: 'Lora', secondary: 'Nunito', mono: 'DM Mono', pairNote: 'Lora for expressive headlines; Nunito for friendly body copy.' },
+ { primary: 'DM Serif Display', secondary: 'DM Sans', mono: 'DM Mono', pairNote: 'Unified DM family. Serif display for character; sans for clarity.' },
+ { primary: 'Playfair Display', secondary: 'Source Sans 3', mono: 'Courier Prime', pairNote: 'Playfair adds personality; Source Sans 3 grounds body text.' },
+ ],
+ general: [
+ { primary: 'Inter', secondary: 'Inter', mono: 'JetBrains Mono', pairNote: 'Inter throughout with weight variation. Universal starting point.' },
+ { primary: 'Plus Jakarta Sans', secondary: 'Lora', mono: 'Fira Code', pairNote: 'Geometric sans for UI; Lora serif for long-form content.' },
+ { primary: 'Manrope', secondary: 'Merriweather', mono: 'Source Code Pro', pairNote: 'Friendly geometric sans paired with a trusted editorial serif.' },
+ ],
+};
+
+function generateTypography(ctx: GenContext): Typography {
+ const pairs = FONT_PAIRS[ctx.catType] ?? FONT_PAIRS.general;
+ return { ...pick(pairs), scale: TYPE_SCALE };
+}
+
+// ── Color Palette ─────────────────────────────────────────────────────────────
+
+function makeSwatches(entries: [string, string, string][]): ColorSwatch[] {
+ return entries.map(([name, hex, role], i) => ({ id: `s${i}`, name, hex, role }));
+}
+
+function generateColorPalette(ctx: GenContext): ColorPalette {
+ type SwatchEntry = [string, string, string]; // [name, hex, role]
+ type PaletteSet = SwatchEntry[][];
+
+ const palettes: Record<string, PaletteSet> = {
+ developer: [
+ [
+ ['Background', '#0d1117', 'background'],
+ ['Surface', '#161b22', 'neutral'],
+ ['Border', '#30363d', 'neutral'],
+ ['Primary', '#58a6ff', 'primary'],
+ ['Accent', '#3fb950', 'accent'],
+ ['Text', '#f0f6fc', 'text'],
+ ],
+ [
+ ['Background', '#0a0010', 'background'],
+ ['Surface', '#160025', 'neutral'],
+ ['Neutral', '#2d1f3d', 'neutral'],
+ ['Primary', '#7c3aed', 'primary'],
+ ['Accent', '#a78bfa', 'accent'],
+ ['Text', '#e2d9f3', 'text'],
+ ],
+ [
+ ['Background', '#0a0a0a', 'background'],
+ ['Surface', '#141414', 'neutral'],
+ ['Neutral', '#292929', 'neutral'],
+ ['Primary', '#e5e5e5', 'primary'],
+ ['Accent', '#c9a96e', 'accent'],
+ ['Text', '#f5f5f5', 'text'],
+ ],
+ ],
+ creative: [
+ [
+ ['Background', '#faf7f2', 'background'],
+ ['Surface', '#f5efe4', 'neutral'],
+ ['Neutral', '#e8d9c4', 'neutral'],
+ ['Primary', '#c07850', 'primary'],
+ ['Accent', '#4a7c5f', 'accent'],
+ ['Text', '#1a1410', 'text'],
+ ],
+ [
+ ['Background', '#0f0f0f', 'background'],
+ ['Surface', '#1a1a1a', 'neutral'],
+ ['Neutral', '#2e2e2e', 'neutral'],
+ ['Primary', '#ff6b35', 'primary'],
+ ['Accent', '#ffd700', 'accent'],
+ ['Text', '#f8f8f8', 'text'],
+ ],
+ [
+ ['Background', '#f8f4ef', 'background'],
+ ['Surface', '#efe9e0', 'neutral'],
+ ['Neutral', '#d4c4b0', 'neutral'],
+ ['Primary', '#8b5e3c', 'primary'],
+ ['Accent', '#6b8f71', 'accent'],
+ ['Text', '#2c2018', 'text'],
+ ],
+ ],
+ product: [
+ [
+ ['Background', '#fafbfc', 'background'],
+ ['Surface', '#f0f4f8', 'neutral'],
+ ['Neutral', '#d1dce8', 'neutral'],
+ ['Primary', '#2563eb', 'primary'],
+ ['Accent', '#7c3aed', 'accent'],
+ ['Text', '#1e293b', 'text'],
+ ],
+ [
+ ['Background', '#0f172a', 'background'],
+ ['Surface', '#1e293b', 'neutral'],
+ ['Neutral', '#334155', 'neutral'],
+ ['Primary', '#6366f1', 'primary'],
+ ['Accent', '#22d3ee', 'accent'],
+ ['Text', '#f1f5f9', 'text'],
+ ],
+ [
+ ['Background', '#f0fafa', 'background'],
+ ['Surface', '#e0f5f5', 'neutral'],
+ ['Neutral', '#b2dede', 'neutral'],
+ ['Primary', '#0d9488', 'primary'],
+ ['Accent', '#f59e0b', 'accent'],
+ ['Text', '#134e4a', 'text'],
+ ],
+ ],
+ services: [
+ [
+ ['Background', '#f8fafd', 'background'],
+ ['Surface', '#edf2fa', 'neutral'],
+ ['Neutral', '#ccd9ee', 'neutral'],
+ ['Primary', '#1d4ed8', 'primary'],
+ ['Accent', '#0f9e6e', 'accent'],
+ ['Text', '#1a2038', 'text'],
+ ],
+ [
+ ['Background', '#0c1421', 'background'],
+ ['Surface', '#152035', 'neutral'],
+ ['Neutral', '#243450', 'neutral'],
+ ['Primary', '#3b82f6', 'primary'],
+ ['Accent', '#34d399', 'accent'],
+ ['Text', '#f8fafc', 'text'],
+ ],
+ [
+ ['Background', '#faf8f5', 'background'],
+ ['Surface', '#f0ebe0', 'neutral'],
+ ['Neutral', '#d9cdb8', 'neutral'],
+ ['Primary', '#78523a', 'primary'],
+ ['Accent', '#2d6a4f', 'accent'],
+ ['Text', '#1c1410', 'text'],
+ ],
+ ],
+ personal: [
+ [
+ ['Background', '#fffef9', 'background'],
+ ['Surface', '#fdf8ee', 'neutral'],
+ ['Neutral', '#f0e6cc', 'neutral'],
+ ['Primary', '#c9a96e', 'primary'],
+ ['Accent', '#7c9e87', 'accent'],
+ ['Text', '#2d2d2d', 'text'],
+ ],
+ [
+ ['Background', '#fafafa', 'background'],
+ ['Surface', '#f5f5f5', 'neutral'],
+ ['Neutral', '#e5e5e5', 'neutral'],
+ ['Primary', '#171717', 'primary'],
+ ['Accent', '#737373', 'accent'],
+ ['Text', '#404040', 'text'],
+ ],
+ [
+ ['Background', '#f9f8ff', 'background'],
+ ['Surface', '#f0eeff', 'neutral'],
+ ['Neutral', '#ddd8f7', 'neutral'],
+ ['Primary', '#4f46e5', 'primary'],
+ ['Accent', '#ec4899', 'accent'],
+ ['Text', '#1e1b4b', 'text'],
+ ],
+ ],
+ general: [
+ [
+ ['Background', '#111111', 'background'],
+ ['Surface', '#1a1a1a', 'neutral'],
+ ['Neutral', '#2e2e2e', 'neutral'],
+ ['Primary', '#c9a96e', 'primary'],
+ ['Accent', '#6b8f71', 'accent'],
+ ['Text', '#dedede', 'text'],
+ ],
+ [
+ ['Background', '#ffffff', 'background'],
+ ['Surface', '#f5f5f5', 'neutral'],
+ ['Neutral', '#e0e0e0', 'neutral'],
+ ['Primary', '#1a1a1a', 'primary'],
+ ['Accent', '#3b82f6', 'accent'],
+ ['Text', '#333333', 'text'],
+ ],
+ [
+ ['Background', '#0f0f1a', 'background'],
+ ['Surface', '#1a1a2e', 'neutral'],
+ ['Neutral', '#252545', 'neutral'],
+ ['Primary', '#4f8ef7', 'primary'],
+ ['Accent', '#a78bfa', 'accent'],
+ ['Text', '#e2e8f0', 'text'],
+ ],
+ ],
+ };
+
+ const pool = palettes[ctx.catType] ?? palettes.general;
+ return { swatches: makeSwatches(pick(pool) as SwatchEntry[]) };
+}
+
+// ── Entry point ───────────────────────────────────────────────────────────────
+
+export function generate(inputs: BrandInputs): BrandOutputs {
+ const ctx = buildContext(inputs);
+
+ return {
+ overview: generateOverview(ctx),
+ positioning: generatePositioning(ctx),
+ tone: generateToneGuidance(ctx),
+ titles: generateTitles(ctx),
+ subtitles: generateSubtitles(ctx),
+ taglines: generateTaglines(ctx),
+ visualDirections: generateVisualDirections(ctx),
+ palette: generateColorPalette(ctx),
+ typography: generateTypography(ctx),
+ logoConcepts: generateLogoConcepts(ctx),
+ usageExamples: generateUsageExamples(ctx),
+ constraints: generateConstraints(ctx),
+ };
+}
diff --git a/src/hooks/usePackages.ts b/src/hooks/usePackages.ts
new file mode 100644
index 0000000..b137160
--- /dev/null
+++ b/src/hooks/usePackages.ts
@@ -0,0 +1,112 @@
+import { useState, useCallback } from 'react';
+
+export interface PackageSlot {
+ id: string;
+ name: string;
+ createdAt: string;
+ storageKey: string;
+}
+
+interface PackagesStore {
+ activeId: string;
+ slots: PackageSlot[];
+}
+
+const STORE_KEY = 'bw:packages';
+const LEGACY_KEY = 'bw:workspace';
+
+function makeId(): string {
+ return `pkg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+function makeSlot(name: string, id = makeId()): PackageSlot {
+ return { id, name, createdAt: new Date().toISOString(), storageKey: `bw:ws:${id}` };
+}
+
+function loadStore(): PackagesStore {
+ try {
+ const raw = localStorage.getItem(STORE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch { /* ignore */ }
+
+ // First run — migrate any legacy single-workspace data into the first slot
+ const firstSlot = makeSlot('Package 1');
+ try {
+ const legacy = localStorage.getItem(LEGACY_KEY);
+ if (legacy) localStorage.setItem(firstSlot.storageKey, legacy);
+ } catch { /* ignore */ }
+
+ const store: PackagesStore = { activeId: firstSlot.id, slots: [firstSlot] };
+ saveStore(store);
+ return store;
+}
+
+function saveStore(store: PackagesStore): void {
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch { /* ignore */ }
+}
+
+export function usePackages() {
+ const [store, setStore] = useState<PackagesStore>(loadStore);
+
+ const activeSlot = store.slots.find(s => s.id === store.activeId) ?? store.slots[0];
+
+ // useWorkspace saves directly to each slot's storageKey on every change,
+ // so switching just needs to update activeId — no snapshot/copy required.
+ const switchTo = useCallback((id: string) => {
+ setStore(prev => {
+ if (prev.activeId === id) return prev;
+ const next = { ...prev, activeId: id };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const createNew = useCallback(() => {
+ setStore(prev => {
+ const slot = makeSlot(`Package ${prev.slots.length + 1}`);
+ // New slot has no data in localStorage — useWorkspace will start fresh
+ const next = { activeId: slot.id, slots: [...prev.slots, slot] };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ setStore(prev => {
+ if (prev.slots.length <= 1) return prev;
+ const target = prev.slots.find(s => s.id === id);
+ try { if (target) localStorage.removeItem(target.storageKey); } catch { /* ignore */ }
+ const slots = prev.slots.filter(s => s.id !== id);
+ const activeId = prev.activeId === id ? slots[0].id : prev.activeId;
+ const next = { activeId, slots };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const rename = useCallback((id: string, name: string) => {
+ setStore(prev => {
+ const slots = prev.slots.map(s => s.id === id ? { ...s, name } : s);
+ const next = { ...prev, slots };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ const duplicate = useCallback((id: string) => {
+ setStore(prev => {
+ const source = prev.slots.find(s => s.id === id);
+ if (!source) return prev;
+ const newSlot = makeSlot(`${source.name} copy`);
+ try {
+ const data = localStorage.getItem(source.storageKey);
+ if (data) localStorage.setItem(newSlot.storageKey, data);
+ } catch { /* ignore */ }
+ const next = { activeId: newSlot.id, slots: [...prev.slots, newSlot] };
+ saveStore(next);
+ return next;
+ });
+ }, []);
+
+ return { slots: store.slots, activeId: store.activeId, activeSlot, switchTo, createNew, remove, rename, duplicate };
+}
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
new file mode 100644
index 0000000..2452945
--- /dev/null
+++ b/src/hooks/useSettings.ts
@@ -0,0 +1,41 @@
+import { useState } from 'react';
+
+export interface OllamaSettings {
+ enabled: boolean;
+ baseUrl: string;
+ model: string;
+}
+
+const DEFAULTS: OllamaSettings = {
+ enabled: false,
+ baseUrl: 'http://localhost:11434',
+ model: 'llama3.2',
+};
+
+const KEY = 'bw:settings';
+
+function load(): OllamaSettings {
+ try {
+ const raw = localStorage.getItem(KEY);
+ return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : { ...DEFAULTS };
+ } catch {
+ return { ...DEFAULTS };
+ }
+}
+
+// Non-reactive read — used inside callbacks without needing the hook
+export function readSettings(): OllamaSettings {
+ return load();
+}
+
+export function useSettings() {
+ const [settings, setSettingsRaw] = useState<OllamaSettings>(load);
+
+ const setSettings = (next: Partial<OllamaSettings>) => {
+ const merged = { ...settings, ...next };
+ try { localStorage.setItem(KEY, JSON.stringify(merged)); } catch { /* ignore */ }
+ setSettingsRaw(merged);
+ };
+
+ return { settings, setSettings };
+}
diff --git a/src/hooks/useWorkspace.ts b/src/hooks/useWorkspace.ts
new file mode 100644
index 0000000..c583269
--- /dev/null
+++ b/src/hooks/useWorkspace.ts
@@ -0,0 +1,225 @@
+import { useState, useCallback, useRef } from 'react';
+import type { BrandInputs, BrandOutputs, LockedSections, WorkspaceState } from '../types';
+import { generate } from '../engine/generator';
+import { generateWithAI } from '../engine/aiGenerator';
+import { readSettings } from './useSettings';
+import { sanitizeOutputs } from '../lib/sanitize';
+
+const DEFAULT_INPUTS: BrandInputs = {
+ name: '',
+ category: '',
+ purpose: '',
+ audience: '',
+ tone: [],
+ avoid: [],
+ notes: '',
+};
+
+const DEFAULT_LOCKED: LockedSections = {
+ overview: false,
+ positioning: false,
+ tone: false,
+ messaging: false,
+ visual: false,
+ palette: false,
+ typography: false,
+ logo: false,
+ usage: false,
+ constraints: false,
+};
+
+function loadState(storageKey: string): Partial<WorkspaceState> {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (raw) {
+ const state = JSON.parse(raw) as Partial<WorkspaceState>;
+ // Always run through sanitizeOutputs — coerces any corrupted/legacy field
+ // (e.g. AI-returned objects in string fields) so React never crashes on render.
+ if (state.outputs) {
+ state.outputs = sanitizeOutputs(state.outputs) ?? null;
+ if (!state.outputs) state.edits = {};
+ }
+ return state;
+ }
+ } catch { /* ignore */ }
+ return {};
+}
+
+function saveState(storageKey: string, state: WorkspaceState): void {
+ try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch { /* ignore */ }
+}
+
+function applyLocks(
+ merged: BrandOutputs,
+ locked: LockedSections,
+ outputs: BrandOutputs | null,
+ edits: Partial<BrandOutputs>,
+): BrandOutputs {
+ if (!outputs) return merged;
+ if (locked.overview) merged.overview = edits.overview ?? outputs.overview;
+ if (locked.positioning) merged.positioning = edits.positioning ?? outputs.positioning;
+ if (locked.tone) merged.tone = edits.tone ?? outputs.tone;
+ if (locked.messaging) {
+ merged.titles = edits.titles ?? outputs.titles;
+ merged.subtitles = edits.subtitles ?? outputs.subtitles;
+ merged.taglines = edits.taglines ?? outputs.taglines;
+ }
+ if (locked.visual) merged.visualDirections = edits.visualDirections ?? outputs.visualDirections;
+ if (locked.palette) merged.palette = edits.palette ?? outputs.palette;
+ if (locked.typography) merged.typography = edits.typography ?? outputs.typography;
+ if (locked.logo) merged.logoConcepts = edits.logoConcepts ?? outputs.logoConcepts;
+ if (locked.usage) merged.usageExamples = edits.usageExamples ?? outputs.usageExamples;
+ if (locked.constraints) merged.constraints = edits.constraints ?? outputs.constraints;
+ return merged;
+}
+
+export function useWorkspace(storageKey = 'bw:workspace') {
+ const saved = loadState(storageKey);
+
+ const [inputs, setInputsRaw] = useState<BrandInputs>(saved.inputs ?? DEFAULT_INPUTS);
+ const [outputs, setOutputs] = useState<BrandOutputs | null>(saved.outputs ?? null);
+ const [edits, setEditsRaw] = useState<Partial<BrandOutputs>>(saved.edits ?? {});
+ const [locked, setLocked] = useState<LockedSections>(saved.locked ?? DEFAULT_LOCKED);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [generateMode, setGenerateMode] = useState<'template' | string>('template');
+ const [generateError, setGenerateError] = useState<string | null>(null);
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ const editStack = useRef<Array<Partial<BrandOutputs>>>([saved.edits ?? {}]);
+ const stackIdx = useRef(0);
+ const abortRef = useRef<AbortController | null>(null);
+
+ const stateRef = useRef({ inputs, outputs, edits, locked });
+ stateRef.current = { inputs, outputs, edits, locked };
+
+ const syncUndoRedo = () => {
+ setCanUndo(stackIdx.current > 0);
+ setCanRedo(stackIdx.current < editStack.current.length - 1);
+ };
+
+ const setEdits = useCallback((next: Partial<BrandOutputs>, pushHistory: boolean) => {
+ if (pushHistory) {
+ editStack.current = editStack.current.slice(0, stackIdx.current + 1);
+ editStack.current.push(next);
+ stackIdx.current = editStack.current.length - 1;
+ }
+ setEditsRaw(next);
+ syncUndoRedo();
+ }, []);
+
+ const persistInputs = useCallback((value: BrandInputs) => {
+ const { outputs, edits, locked } = stateRef.current;
+ saveState(storageKey, { inputs: value, outputs, edits, locked });
+ }, [storageKey]);
+
+ const setInputs = useCallback((next: BrandInputs | ((prev: BrandInputs) => BrandInputs)) => {
+ setInputsRaw(prev => {
+ const value = typeof next === 'function' ? next(prev) : next;
+ persistInputs(value);
+ return value;
+ });
+ }, [persistInputs]);
+
+ const runGenerate = useCallback(async () => {
+ // Cancel any in-flight request
+ abortRef.current?.abort();
+ const abort = new AbortController();
+ abortRef.current = abort;
+
+ const { inputs, outputs, edits, locked } = stateRef.current;
+ const settings = readSettings();
+
+ setIsGenerating(true);
+ setGenerateError(null);
+ setGenerateMode(settings.enabled ? settings.model : 'template');
+
+ try {
+ let newOutputs: BrandOutputs;
+
+ if (settings.enabled) {
+ newOutputs = await generateWithAI(inputs, settings, abort.signal);
+ } else {
+ await new Promise(r => setTimeout(r, 120));
+ if (abort.signal.aborted) return;
+ newOutputs = generate(inputs);
+ }
+
+ if (abort.signal.aborted) return;
+
+ const merged = applyLocks({ ...newOutputs }, locked, outputs, edits);
+ setOutputs(merged);
+ editStack.current = [{}];
+ stackIdx.current = 0;
+ setEditsRaw({});
+ syncUndoRedo();
+ saveState(storageKey, { inputs, outputs: merged, edits: {}, locked });
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') return;
+ setGenerateError((err as Error).message);
+ } finally {
+ setIsGenerating(false);
+ }
+ }, [storageKey]);
+
+ const cancelGenerate = useCallback(() => {
+ abortRef.current?.abort();
+ setIsGenerating(false);
+ setGenerateError(null);
+ }, []);
+
+ const updateEdit = useCallback(<K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => {
+ const { inputs, outputs, locked } = stateRef.current;
+ const next = { ...stateRef.current.edits, [key]: value };
+ setEdits(next, true);
+ saveState(storageKey, { inputs, outputs, edits: next, locked });
+ }, [storageKey, setEdits]);
+
+ const undo = useCallback(() => {
+ if (stackIdx.current <= 0) return;
+ stackIdx.current -= 1;
+ const prev = editStack.current[stackIdx.current];
+ const { inputs, outputs, locked } = stateRef.current;
+ setEdits(prev, false);
+ saveState(storageKey, { inputs, outputs, edits: prev, locked });
+ }, [storageKey, setEdits]);
+
+ const redo = useCallback(() => {
+ if (stackIdx.current >= editStack.current.length - 1) return;
+ stackIdx.current += 1;
+ const next = editStack.current[stackIdx.current];
+ const { inputs, outputs, locked } = stateRef.current;
+ setEdits(next, false);
+ saveState(storageKey, { inputs, outputs, edits: next, locked });
+ }, [storageKey, setEdits]);
+
+ const toggleLock = useCallback((section: keyof LockedSections) => {
+ setLocked(prev => {
+ const next = { ...prev, [section]: !prev[section] };
+ const { inputs, outputs, edits } = stateRef.current;
+ saveState(storageKey, { inputs, outputs, edits, locked: next });
+ return next;
+ });
+ }, [storageKey]);
+
+ const effectiveOutputs: BrandOutputs | null = outputs ? { ...outputs, ...edits } : null;
+
+ return {
+ inputs,
+ setInputs,
+ outputs: effectiveOutputs,
+ isGenerating,
+ generateMode,
+ generateError,
+ locked,
+ runGenerate,
+ cancelGenerate,
+ updateEdit,
+ toggleLock,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ hasOutput: outputs !== null,
+ };
+}
diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts
new file mode 100644
index 0000000..d735a51
--- /dev/null
+++ b/src/lib/clipboard.ts
@@ -0,0 +1,17 @@
+export async function copyToClipboard(text: string): Promise<boolean> {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fallback for older browsers
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'fixed';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(el);
+ return ok;
+ }
+}
diff --git a/src/lib/export.ts b/src/lib/export.ts
new file mode 100644
index 0000000..9e1a7f1
--- /dev/null
+++ b/src/lib/export.ts
@@ -0,0 +1,326 @@
+import type { BrandInputs, BrandOutputs } from '../types';
+
+export function toMarkdown(inputs: BrandInputs, outputs: BrandOutputs): string {
+ const lines: string[] = [];
+
+ lines.push(`# Brand Package — ${inputs.name || 'Untitled'}`);
+ lines.push('');
+ lines.push(`*Generated ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}*`);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+
+ lines.push('## Overview');
+ lines.push('');
+ lines.push(outputs.overview);
+ lines.push('');
+
+ lines.push('## Positioning');
+ lines.push('');
+ lines.push(outputs.positioning);
+ lines.push('');
+
+ lines.push('## Tone & Voice');
+ lines.push('');
+ lines.push(`**Attributes:** ${outputs.tone.attributes.join(', ')}`);
+ lines.push('');
+ lines.push(`**Voice:** ${outputs.tone.voiceNotes}`);
+ lines.push('');
+ if (outputs.tone.avoidList.length > 0) {
+ lines.push(`**Avoid:** ${outputs.tone.avoidList.join(', ')}`);
+ lines.push('');
+ }
+ if (outputs.tone.examplePhrases.length > 0) {
+ lines.push('**Example phrases:**');
+ outputs.tone.examplePhrases.forEach(p => lines.push(`- ${p}`));
+ lines.push('');
+ }
+
+ lines.push('## Messaging');
+ lines.push('');
+ lines.push('### Titles');
+ outputs.titles.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
+ lines.push('');
+ lines.push('### Subtitles');
+ outputs.subtitles.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
+ lines.push('');
+ lines.push('### Taglines');
+ outputs.taglines.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
+ lines.push('');
+
+ lines.push('## Color Palette');
+ lines.push('');
+ outputs.palette.swatches.forEach(s => {
+ lines.push(`- **${s.name}** — \`${s.hex}\` *(${s.role})*`);
+ });
+ lines.push('');
+
+ lines.push('## Typography');
+ lines.push('');
+ lines.push(`**Primary:** ${outputs.typography.primary}`);
+ if (outputs.typography.secondary !== outputs.typography.primary)
+ lines.push(`**Secondary:** ${outputs.typography.secondary}`);
+ lines.push(`**Monospace:** ${outputs.typography.mono}`);
+ lines.push('');
+ lines.push(`*${outputs.typography.pairNote}*`);
+ lines.push('');
+ lines.push('| Style | Size | Weight | Usage |');
+ lines.push('|-------|------|--------|-------|');
+ outputs.typography.scale.forEach(t => {
+ lines.push(`| ${t.label} | ${t.size} | ${t.weight} | ${t.usage} |`);
+ });
+ lines.push('');
+
+ lines.push('## Visual Direction');
+ lines.push('');
+ outputs.visualDirections.forEach(dir => {
+ lines.push(`### ${dir.name}`);
+ lines.push('');
+ lines.push(dir.description);
+ lines.push('');
+ lines.push(`**Palette:** ${dir.palette}`);
+ lines.push('');
+ lines.push(`**Typography:** ${dir.typography}`);
+ lines.push('');
+ lines.push(`**References:** ${dir.references}`);
+ lines.push('');
+ });
+
+ lines.push('## Logo Concepts');
+ lines.push('');
+ outputs.logoConcepts.forEach(lc => {
+ lines.push(`### ${lc.title}`);
+ lines.push('');
+ lines.push(lc.concept);
+ lines.push('');
+ lines.push(`**Mark:** ${lc.mark}`);
+ lines.push('');
+ lines.push(`**Execution:** ${lc.execution}`);
+ lines.push('');
+ });
+
+ lines.push('## Usage Examples');
+ lines.push('');
+ outputs.usageExamples.forEach(ex => {
+ lines.push(`### ${ex.context}`);
+ lines.push('');
+ lines.push('```');
+ lines.push(ex.text);
+ lines.push('```');
+ lines.push('');
+ });
+
+ lines.push('## Constraints');
+ lines.push('');
+ outputs.constraints.forEach(c => lines.push(`- ${c}`));
+ lines.push('');
+
+ lines.push('---');
+ lines.push('');
+ lines.push('*Brand Workbench*');
+
+ return lines.join('\n');
+}
+
+export function toJSON(inputs: BrandInputs, outputs: BrandOutputs): string {
+ return JSON.stringify(
+ {
+ project: {
+ name: inputs.name,
+ category: inputs.category,
+ purpose: inputs.purpose,
+ audience: inputs.audience,
+ },
+ brand: outputs,
+ meta: {
+ generated: new Date().toISOString(),
+ version: '1.0',
+ },
+ },
+ null,
+ 2
+ );
+}
+
+export function toHTML(inputs: BrandInputs, outputs: BrandOutputs): string {
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+ const swatchesHtml = outputs.palette.swatches.map(s => `
+ <div class="swatch">
+ <div class="swatch-color" style="background:${esc(s.hex)}"></div>
+ <div class="swatch-name">${esc(s.name)}</div>
+ <div class="swatch-hex">${esc(s.hex)}</div>
+ </div>`).join('');
+
+ const scaleRows = outputs.typography.scale.map(t => `
+ <tr><td>${esc(t.label)}</td><td>${esc(t.size)}</td><td>${esc(t.weight)}</td><td>${esc(t.usage)}</td></tr>`).join('');
+
+ const dirsHtml = outputs.visualDirections.map(d => `
+ <div class="card">
+ <h3>${esc(d.name)}</h3>
+ <p>${esc(d.description)}</p>
+ <dl>
+ <dt>Palette</dt><dd>${esc(d.palette)}</dd>
+ <dt>Typography</dt><dd>${esc(d.typography)}</dd>
+ <dt>References</dt><dd>${esc(d.references)}</dd>
+ </dl>
+ </div>`).join('');
+
+ const logosHtml = outputs.logoConcepts.map(l => `
+ <div class="card">
+ <h3>${esc(l.title)}</h3>
+ <p>${esc(l.concept)}</p>
+ <dl>
+ <dt>Mark</dt><dd>${esc(l.mark)}</dd>
+ <dt>Execution</dt><dd>${esc(l.execution)}</dd>
+ </dl>
+ </div>`).join('');
+
+ const usageHtml = outputs.usageExamples.map(ex => `
+ <div class="usage-item">
+ <div class="usage-label">${esc(ex.context)}</div>
+ <pre>${esc(ex.text)}</pre>
+ </div>`).join('');
+
+ const constraintsHtml = outputs.constraints.map(c => `<li>${esc(c)}</li>`).join('\n');
+
+ const secondaryRow = outputs.typography.secondary !== outputs.typography.primary
+ ? `<tr><td>Secondary</td><td>${esc(outputs.typography.secondary)}</td></tr>` : '';
+
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(inputs.name || 'Brand')} — Brand Guidelines</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ html { font-size: 15px; }
+ body { font-family: ui-sans-serif, -apple-system, system-ui, sans-serif; color: #1a1a1a; background: #fff; line-height: 1.6; }
+ .guide { max-width: 860px; margin: 0 auto; padding: 60px 40px 100px; }
+ .guide-header { border-bottom: 2px solid #111; padding-bottom: 24px; margin-bottom: 48px; }
+ .guide-header h1 { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; }
+ .guide-header .meta { font-size: 13px; color: #777; margin-top: 6px; }
+ h2 { font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: #999; margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
+ section { margin-bottom: 56px; }
+ p { color: #333; margin-bottom: 12px; }
+ .font-table, .scale-table { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 16px; }
+ .font-table td, .scale-table td, .scale-table th { padding: 8px 12px; border-bottom: 1px solid #eee; text-align: left; }
+ .scale-table th { font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.06em; }
+ .font-table td:first-child { color: #999; width: 120px; }
+ .pair-note { font-size: 13px; color: #777; font-style: italic; margin-top: 8px; }
+ .swatches { display: flex; flex-wrap: wrap; gap: 12px; }
+ .swatch { width: 100px; }
+ .swatch-color { width: 100px; height: 64px; border-radius: 6px; border: 1px solid rgba(0,0,0,.08); margin-bottom: 6px; }
+ .swatch-name { font-size: 12px; font-weight: 500; color: #333; }
+ .swatch-hex { font-size: 11px; font-family: ui-monospace, monospace; color: #777; }
+ .card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
+ .card h3 { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
+ .card p { font-size: 14px; color: #555; margin-bottom: 12px; }
+ dl { display: grid; grid-template-columns: 100px 1fr; gap: 4px 16px; font-size: 13px; }
+ dt { color: #999; font-weight: 500; }
+ dd { color: #444; }
+ .usage-item { margin-bottom: 20px; }
+ .usage-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin-bottom: 6px; }
+ pre { background: #f5f5f5; border-radius: 6px; padding: 14px 16px; font-size: 13px; font-family: ui-monospace, monospace; white-space: pre-wrap; color: #333; line-height: 1.6; }
+ ul { padding-left: 20px; }
+ li { font-size: 14px; color: #444; margin-bottom: 6px; }
+ .tone-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
+ .tone-pill { background: #f0f0f0; border-radius: 4px; padding: 3px 10px; font-size: 12px; color: #555; }
+ .tone-row { font-size: 14px; color: #444; margin-bottom: 8px; }
+ .tone-row strong { color: #999; display: inline-block; min-width: 100px; }
+ .taglines ol, .titles ol { padding-left: 20px; }
+ .taglines li, .titles li { font-size: 15px; color: #333; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div class="guide">
+ <div class="guide-header">
+ <h1>${esc(inputs.name || 'Brand')} Guidelines</h1>
+ <div class="meta">Generated ${date}${inputs.category ? ` · ${esc(inputs.category)}` : ''}${inputs.audience ? ` · ${esc(inputs.audience)}` : ''}</div>
+ </div>
+
+ <section>
+ <h2>Overview</h2>
+ <p>${esc(outputs.overview)}</p>
+ </section>
+
+ <section>
+ <h2>Positioning</h2>
+ <p>${esc(outputs.positioning)}</p>
+ </section>
+
+ <section>
+ <h2>Tone &amp; Voice</h2>
+ <div class="tone-pills">${outputs.tone.attributes.map(a => `<span class="tone-pill">${esc(a)}</span>`).join('')}</div>
+ <div class="tone-row"><strong>Voice</strong>${esc(outputs.tone.voiceNotes)}</div>
+ ${outputs.tone.avoidList.length ? `<div class="tone-row"><strong>Avoid</strong>${esc(outputs.tone.avoidList.join(', '))}</div>` : ''}
+ </section>
+
+ <section>
+ <h2>Messaging</h2>
+ <div class="titles">
+ <p><strong>Titles</strong></p>
+ <ol>${outputs.titles.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
+ </div>
+ <br>
+ <div class="taglines">
+ <p><strong>Taglines</strong></p>
+ <ol>${outputs.taglines.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
+ </div>
+ </section>
+
+ <section>
+ <h2>Color Palette</h2>
+ <div class="swatches">${swatchesHtml}</div>
+ </section>
+
+ <section>
+ <h2>Typography</h2>
+ <table class="font-table">
+ <tr><td>Primary</td><td>${esc(outputs.typography.primary)}</td></tr>
+ ${secondaryRow}
+ <tr><td>Monospace</td><td>${esc(outputs.typography.mono)}</td></tr>
+ </table>
+ <p class="pair-note">${esc(outputs.typography.pairNote)}</p>
+ <br>
+ <table class="scale-table">
+ <thead><tr><th>Style</th><th>Size</th><th>Weight</th><th>Usage</th></tr></thead>
+ <tbody>${scaleRows}</tbody>
+ </table>
+ </section>
+
+ <section>
+ <h2>Visual Direction</h2>
+ ${dirsHtml}
+ </section>
+
+ <section>
+ <h2>Logo Concepts</h2>
+ ${logosHtml}
+ </section>
+
+ <section>
+ <h2>Usage Examples</h2>
+ ${usageHtml}
+ </section>
+
+ <section>
+ <h2>Constraints</h2>
+ <ul>${constraintsHtml}</ul>
+ </section>
+</div>
+</body>
+</html>`;
+}
+
+export function downloadFile(content: string, filename: string, mimeType: string): void {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..d63eb19
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,141 @@
+/**
+ * Defensive coercion helpers for AI-generated brand outputs.
+ * AI models sometimes return structured objects instead of plain strings/arrays;
+ * these helpers normalise any value to the expected type so React never receives
+ * an object where it expects a renderable child.
+ */
+import type { BrandOutputs } from '../types';
+import { TYPE_SCALE } from '../engine/generator';
+
+// ---------------------------------------------------------------------------
+// Primitive coercions
+// ---------------------------------------------------------------------------
+
+/** Coerce any value to a non-empty string. */
+export function str(v: unknown, fallback = ''): string {
+ if (typeof v === 'string') return v;
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
+ if (v && typeof v === 'object') {
+ const o = v as Record<string, unknown>;
+ for (const k of ['text', 'content', 'value', 'description', 'name', 'label']) {
+ if (typeof o[k] === 'string' && o[k]) return o[k] as string;
+ }
+ const vals = Object.values(o).filter(x => typeof x === 'string') as string[];
+ if (vals.length) return vals.join(' — ');
+ }
+ return fallback;
+}
+
+/** Coerce any value to an array of non-empty strings. */
+export function strArr(v: unknown, fallback: string[] = []): string[] {
+ if (Array.isArray(v)) return v.map(x => str(x)).filter(Boolean);
+ if (typeof v === 'string' && v) return [v];
+ if (v && typeof v === 'object') return [str(v)].filter(Boolean);
+ return fallback;
+}
+
+function normalizeHex(hex: string): string {
+ const h = hex.trim().toLowerCase();
+ const clean = h.startsWith('#') ? h : `#${h}`;
+ return /^#[0-9a-f]{6}$/.test(clean) ? clean : '#888888';
+}
+
+// ---------------------------------------------------------------------------
+// Full output sanitiser — safe to call on any untrusted value
+// ---------------------------------------------------------------------------
+
+/**
+ * Recursively coerce every field of a brand output object to its expected type.
+ * Returns `null` if the input is clearly not a valid brand output at all.
+ */
+export function sanitizeOutputs(raw: unknown): BrandOutputs | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record<string, unknown>;
+
+ // Must have at least the core keys to be worth keeping
+ if (!r.overview && !r.positioning && !r.tone) return null;
+
+ const tone = (r.tone && typeof r.tone === 'object' ? r.tone : {}) as Record<string, unknown>;
+ const typo = (r.typography && typeof r.typography === 'object' ? r.typography : {}) as Record<string, unknown>;
+ const palette = (r.palette && typeof r.palette === 'object' ? r.palette : {}) as Record<string, unknown>;
+
+ const swatches = Array.isArray(palette.swatches)
+ ? palette.swatches.map((s: unknown, i: number) => {
+ const sw = (s && typeof s === 'object' ? s : {}) as Record<string, unknown>;
+ return {
+ id: str(sw.id, `s${i}`),
+ name: str(sw.name, 'Color'),
+ hex: normalizeHex(str(sw.hex, '#888888')),
+ role: str(sw.role, 'accent'),
+ };
+ })
+ : [];
+
+ if (!swatches.length) return null;
+
+ const visualDirections = Array.isArray(r.visualDirections)
+ ? r.visualDirections.map((v: unknown, i: number) => {
+ const d = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ id: str(d.id, `v${i + 1}`),
+ name: str(d.name, `Direction ${i + 1}`),
+ description: str(d.description, ''),
+ palette: str(d.palette, ''),
+ typography: str(d.typography, ''),
+ references: str(d.references, ''),
+ };
+ })
+ : [];
+
+ const logoConcepts = Array.isArray(r.logoConcepts)
+ ? r.logoConcepts.map((v: unknown, i: number) => {
+ const c = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ id: str(c.id, `l${i + 1}`),
+ title: str(c.title, `Concept ${i + 1}`),
+ concept: str(c.concept, ''),
+ mark: str(c.mark, ''),
+ execution: str(c.execution, ''),
+ };
+ })
+ : [];
+
+ const usageExamples = Array.isArray(r.usageExamples)
+ ? r.usageExamples.map((v: unknown) => {
+ const e = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ context: str(e.context, 'Example'),
+ text: str(e.text, ''),
+ };
+ })
+ : [];
+
+ // Preserve an existing valid scale (already-saved data) or fall back to default
+ const existingScale = Array.isArray(typo.scale) ? typo.scale : TYPE_SCALE;
+
+ return {
+ overview: str(r.overview, ''),
+ positioning: str(r.positioning, ''),
+ tone: {
+ attributes: strArr(tone.attributes, []),
+ voiceNotes: str(tone.voiceNotes, ''),
+ avoidList: strArr(tone.avoidList, []),
+ examplePhrases: strArr(tone.examplePhrases, []),
+ },
+ titles: strArr(r.titles, []),
+ subtitles: strArr(r.subtitles, []),
+ taglines: strArr(r.taglines, []),
+ palette: { swatches },
+ typography: {
+ primary: str(typo.primary, 'Inter'),
+ secondary: str(typo.secondary, 'Inter'),
+ mono: str(typo.mono, 'JetBrains Mono'),
+ pairNote: str(typo.pairNote, ''),
+ scale: existingScale,
+ },
+ visualDirections,
+ logoConcepts,
+ usageExamples,
+ constraints: strArr(r.constraints, []),
+ };
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..4c5213a
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './styles/globals.css';
+import App from './App';
+
+createRoot(document.getElementById('root')!).render(
+ <StrictMode>
+ <App />
+ </StrictMode>
+);
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..8fb1ae3
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,1594 @@
+/* ── Reset ───────────────────────────────────────────────────────────────── */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 14px; }
+
+/* ── Tokens ──────────────────────────────────────────────────────────────── */
+
+:root {
+ --bg: #0d0d0d;
+ --bg-1: #111111;
+ --bg-2: #161616;
+ --bg-3: #1c1c1c;
+ --bg-4: #222222;
+
+ --border: #242424;
+ --border-2: #1a1a1a;
+ --border-3: #2e2e2e;
+
+ --text: #dedede;
+ --text-2: #999999;
+ --text-3: #555555;
+ --text-4: #333333;
+
+ --accent: #c9a96e;
+ --accent-dim: #7a6341;
+
+ --success: #4a9470;
+ --danger: #8a3030;
+
+ --font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ --font-mono: ui-monospace, 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
+
+ --r: 4px;
+ --r-lg: 6px;
+ --header-h: 44px;
+ --panel-w: 360px;
+}
+
+/* ── Base ────────────────────────────────────────────────────────────────── */
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 13px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ overflow: hidden;
+ height: 100vh;
+}
+
+#root { height: 100vh; display: flex; flex-direction: column; }
+
+/* ── App Header ──────────────────────────────────────────────────────────── */
+
+.app-header {
+ height: var(--header-h);
+ min-height: var(--header-h);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-1);
+ flex-shrink: 0;
+ gap: 12px;
+}
+
+.app-header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.app-wordmark {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ color: var(--text);
+ text-transform: uppercase;
+ flex-shrink: 0;
+}
+
+.app-wordmark span {
+ color: var(--text-3);
+ font-weight: 400;
+}
+
+.app-project-name {
+ font-size: 12px;
+ color: var(--text-3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.app-project-name::before {
+ content: '/ ';
+ color: var(--text-4);
+}
+
+.app-header-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+/* ── Package Switcher ────────────────────────────────────────────────────── */
+
+.pkg-switcher {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ max-width: 480px;
+}
+
+.pkg-switcher::-webkit-scrollbar { display: none; }
+
+.pkg-tab {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+ height: 26px;
+ border-radius: var(--r);
+ border: 1px solid transparent;
+ font-size: 12px;
+ color: var(--text-3);
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+ transition: all 0.1s;
+ flex-shrink: 0;
+}
+
+.pkg-tab:hover {
+ background: var(--bg-3);
+ color: var(--text-2);
+}
+
+.pkg-tab.active {
+ background: var(--bg-3);
+ border-color: var(--border);
+ color: var(--text);
+}
+
+.pkg-tab-name {
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pkg-tab-input {
+ width: 100px;
+ background: transparent;
+ border: none;
+ outline: none;
+ font-size: 12px;
+ font-family: inherit;
+ color: var(--text);
+ padding: 0;
+}
+
+.pkg-tab-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: 2px;
+ opacity: 0;
+ transition: opacity 0.1s;
+}
+
+.pkg-tab:hover .pkg-tab-actions,
+.pkg-tab.active .pkg-tab-actions {
+ opacity: 1;
+}
+
+.pkg-tab-close, .pkg-tab-menu-btn {
+ background: none;
+ border: none;
+ color: var(--text-4);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0 2px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ border-radius: 2px;
+ height: 16px;
+}
+
+.pkg-tab-close:hover, .pkg-tab-menu-btn:hover {
+ background: var(--bg-4);
+ color: var(--text-2);
+}
+
+.pkg-tab-menu-btn {
+ letter-spacing: -1px;
+ font-size: 10px;
+}
+
+.pkg-tab-dropdown {
+ background: var(--bg-3);
+ border: 1px solid var(--border-3);
+ border-radius: var(--r-lg);
+ padding: 4px;
+ z-index: 100;
+ min-width: 120px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+}
+
+.pkg-tab-dropdown button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ color: var(--text-2);
+ font-size: 12px;
+ font-family: inherit;
+ padding: 5px 8px;
+ border-radius: var(--r);
+ cursor: pointer;
+}
+
+.pkg-tab-dropdown button:hover {
+ background: var(--bg-4);
+ color: var(--text);
+}
+
+.pkg-tab-dropdown button.danger { color: #c04040; }
+.pkg-tab-dropdown button.danger:hover { background: rgba(192,64,64,0.15); }
+
+.pkg-new-btn {
+ width: 26px;
+ height: 26px;
+ border-radius: var(--r);
+ border: 1px dashed var(--border-3);
+ background: none;
+ color: var(--text-4);
+ font-size: 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: all 0.1s;
+}
+
+.pkg-new-btn:hover {
+ border-color: var(--text-3);
+ color: var(--text-2);
+ background: var(--bg-3);
+}
+
+/* ── App Shell ───────────────────────────────────────────────────────────── */
+
+.app-shell {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Input Panel ─────────────────────────────────────────────────────────── */
+
+.input-panel {
+ width: var(--panel-w);
+ min-width: var(--panel-w);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ background: var(--bg-1);
+}
+
+.input-panel::-webkit-scrollbar { width: 4px; }
+.input-panel::-webkit-scrollbar-track { background: transparent; }
+.input-panel::-webkit-scrollbar-thumb { background: var(--border-3); border-radius: 2px; }
+
+.input-section {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-2);
+}
+
+.input-section:last-of-type { border-bottom: none; }
+
+.input-section-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: 12px;
+}
+
+/* ── Form Fields ─────────────────────────────────────────────────────────── */
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ margin-bottom: 10px;
+}
+
+.field:last-child { margin-bottom: 0; }
+
+.field-label {
+ font-size: 11px;
+ color: var(--text-2);
+ font-weight: 500;
+}
+
+.field-input,
+.field-textarea {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 13px;
+ padding: 7px 10px;
+ width: 100%;
+ transition: border-color 0.1s;
+ outline: none;
+}
+
+.field-input::placeholder,
+.field-textarea::placeholder {
+ color: var(--text-4);
+}
+
+.field-input:focus,
+.field-textarea:focus {
+ border-color: var(--border-3);
+}
+
+.field-textarea {
+ resize: vertical;
+ min-height: 72px;
+ line-height: 1.5;
+}
+
+/* ── Token Input ─────────────────────────────────────────────────────────── */
+
+.token-field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.token-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ padding: 6px;
+ min-height: 36px;
+ cursor: text;
+ transition: border-color 0.1s;
+}
+
+.token-container:focus-within {
+ border-color: var(--border-3);
+}
+
+.token {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--bg-3);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 2px 7px 2px 8px;
+ font-size: 12px;
+ color: var(--text-2);
+ line-height: 1;
+ height: 22px;
+}
+
+.token-remove {
+ background: none;
+ border: none;
+ color: var(--text-3);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ margin-left: 1px;
+ opacity: 0.7;
+}
+
+.token-remove:hover { color: var(--text); opacity: 1; }
+
+.token-input {
+ background: none;
+ border: none;
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ outline: none;
+ min-width: 80px;
+ flex: 1;
+ padding: 2px 2px;
+}
+
+.token-input::placeholder { color: var(--text-4); }
+
+.token-suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.token-suggestion {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ color: var(--text-3);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 11px;
+ padding: 2px 7px;
+ transition: all 0.1s;
+}
+
+.token-suggestion:hover {
+ border-color: var(--border-3);
+ color: var(--text-2);
+}
+
+.token-suggestion.active {
+ background: var(--bg-3);
+ border-color: var(--border-3);
+ color: var(--text);
+}
+
+/* ── Generate Button ─────────────────────────────────────────────────────── */
+
+.generate-area {
+ padding: 12px 16px 16px;
+}
+
+.btn-generate {
+ width: 100%;
+ background: var(--text);
+ border: none;
+ border-radius: var(--r);
+ color: var(--bg);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 13px;
+ font-weight: 600;
+ height: 36px;
+ letter-spacing: 0.01em;
+ transition: opacity 0.1s, background 0.1s;
+}
+
+.btn-generate:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.btn-generate:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.btn-generate.generating {
+ background: var(--bg-3);
+ color: var(--text-2);
+}
+
+/* ── Shared Buttons ──────────────────────────────────────────────────────── */
+
+.btn {
+ align-items: center;
+ background: var(--bg-3);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ color: var(--text-2);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: var(--font-sans);
+ font-size: 12px;
+ gap: 5px;
+ height: 28px;
+ padding: 0 10px;
+ transition: all 0.1s;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ border-color: var(--border-3);
+ color: var(--text);
+}
+
+.btn-ghost {
+ background: none;
+ border-color: transparent;
+ color: var(--text-3);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-3);
+ border-color: var(--border);
+ color: var(--text-2);
+}
+
+.btn-icon {
+ padding: 0;
+ width: 28px;
+ justify-content: center;
+}
+
+/* ── Preview Panel ───────────────────────────────────────────────────────── */
+
+.preview-panel {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.preview-toolbar {
+ height: 40px;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--border-2);
+ background: var(--bg);
+ flex-shrink: 0;
+ gap: 12px;
+}
+
+.preview-toolbar-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.preview-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.preview-doc-label {
+ font-size: 11px;
+ color: var(--text-3);
+ font-weight: 500;
+}
+
+.preview-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 40px 48px 80px;
+}
+
+.preview-scroll::-webkit-scrollbar { width: 6px; }
+.preview-scroll::-webkit-scrollbar-track { background: transparent; }
+.preview-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+/* ── Empty State ─────────────────────────────────────────────────────────── */
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ gap: 8px;
+ color: var(--text-4);
+}
+
+.empty-state-title {
+ font-size: 14px;
+ color: var(--text-3);
+ font-weight: 500;
+}
+
+.empty-state-sub {
+ font-size: 12px;
+ color: var(--text-4);
+}
+
+/* ── Brand Document ──────────────────────────────────────────────────────── */
+
+.brand-doc {
+ max-width: 720px;
+}
+
+.brand-doc-header {
+ margin-bottom: 40px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border-2);
+}
+
+.brand-doc-title {
+ font-size: 28px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--text);
+ line-height: 1.2;
+ margin-bottom: 6px;
+}
+
+.brand-doc-meta {
+ font-size: 11px;
+ color: var(--text-3);
+ display: flex;
+ gap: 12px;
+}
+
+.brand-doc-meta-item {
+ display: flex;
+ gap: 5px;
+}
+
+.brand-doc-meta-label {
+ color: var(--text-4);
+}
+
+/* ── Doc Section ─────────────────────────────────────────────────────────── */
+
+.doc-section {
+ margin-bottom: 36px;
+ border-radius: var(--r);
+ padding: 2px;
+ margin: -2px -2px 36px;
+}
+
+.doc-section.is-locked {
+ background: rgba(201, 169, 110, 0.025);
+}
+
+.doc-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ gap: 8px;
+}
+
+.doc-section-title {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.section-lock {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ color: var(--text-4);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ padding: 2px 6px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ transition: all 0.1s;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.section-lock:hover {
+ border-color: var(--border);
+ color: var(--text-3);
+ background: var(--bg-3);
+}
+
+.section-lock.locked {
+ color: var(--accent);
+ border-color: var(--accent-dim);
+ background: rgba(201, 169, 110, 0.06);
+}
+
+.doc-section-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.doc-section:hover .doc-section-actions { opacity: 1; }
+.doc-section .doc-section-actions:has(.section-lock.locked) { opacity: 1; }
+
+.doc-section-body {
+ border-left: 1px solid var(--border);
+ padding-left: 16px;
+}
+
+.doc-section.is-locked .doc-section-body {
+ border-left-color: var(--accent-dim);
+ border-left-width: 2px;
+}
+
+/* ── Editable Text ───────────────────────────────────────────────────────── */
+
+.editable-text {
+ cursor: text;
+ border-radius: 3px;
+ transition: background 0.1s, outline 0.1s;
+ line-height: 1.65;
+ font-size: 14px;
+ color: var(--text);
+ min-height: 1em;
+ outline: 1px solid transparent;
+}
+
+.editable-text:hover {
+ background: var(--bg-2);
+ outline-color: var(--border-2);
+}
+
+.editable-text.editing {
+ background: var(--bg-2);
+ outline: 1px solid var(--border-3);
+ border-radius: 3px;
+}
+
+/* ── Numbered List ───────────────────────────────────────────────────────── */
+
+.numbered-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.numbered-item {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.numbered-item-num {
+ font-size: 11px;
+ color: var(--text-4);
+ font-family: var(--font-mono);
+ min-width: 16px;
+ margin-top: 2px;
+ flex-shrink: 0;
+}
+
+.numbered-item-text {
+ flex: 1;
+ font-size: 14px;
+ color: var(--text);
+ line-height: 1.5;
+ cursor: text;
+ border-radius: 3px;
+ padding: 1px 4px;
+ margin: -1px -4px;
+ transition: background 0.1s;
+}
+
+.numbered-item-text:hover { background: var(--bg-2); }
+.numbered-item-text:focus { background: var(--bg-2); outline: 1px solid var(--border-3); }
+
+/* ── Bullet List ─────────────────────────────────────────────────────────── */
+
+.bullet-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.bullet-item {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.5;
+}
+
+.bullet-item::before {
+ content: '–';
+ color: var(--text-4);
+ flex-shrink: 0;
+ margin-top: 0;
+}
+
+/* ── Tone Section ────────────────────────────────────────────────────────── */
+
+.tone-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.tone-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.tone-row-label {
+ font-size: 11px;
+ color: var(--text-3);
+ font-weight: 500;
+}
+
+.tone-row-value {
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.55;
+}
+
+.tone-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.tone-tag {
+ background: var(--bg-3);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ color: var(--text-2);
+ font-size: 11px;
+ padding: 2px 8px;
+}
+
+.tone-phrases {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.tone-phrase {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-2);
+ padding: 5px 8px;
+ background: var(--bg-2);
+ border-radius: 3px;
+ border-left: 2px solid var(--border-3);
+}
+
+/* ── Visual Directions ───────────────────────────────────────────────────── */
+
+.directions-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.direction-card {
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 16px;
+ background: var(--bg-1);
+}
+
+.direction-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 8px;
+}
+
+.direction-desc {
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.6;
+ margin-bottom: 12px;
+}
+
+.direction-attrs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.direction-attr {
+ display: flex;
+ gap: 8px;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.direction-attr-label {
+ color: var(--text-4);
+ font-weight: 500;
+ min-width: 88px;
+ flex-shrink: 0;
+}
+
+.direction-attr-value {
+ color: var(--text-2);
+}
+
+/* ── Logo Concepts ───────────────────────────────────────────────────────── */
+
+.logo-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.logo-card {
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 16px;
+ background: var(--bg-1);
+}
+
+.logo-card-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 8px;
+}
+
+.logo-card-concept {
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.6;
+ margin-bottom: 12px;
+}
+
+.logo-card-attrs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.logo-card-attr {
+ display: flex;
+ gap: 8px;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.logo-card-attr-label {
+ color: var(--text-4);
+ font-weight: 500;
+ min-width: 72px;
+ flex-shrink: 0;
+}
+
+.logo-card-attr-value {
+ color: var(--text-2);
+}
+
+/* ── Usage Examples ──────────────────────────────────────────────────────── */
+
+.usage-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.usage-item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.usage-context {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-4);
+}
+
+.usage-text {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-2);
+ background: var(--bg-2);
+ border: 1px solid var(--border-2);
+ border-radius: var(--r);
+ padding: 10px 12px;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ position: relative;
+}
+
+.usage-copy {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ opacity: 0;
+ transition: opacity 0.1s;
+}
+
+.usage-item:hover .usage-copy { opacity: 1; }
+
+/* ── Typography Section ──────────────────────────────────────────────────── */
+
+.type-section {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.type-fonts {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.type-font-row {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ font-size: 13px;
+}
+
+.type-font-role {
+ font-size: 11px;
+ color: var(--text-4);
+ font-weight: 500;
+ min-width: 80px;
+ flex-shrink: 0;
+}
+
+.type-font-name {
+ color: var(--text);
+ font-weight: 500;
+}
+
+.type-pair-note {
+ font-size: 12px;
+ color: var(--text-3);
+ margin-top: 4px;
+}
+
+.type-scale {
+ display: flex;
+ flex-direction: column;
+}
+
+.type-scale-header {
+ display: grid;
+ grid-template-columns: 90px 52px 56px 1fr;
+ gap: 0 12px;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-4);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 0 0 6px;
+ border-bottom: 1px solid var(--border-2);
+}
+
+.type-scale-row {
+ display: grid;
+ grid-template-columns: 90px 52px 56px 1fr;
+ gap: 0 12px;
+ font-size: 12px;
+ padding: 7px 0;
+ border-bottom: 1px solid var(--border-2);
+ align-items: center;
+}
+
+.type-scale-row:last-child { border-bottom: none; }
+
+.type-scale-label { color: var(--text-2); font-weight: 500; }
+.type-scale-size { color: var(--text-3); font-family: var(--font-mono); }
+.type-scale-weight{ color: var(--text-3); font-family: var(--font-mono); }
+.type-scale-usage { color: var(--text-4); }
+
+/* ── Color Palette ───────────────────────────────────────────────────────── */
+
+.color-palette {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.color-swatch-card {
+ display: flex;
+ flex-direction: column;
+ width: 88px;
+ gap: 6px;
+}
+
+.color-swatch-preview {
+ position: relative;
+ width: 88px;
+ height: 60px;
+ border-radius: var(--r);
+ border: 1px solid rgba(0,0,0,0.15);
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.color-swatch-picker {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ border: none;
+ padding: 0;
+}
+
+.color-swatch-remove {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: rgba(0,0,0,0.5);
+ border: none;
+ color: #fff;
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.1s;
+ padding: 0;
+ z-index: 1;
+}
+
+.color-swatch-card:hover .color-swatch-remove { opacity: 1; }
+
+.color-swatch-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.color-swatch-name {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-2);
+ cursor: text;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.color-swatch-name:hover { color: var(--text); }
+
+.color-swatch-hex {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-3);
+ cursor: text;
+}
+
+.color-swatch-hex:hover { color: var(--text-2); }
+
+.color-swatch-field-input {
+ width: 100%;
+ background: var(--bg-2);
+ border: 1px solid var(--border-3);
+ border-radius: 3px;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 11px;
+ padding: 1px 4px;
+ outline: none;
+}
+
+.color-swatch-hex-input {
+ font-family: var(--font-mono);
+ font-size: 10px;
+}
+
+.color-swatch-add {
+ width: 88px;
+ height: 60px;
+ border-radius: var(--r);
+ border: 1px dashed var(--border-3);
+ background: none;
+ color: var(--text-4);
+ font-size: 20px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.1s;
+ align-self: flex-start;
+}
+
+.color-swatch-add:hover {
+ border-color: var(--text-3);
+ color: var(--text-2);
+ background: var(--bg-2);
+}
+
+/* ── Constraints ─────────────────────────────────────────────────────────── */
+
+.constraints-list {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.constraint-item {
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.5;
+ display: flex;
+ gap: 8px;
+}
+
+.constraint-item::before {
+ content: '·';
+ color: var(--text-4);
+ flex-shrink: 0;
+}
+
+/* ── Copy feedback ───────────────────────────────────────────────────────── */
+
+.copy-btn {
+ background: var(--bg-3);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ color: var(--text-3);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 10px;
+ padding: 2px 7px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: all 0.1s;
+ white-space: nowrap;
+}
+
+.copy-btn:hover {
+ border-color: var(--border-3);
+ color: var(--text-2);
+}
+
+.copy-btn.copied {
+ color: var(--success);
+ border-color: var(--success);
+}
+
+/* ── Export Bar ──────────────────────────────────────────────────────────── */
+
+.export-bar {
+ height: 44px;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-1);
+ flex-shrink: 0;
+}
+
+.export-bar-left {
+ font-size: 11px;
+ color: var(--text-4);
+}
+
+.export-bar-right {
+ display: flex;
+ gap: 6px;
+}
+
+/* ── AI badge ────────────────────────────────────────────────────────────── */
+
+.settings-open-btn { font-size: 16px; }
+
+.ai-badge {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ border: 1px solid var(--accent-dim);
+ border-radius: 3px;
+ padding: 1px 5px;
+ cursor: default;
+ flex-shrink: 0;
+}
+
+/* ── Generate error banner ───────────────────────────────────────────────── */
+
+.generate-error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 20px;
+ background: rgba(138, 48, 48, 0.15);
+ border-bottom: 1px solid rgba(138, 48, 48, 0.3);
+ font-size: 12px;
+ flex-shrink: 0;
+}
+
+.generate-error-icon { color: #c04040; flex-shrink: 0; }
+.generate-error-msg { color: var(--text-2); flex: 1; line-height: 1.4; }
+.generate-error-retry { margin-left: auto; font-size: 11px; flex-shrink: 0; }
+
+/* ── Settings overlay + panel ────────────────────────────────────────────── */
+
+.settings-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 200;
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-end;
+ padding: var(--header-h) 0 0;
+}
+
+.settings-panel {
+ width: 400px;
+ height: calc(100vh - var(--header-h));
+ background: var(--bg-2);
+ border-left: 1px solid var(--border-3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.settings-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px;
+ height: 44px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.settings-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.settings-close {
+ background: none;
+ border: none;
+ color: var(--text-3);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--r);
+}
+
+.settings-close:hover { background: var(--bg-3); color: var(--text); }
+
+.settings-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+}
+
+.settings-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.settings-section-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-4);
+}
+
+.settings-toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ cursor: pointer;
+}
+
+.settings-toggle-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.settings-toggle-name {
+ font-size: 13px;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.settings-toggle-desc {
+ font-size: 11px;
+ color: var(--text-3);
+ line-height: 1.4;
+}
+
+/* Toggle switch */
+.toggle {
+ width: 36px;
+ height: 20px;
+ background: var(--bg-4);
+ border: 1px solid var(--border-3);
+ border-radius: 10px;
+ cursor: pointer;
+ position: relative;
+ flex-shrink: 0;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.toggle.on {
+ background: var(--accent-dim);
+ border-color: var(--accent);
+}
+
+.toggle-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--text-3);
+ transition: transform 0.15s, background 0.15s;
+}
+
+.toggle.on .toggle-thumb {
+ transform: translateX(16px);
+ background: var(--accent);
+}
+
+/* Settings fields */
+.settings-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.settings-field-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+}
+
+.settings-field-row {
+ display: flex;
+ gap: 6px;
+}
+
+.settings-input {
+ flex: 1;
+ background: var(--bg-1);
+ border: 1px solid var(--border-3);
+ border-radius: var(--r);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 6px 10px;
+ outline: none;
+ width: 100%;
+ transition: border-color 0.1s;
+}
+
+.settings-input:focus { border-color: var(--accent-dim); }
+
+.settings-test-btn { flex-shrink: 0; }
+.settings-test-btn.testing { opacity: 0.6; }
+
+.settings-status {
+ font-size: 11px;
+ padding: 4px 8px;
+ border-radius: var(--r);
+ line-height: 1.4;
+}
+
+.settings-status.ok { background: rgba(74, 148, 112, 0.15); color: #4a9470; }
+.settings-status.error { background: rgba(138, 48, 48, 0.15); color: #c04040; }
+
+.settings-model-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 180px;
+ overflow-y: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ padding: 4px;
+ background: var(--bg-1);
+}
+
+.settings-model-item {
+ text-align: left;
+ background: none;
+ border: none;
+ color: var(--text-2);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 5px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.settings-model-item:hover { background: var(--bg-3); color: var(--text); }
+.settings-model-item.active { background: var(--bg-3); color: var(--accent); }
+
+.settings-field-hint {
+ font-size: 11px;
+ color: var(--text-4);
+ line-height: 1.5;
+}
+
+.settings-field-hint code {
+ font-family: var(--font-mono);
+ color: var(--text-3);
+ background: var(--bg-1);
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+
+.settings-help p {
+ font-size: 12px;
+ color: var(--text-3);
+ line-height: 1.6;
+ margin-bottom: 8px;
+}
+
+.settings-help p:last-child { margin-bottom: 0; }
+
+.settings-help strong { color: var(--text-2); font-weight: 500; }
+
+.settings-help code {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--text-3);
+ background: var(--bg-1);
+ padding: 2px 5px;
+ border-radius: 3px;
+ display: inline-block;
+ margin-top: 4px;
+}
+
+/* ── Scrollbar global ────────────────────────────────────────────────────── */
+
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border-3); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
+
+/* ── Utilities ───────────────────────────────────────────────────────────── */
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..e1b436e
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,100 @@
+export interface BrandInputs {
+ name: string;
+ category: string;
+ purpose: string;
+ audience: string;
+ tone: string[];
+ avoid: string[];
+ notes: string;
+}
+
+export interface ToneGuidance {
+ attributes: string[];
+ voiceNotes: string;
+ avoidList: string[];
+ examplePhrases: string[];
+}
+
+export interface VisualDirection {
+ id: string;
+ name: string;
+ description: string;
+ palette: string;
+ typography: string;
+ references: string;
+}
+
+export interface LogoConcept {
+ id: string;
+ title: string;
+ concept: string;
+ mark: string;
+ execution: string;
+}
+
+export interface UsageExample {
+ context: string;
+ text: string;
+}
+
+export interface TypographyToken {
+ label: string;
+ size: string;
+ weight: string;
+ lineHeight: string;
+ usage: string;
+}
+
+export interface Typography {
+ primary: string;
+ secondary: string;
+ mono: string;
+ pairNote: string;
+ scale: TypographyToken[];
+}
+
+export interface ColorSwatch {
+ id: string;
+ name: string;
+ hex: string;
+ role: string;
+}
+
+export interface ColorPalette {
+ swatches: ColorSwatch[];
+}
+
+export interface BrandOutputs {
+ overview: string;
+ positioning: string;
+ tone: ToneGuidance;
+ titles: string[];
+ subtitles: string[];
+ taglines: string[];
+ visualDirections: VisualDirection[];
+ palette: ColorPalette;
+ typography: Typography;
+ logoConcepts: LogoConcept[];
+ usageExamples: UsageExample[];
+ constraints: string[];
+}
+
+export interface LockedSections {
+ overview: boolean;
+ positioning: boolean;
+ tone: boolean;
+ messaging: boolean;
+ visual: boolean;
+ palette: boolean;
+ typography: boolean;
+ logo: boolean;
+ usage: boolean;
+ constraints: boolean;
+}
+
+export interface WorkspaceState {
+ inputs: BrandInputs;
+ outputs: BrandOutputs | null;
+ edits: Partial<BrandOutputs>;
+ locked: LockedSections;
+}