krz/brand-bench
A dynamic brand documentation generator with Ollama integration.
clone: git clone https://gitbay.org/krz/brand-bench.git
main: src/components/BrandDoc.tsx · raw
1import { useRef, useState } from 'react';
2import type { BrandInputs, BrandOutputs, ColorSwatch, LockedSections, Typography } from '../types';
3import { CopyButton } from './CopyButton';
4
5// ── Editable text ─────────────────────────────────────────────────────────────
6
7interface EditableProps {
8 value: string;
9 onChange: (v: string) => void;
10 multiline?: boolean;
11}
12
13function Editable({ value, onChange, multiline = false }: EditableProps) {
14 const [editing, setEditing] = useState(false);
15 const [draft, setDraft] = useState(value);
16 const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
17
18 const commit = () => {
19 setEditing(false);
20 if (draft !== value) onChange(draft);
21 };
22
23 if (!editing) {
24 return (
25 <div
26 className="editable-text"
27 onClick={() => { setDraft(value); setEditing(true); }}
28 title="Click to edit"
29 style={{ whiteSpace: multiline ? 'pre-wrap' : 'normal', padding: '2px 4px', margin: '-2px -4px' }}
30 >
31 {value}
32 </div>
33 );
34 }
35
36 if (multiline) {
37 return (
38 <textarea
39 ref={ref as React.RefObject<HTMLTextAreaElement>}
40 autoFocus
41 className="editable-text editing"
42 value={draft}
43 onChange={e => setDraft(e.target.value)}
44 onBlur={commit}
45 onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setDraft(value); } }}
46 style={{
47 width: '100%',
48 resize: 'vertical',
49 minHeight: 80,
50 padding: '4px 6px',
51 fontFamily: 'inherit',
52 fontSize: 'inherit',
53 lineHeight: 1.65,
54 background: 'var(--bg-2)',
55 border: '1px solid var(--border-3)',
56 borderRadius: 3,
57 color: 'var(--text)',
58 outline: 'none',
59 }}
60 />
61 );
62 }
63
64 return (
65 <input
66 ref={ref as React.RefObject<HTMLInputElement>}
67 autoFocus
68 className="editable-text editing"
69 value={draft}
70 onChange={e => setDraft(e.target.value)}
71 onBlur={commit}
72 onKeyDown={e => {
73 if (e.key === 'Enter') commit();
74 if (e.key === 'Escape') { setEditing(false); setDraft(value); }
75 }}
76 style={{
77 width: '100%',
78 padding: '2px 6px',
79 fontFamily: 'inherit',
80 fontSize: 'inherit',
81 background: 'var(--bg-2)',
82 border: '1px solid var(--border-3)',
83 borderRadius: 3,
84 color: 'var(--text)',
85 outline: 'none',
86 }}
87 />
88 );
89}
90
91// ── Section wrapper ───────────────────────────────────────────────────────────
92
93interface SectionProps {
94 title: string;
95 locked: boolean;
96 onToggleLock: () => void;
97 copyText?: string;
98 children: React.ReactNode;
99}
100
101function Section({ title, locked, onToggleLock, copyText, children }: SectionProps) {
102 return (
103 <div className={`doc-section${locked ? ' is-locked' : ''}`}>
104 <div className="doc-section-header">
105 <div className="doc-section-title">{title}</div>
106 <div className="doc-section-actions">
107 {copyText && <CopyButton text={copyText} label="Copy" />}
108 <button
109 type="button"
110 className={`section-lock${locked ? ' locked' : ''}`}
111 onClick={onToggleLock}
112 title={locked ? 'Locked — click to unlock' : 'Lock to preserve on regenerate'}
113 >
114 {locked ? '● locked' : '○ lock'}
115 </button>
116 </div>
117 </div>
118 <div className="doc-section-body">
119 {children}
120 </div>
121 </div>
122 );
123}
124
125// ── Editable list item ────────────────────────────────────────────────────────
126
127interface EditableListProps {
128 items: string[];
129 onChange: (items: string[]) => void;
130 numbered?: boolean;
131}
132
133function EditableList({ items, onChange, numbered = true }: EditableListProps) {
134 const updateAt = (i: number, v: string) => {
135 const next = [...items];
136 next[i] = v;
137 onChange(next);
138 };
139
140 if (numbered) {
141 return (
142 <div className="numbered-list">
143 {items.map((item, i) => (
144 <div key={i} className="numbered-item">
145 <span className="numbered-item-num">{i + 1}.</span>
146 <div
147 className="numbered-item-text"
148 contentEditable
149 suppressContentEditableWarning
150 onBlur={e => updateAt(i, e.currentTarget.textContent ?? '')}
151 onKeyDown={e => { if (e.key === 'Escape') e.currentTarget.blur(); }}
152 >
153 {item}
154 </div>
155 </div>
156 ))}
157 </div>
158 );
159 }
160
161 return (
162 <div className="bullet-list">
163 {items.map((item, i) => (
164 <div key={i} className="bullet-item">
165 {item}
166 </div>
167 ))}
168 </div>
169 );
170}
171
172// ── Typography section ────────────────────────────────────────────────────────
173
174function TypographySection({ typography }: { typography: Typography }) {
175 return (
176 <div className="type-section">
177 <div className="type-fonts">
178 <div className="type-font-row">
179 <span className="type-font-role">Primary</span>
180 <span className="type-font-name">{typography.primary}</span>
181 </div>
182 {typography.secondary !== typography.primary && (
183 <div className="type-font-row">
184 <span className="type-font-role">Secondary</span>
185 <span className="type-font-name">{typography.secondary}</span>
186 </div>
187 )}
188 <div className="type-font-row">
189 <span className="type-font-role">Monospace</span>
190 <span className="type-font-name" style={{ fontFamily: 'var(--font-mono)' }}>{typography.mono}</span>
191 </div>
192 <div className="type-pair-note">{typography.pairNote}</div>
193 </div>
194
195 <div className="type-scale">
196 <div className="type-scale-header">
197 <span>Style</span>
198 <span>Size</span>
199 <span>Weight</span>
200 <span className="type-scale-usage">Usage</span>
201 </div>
202 {typography.scale.map(token => (
203 <div key={token.label} className="type-scale-row">
204 <span className="type-scale-label">{token.label}</span>
205 <span className="type-scale-size">{token.size}</span>
206 <span className="type-scale-weight">{token.weight}</span>
207 <span className="type-scale-usage">{token.usage}</span>
208 </div>
209 ))}
210 </div>
211 </div>
212 );
213}
214
215// ── Color swatch card ─────────────────────────────────────────────────────────
216
217interface ColorSwatchCardProps {
218 swatch: ColorSwatch;
219 onChange: (s: ColorSwatch) => void;
220 onRemove: () => void;
221}
222
223function ColorSwatchCard({ swatch, onChange, onRemove }: ColorSwatchCardProps) {
224 const [nameEditing, setNameEditing] = useState(false);
225 const [nameDraft, setNameDraft] = useState(swatch.name);
226 const [hexEditing, setHexEditing] = useState(false);
227 const [hexDraft, setHexDraft] = useState(swatch.hex);
228
229 const commitName = () => {
230 setNameEditing(false);
231 const v = nameDraft.trim();
232 if (v && v !== swatch.name) onChange({ ...swatch, name: v });
233 };
234
235 const commitHex = () => {
236 setHexEditing(false);
237 const v = hexDraft.trim().toLowerCase();
238 const normalized = v.startsWith('#') ? v : `#${v}`;
239 if (/^#[0-9a-f]{6}$/.test(normalized)) {
240 onChange({ ...swatch, hex: normalized });
241 } else {
242 setHexDraft(swatch.hex);
243 }
244 };
245
246 return (
247 <div className="color-swatch-card">
248 <div className="color-swatch-preview" style={{ background: swatch.hex }}>
249 <input
250 type="color"
251 className="color-swatch-picker"
252 value={swatch.hex}
253 onChange={e => onChange({ ...swatch, hex: e.target.value })}
254 title="Pick color"
255 />
256 <button className="color-swatch-remove" onClick={onRemove} title="Remove">×</button>
257 </div>
258 <div className="color-swatch-info">
259 {nameEditing ? (
260 <input
261 autoFocus
262 className="color-swatch-field-input"
263 value={nameDraft}
264 onChange={e => setNameDraft(e.target.value)}
265 onBlur={commitName}
266 onKeyDown={e => {
267 if (e.key === 'Enter') commitName();
268 if (e.key === 'Escape') { setNameEditing(false); setNameDraft(swatch.name); }
269 }}
270 />
271 ) : (
272 <div className="color-swatch-name" onClick={() => { setNameDraft(swatch.name); setNameEditing(true); }} title="Click to edit">
273 {swatch.name}
274 </div>
275 )}
276 {hexEditing ? (
277 <input
278 autoFocus
279 className="color-swatch-field-input color-swatch-hex-input"
280 value={hexDraft}
281 onChange={e => setHexDraft(e.target.value)}
282 onBlur={commitHex}
283 onKeyDown={e => {
284 if (e.key === 'Enter') commitHex();
285 if (e.key === 'Escape') { setHexEditing(false); setHexDraft(swatch.hex); }
286 }}
287 />
288 ) : (
289 <div className="color-swatch-hex" onClick={() => { setHexDraft(swatch.hex); setHexEditing(true); }} title="Click to edit">
290 {swatch.hex}
291 </div>
292 )}
293 </div>
294 </div>
295 );
296}
297
298// ── Main BrandDoc ─────────────────────────────────────────────────────────────
299
300interface Props {
301 inputs: BrandInputs;
302 outputs: BrandOutputs;
303 locked: LockedSections;
304 onToggleLock: (section: keyof LockedSections) => void;
305 onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void;
306}
307
308export function BrandDoc({ inputs, outputs, locked, onToggleLock, onEdit }: Props) {
309 const paletteCopy = outputs.palette.swatches
310 .map(s => `${s.name}: ${s.hex}`)
311 .join('\n');
312
313 const updateSwatch = (index: number, updated: ColorSwatch) => {
314 const swatches = [...outputs.palette.swatches];
315 swatches[index] = updated;
316 onEdit('palette', { swatches });
317 };
318
319 const removeSwatch = (index: number) => {
320 const swatches = outputs.palette.swatches.filter((_, i) => i !== index);
321 onEdit('palette', { swatches });
322 };
323
324 const addSwatch = () => {
325 const id = `s${Date.now()}`;
326 onEdit('palette', {
327 swatches: [...outputs.palette.swatches, { id, name: 'New Color', hex: '#888888', role: 'accent' }],
328 });
329 };
330
331 const messagingCopy = [
332 'Titles:',
333 ...outputs.titles.map((t, i) => `${i + 1}. ${t}`),
334 '',
335 'Taglines:',
336 ...outputs.taglines.map((t, i) => `${i + 1}. ${t}`),
337 ].join('\n');
338
339 return (
340 <div className="brand-doc">
341 <div className="brand-doc-header">
342 <div className="brand-doc-title">{inputs.name || 'Brand Package'}</div>
343 <div className="brand-doc-meta">
344 {inputs.category && (
345 <span className="brand-doc-meta-item">
346 <span className="brand-doc-meta-label">category</span>
347 {inputs.category}
348 </span>
349 )}
350 {inputs.audience && (
351 <span className="brand-doc-meta-item">
352 <span className="brand-doc-meta-label">audience</span>
353 {inputs.audience}
354 </span>
355 )}
356 </div>
357 </div>
358
359 {/* Overview */}
360 <Section title="Overview" locked={locked.overview} onToggleLock={() => onToggleLock('overview')} copyText={outputs.overview}>
361 <Editable value={outputs.overview} onChange={v => onEdit('overview', v)} multiline />
362 </Section>
363
364 {/* Positioning */}
365 <Section title="Positioning" locked={locked.positioning} onToggleLock={() => onToggleLock('positioning')} copyText={outputs.positioning}>
366 <Editable value={outputs.positioning} onChange={v => onEdit('positioning', v)} multiline />
367 </Section>
368
369 {/* Tone */}
370 <Section title="Tone & Voice" locked={locked.tone} onToggleLock={() => onToggleLock('tone')}>
371 <div className="tone-grid">
372 <div className="tone-row">
373 <div className="tone-row-label">Attributes</div>
374 <div className="tone-tags">
375 {outputs.tone.attributes.map(a => (
376 <span key={a} className="tone-tag">{a}</span>
377 ))}
378 </div>
379 </div>
380 <div className="tone-row">
381 <div className="tone-row-label">Voice</div>
382 <div className="tone-row-value">{outputs.tone.voiceNotes}</div>
383 </div>
384 {outputs.tone.avoidList.length > 0 && (
385 <div className="tone-row">
386 <div className="tone-row-label">Avoid</div>
387 <div className="tone-row-value">{outputs.tone.avoidList.join(', ')}</div>
388 </div>
389 )}
390 {outputs.tone.examplePhrases.length > 0 && (
391 <div className="tone-row">
392 <div className="tone-row-label">Example phrases</div>
393 <div className="tone-phrases">
394 {outputs.tone.examplePhrases.map((p, i) => (
395 <div key={i} className="tone-phrase">{p}</div>
396 ))}
397 </div>
398 </div>
399 )}
400 </div>
401 </Section>
402
403 {/* Messaging */}
404 <Section title="Messaging" locked={locked.messaging} onToggleLock={() => onToggleLock('messaging')} copyText={messagingCopy}>
405 <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
406 <div>
407 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Titles</div>
408 <EditableList items={outputs.titles} onChange={v => onEdit('titles', v)} />
409 </div>
410 <div>
411 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Subtitles</div>
412 <EditableList items={outputs.subtitles} onChange={v => onEdit('subtitles', v)} />
413 </div>
414 <div>
415 <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Taglines</div>
416 <EditableList items={outputs.taglines} onChange={v => onEdit('taglines', v)} />
417 </div>
418 </div>
419 </Section>
420
421 {/* Visual Directions */}
422 <Section title="Visual Direction" locked={locked.visual} onToggleLock={() => onToggleLock('visual')}>
423 <div className="directions-grid">
424 {outputs.visualDirections.map(dir => (
425 <div key={dir.id} className="direction-card">
426 <div className="direction-name">{dir.name}</div>
427 <div className="direction-desc">{dir.description}</div>
428 <div className="direction-attrs">
429 <div className="direction-attr">
430 <span className="direction-attr-label">Palette</span>
431 <span className="direction-attr-value">{dir.palette}</span>
432 </div>
433 <div className="direction-attr">
434 <span className="direction-attr-label">Typography</span>
435 <span className="direction-attr-value">{dir.typography}</span>
436 </div>
437 <div className="direction-attr">
438 <span className="direction-attr-label">References</span>
439 <span className="direction-attr-value">{dir.references}</span>
440 </div>
441 </div>
442 </div>
443 ))}
444 </div>
445 </Section>
446
447 {/* Color Palette */}
448 <Section title="Color Palette" locked={locked.palette} onToggleLock={() => onToggleLock('palette')} copyText={paletteCopy}>
449 <div className="color-palette">
450 {outputs.palette.swatches.map((swatch, i) => (
451 <ColorSwatchCard
452 key={swatch.id}
453 swatch={swatch}
454 onChange={updated => updateSwatch(i, updated)}
455 onRemove={() => removeSwatch(i)}
456 />
457 ))}
458 <button className="color-swatch-add" onClick={addSwatch} title="Add color">
459 +
460 </button>
461 </div>
462 </Section>
463
464 {/* Typography */}
465 <Section title="Typography" locked={locked.typography} onToggleLock={() => onToggleLock('typography')}>
466 <TypographySection typography={outputs.typography} />
467 </Section>
468
469 {/* Logo Concepts */}
470 <Section title="Logo Concepts" locked={locked.logo} onToggleLock={() => onToggleLock('logo')}>
471 <div className="logo-grid">
472 {outputs.logoConcepts.map(lc => (
473 <div key={lc.id} className="logo-card">
474 <div className="logo-card-title">{lc.title}</div>
475 <div className="logo-card-concept">{lc.concept}</div>
476 <div className="logo-card-attrs">
477 <div className="logo-card-attr">
478 <span className="logo-card-attr-label">Mark</span>
479 <span className="logo-card-attr-value">{lc.mark}</span>
480 </div>
481 <div className="logo-card-attr">
482 <span className="logo-card-attr-label">Execution</span>
483 <span className="logo-card-attr-value">{lc.execution}</span>
484 </div>
485 </div>
486 </div>
487 ))}
488 </div>
489 </Section>
490
491 {/* Usage Examples */}
492 <Section title="Usage Examples" locked={locked.usage} onToggleLock={() => onToggleLock('usage')}>
493 <div className="usage-grid">
494 {outputs.usageExamples.map((ex, i) => (
495 <div key={i} className="usage-item">
496 <div className="usage-context">{ex.context}</div>
497 <div className="usage-text" style={{ position: 'relative' }}>
498 {ex.text}
499 <div className="usage-copy">
500 <CopyButton text={ex.text} label="Copy" />
501 </div>
502 </div>
503 </div>
504 ))}
505 </div>
506 </Section>
507
508 {/* Constraints */}
509 <Section title="Constraints" locked={locked.constraints} onToggleLock={() => onToggleLock('constraints')}>
510 <div className="constraints-list">
511 {outputs.constraints.map((c, i) => (
512 <div key={i} className="constraint-item">{c}</div>
513 ))}
514 </div>
515 </Section>
516 </div>
517 );
518}