summaryrefslogtreecommitdiff
path: root/src/components
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/components
downloadbrand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.gz
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.tar.bz2
brand-bench-580e798e16346eb894eb899edac82d2efdf40adc.zip
initial commit
Diffstat (limited to 'src/components')
-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
7 files changed, 1166 insertions, 0 deletions
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>
+ );
+}