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}