diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 130 | ||||
| -rw-r--r-- | src/components/BrandDoc.tsx | 518 | ||||
| -rw-r--r-- | src/components/CopyButton.tsx | 31 | ||||
| -rw-r--r-- | src/components/InputPanel.tsx | 133 | ||||
| -rw-r--r-- | src/components/PackageSwitcher.tsx | 125 | ||||
| -rw-r--r-- | src/components/PreviewPanel.tsx | 116 | ||||
| -rw-r--r-- | src/components/SettingsPanel.tsx | 158 | ||||
| -rw-r--r-- | src/components/TokenInput.tsx | 85 | ||||
| -rw-r--r-- | src/engine/aiGenerator.ts | 138 | ||||
| -rw-r--r-- | src/engine/generator.ts | 957 | ||||
| -rw-r--r-- | src/hooks/usePackages.ts | 112 | ||||
| -rw-r--r-- | src/hooks/useSettings.ts | 41 | ||||
| -rw-r--r-- | src/hooks/useWorkspace.ts | 225 | ||||
| -rw-r--r-- | src/lib/clipboard.ts | 17 | ||||
| -rw-r--r-- | src/lib/export.ts | 326 | ||||
| -rw-r--r-- | src/lib/sanitize.ts | 141 | ||||
| -rw-r--r-- | src/main.tsx | 10 | ||||
| -rw-r--r-- | src/styles/globals.css | 1594 | ||||
| -rw-r--r-- | src/types.ts | 100 |
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, '&').replace(/</g, '<').replace(/>/g, '>'); + + 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 & 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; +} |
