summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/clipboard.ts17
-rw-r--r--src/lib/export.ts326
-rw-r--r--src/lib/sanitize.ts141
3 files changed, 484 insertions, 0 deletions
diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts
new file mode 100644
index 0000000..d735a51
--- /dev/null
+++ b/src/lib/clipboard.ts
@@ -0,0 +1,17 @@
+export async function copyToClipboard(text: string): Promise<boolean> {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fallback for older browsers
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'fixed';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(el);
+ return ok;
+ }
+}
diff --git a/src/lib/export.ts b/src/lib/export.ts
new file mode 100644
index 0000000..9e1a7f1
--- /dev/null
+++ b/src/lib/export.ts
@@ -0,0 +1,326 @@
+import type { BrandInputs, BrandOutputs } from '../types';
+
+export function toMarkdown(inputs: BrandInputs, outputs: BrandOutputs): string {
+ const lines: string[] = [];
+
+ lines.push(`# Brand Package — ${inputs.name || 'Untitled'}`);
+ lines.push('');
+ lines.push(`*Generated ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}*`);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+
+ lines.push('## Overview');
+ lines.push('');
+ lines.push(outputs.overview);
+ lines.push('');
+
+ lines.push('## Positioning');
+ lines.push('');
+ lines.push(outputs.positioning);
+ lines.push('');
+
+ lines.push('## Tone & Voice');
+ lines.push('');
+ lines.push(`**Attributes:** ${outputs.tone.attributes.join(', ')}`);
+ lines.push('');
+ lines.push(`**Voice:** ${outputs.tone.voiceNotes}`);
+ lines.push('');
+ if (outputs.tone.avoidList.length > 0) {
+ lines.push(`**Avoid:** ${outputs.tone.avoidList.join(', ')}`);
+ lines.push('');
+ }
+ if (outputs.tone.examplePhrases.length > 0) {
+ lines.push('**Example phrases:**');
+ outputs.tone.examplePhrases.forEach(p => lines.push(`- ${p}`));
+ lines.push('');
+ }
+
+ lines.push('## Messaging');
+ lines.push('');
+ lines.push('### Titles');
+ outputs.titles.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
+ lines.push('');
+ lines.push('### Subtitles');
+ outputs.subtitles.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
+ lines.push('');
+ lines.push('### Taglines');
+ outputs.taglines.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
+ lines.push('');
+
+ lines.push('## Color Palette');
+ lines.push('');
+ outputs.palette.swatches.forEach(s => {
+ lines.push(`- **${s.name}** — \`${s.hex}\` *(${s.role})*`);
+ });
+ lines.push('');
+
+ lines.push('## Typography');
+ lines.push('');
+ lines.push(`**Primary:** ${outputs.typography.primary}`);
+ if (outputs.typography.secondary !== outputs.typography.primary)
+ lines.push(`**Secondary:** ${outputs.typography.secondary}`);
+ lines.push(`**Monospace:** ${outputs.typography.mono}`);
+ lines.push('');
+ lines.push(`*${outputs.typography.pairNote}*`);
+ lines.push('');
+ lines.push('| Style | Size | Weight | Usage |');
+ lines.push('|-------|------|--------|-------|');
+ outputs.typography.scale.forEach(t => {
+ lines.push(`| ${t.label} | ${t.size} | ${t.weight} | ${t.usage} |`);
+ });
+ lines.push('');
+
+ lines.push('## Visual Direction');
+ lines.push('');
+ outputs.visualDirections.forEach(dir => {
+ lines.push(`### ${dir.name}`);
+ lines.push('');
+ lines.push(dir.description);
+ lines.push('');
+ lines.push(`**Palette:** ${dir.palette}`);
+ lines.push('');
+ lines.push(`**Typography:** ${dir.typography}`);
+ lines.push('');
+ lines.push(`**References:** ${dir.references}`);
+ lines.push('');
+ });
+
+ lines.push('## Logo Concepts');
+ lines.push('');
+ outputs.logoConcepts.forEach(lc => {
+ lines.push(`### ${lc.title}`);
+ lines.push('');
+ lines.push(lc.concept);
+ lines.push('');
+ lines.push(`**Mark:** ${lc.mark}`);
+ lines.push('');
+ lines.push(`**Execution:** ${lc.execution}`);
+ lines.push('');
+ });
+
+ lines.push('## Usage Examples');
+ lines.push('');
+ outputs.usageExamples.forEach(ex => {
+ lines.push(`### ${ex.context}`);
+ lines.push('');
+ lines.push('```');
+ lines.push(ex.text);
+ lines.push('```');
+ lines.push('');
+ });
+
+ lines.push('## Constraints');
+ lines.push('');
+ outputs.constraints.forEach(c => lines.push(`- ${c}`));
+ lines.push('');
+
+ lines.push('---');
+ lines.push('');
+ lines.push('*Brand Workbench*');
+
+ return lines.join('\n');
+}
+
+export function toJSON(inputs: BrandInputs, outputs: BrandOutputs): string {
+ return JSON.stringify(
+ {
+ project: {
+ name: inputs.name,
+ category: inputs.category,
+ purpose: inputs.purpose,
+ audience: inputs.audience,
+ },
+ brand: outputs,
+ meta: {
+ generated: new Date().toISOString(),
+ version: '1.0',
+ },
+ },
+ null,
+ 2
+ );
+}
+
+export function toHTML(inputs: BrandInputs, outputs: BrandOutputs): string {
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+ const swatchesHtml = outputs.palette.swatches.map(s => `
+ <div class="swatch">
+ <div class="swatch-color" style="background:${esc(s.hex)}"></div>
+ <div class="swatch-name">${esc(s.name)}</div>
+ <div class="swatch-hex">${esc(s.hex)}</div>
+ </div>`).join('');
+
+ const scaleRows = outputs.typography.scale.map(t => `
+ <tr><td>${esc(t.label)}</td><td>${esc(t.size)}</td><td>${esc(t.weight)}</td><td>${esc(t.usage)}</td></tr>`).join('');
+
+ const dirsHtml = outputs.visualDirections.map(d => `
+ <div class="card">
+ <h3>${esc(d.name)}</h3>
+ <p>${esc(d.description)}</p>
+ <dl>
+ <dt>Palette</dt><dd>${esc(d.palette)}</dd>
+ <dt>Typography</dt><dd>${esc(d.typography)}</dd>
+ <dt>References</dt><dd>${esc(d.references)}</dd>
+ </dl>
+ </div>`).join('');
+
+ const logosHtml = outputs.logoConcepts.map(l => `
+ <div class="card">
+ <h3>${esc(l.title)}</h3>
+ <p>${esc(l.concept)}</p>
+ <dl>
+ <dt>Mark</dt><dd>${esc(l.mark)}</dd>
+ <dt>Execution</dt><dd>${esc(l.execution)}</dd>
+ </dl>
+ </div>`).join('');
+
+ const usageHtml = outputs.usageExamples.map(ex => `
+ <div class="usage-item">
+ <div class="usage-label">${esc(ex.context)}</div>
+ <pre>${esc(ex.text)}</pre>
+ </div>`).join('');
+
+ const constraintsHtml = outputs.constraints.map(c => `<li>${esc(c)}</li>`).join('\n');
+
+ const secondaryRow = outputs.typography.secondary !== outputs.typography.primary
+ ? `<tr><td>Secondary</td><td>${esc(outputs.typography.secondary)}</td></tr>` : '';
+
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(inputs.name || 'Brand')} — Brand Guidelines</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ html { font-size: 15px; }
+ body { font-family: ui-sans-serif, -apple-system, system-ui, sans-serif; color: #1a1a1a; background: #fff; line-height: 1.6; }
+ .guide { max-width: 860px; margin: 0 auto; padding: 60px 40px 100px; }
+ .guide-header { border-bottom: 2px solid #111; padding-bottom: 24px; margin-bottom: 48px; }
+ .guide-header h1 { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; }
+ .guide-header .meta { font-size: 13px; color: #777; margin-top: 6px; }
+ h2 { font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: #999; margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
+ section { margin-bottom: 56px; }
+ p { color: #333; margin-bottom: 12px; }
+ .font-table, .scale-table { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 16px; }
+ .font-table td, .scale-table td, .scale-table th { padding: 8px 12px; border-bottom: 1px solid #eee; text-align: left; }
+ .scale-table th { font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.06em; }
+ .font-table td:first-child { color: #999; width: 120px; }
+ .pair-note { font-size: 13px; color: #777; font-style: italic; margin-top: 8px; }
+ .swatches { display: flex; flex-wrap: wrap; gap: 12px; }
+ .swatch { width: 100px; }
+ .swatch-color { width: 100px; height: 64px; border-radius: 6px; border: 1px solid rgba(0,0,0,.08); margin-bottom: 6px; }
+ .swatch-name { font-size: 12px; font-weight: 500; color: #333; }
+ .swatch-hex { font-size: 11px; font-family: ui-monospace, monospace; color: #777; }
+ .card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
+ .card h3 { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
+ .card p { font-size: 14px; color: #555; margin-bottom: 12px; }
+ dl { display: grid; grid-template-columns: 100px 1fr; gap: 4px 16px; font-size: 13px; }
+ dt { color: #999; font-weight: 500; }
+ dd { color: #444; }
+ .usage-item { margin-bottom: 20px; }
+ .usage-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin-bottom: 6px; }
+ pre { background: #f5f5f5; border-radius: 6px; padding: 14px 16px; font-size: 13px; font-family: ui-monospace, monospace; white-space: pre-wrap; color: #333; line-height: 1.6; }
+ ul { padding-left: 20px; }
+ li { font-size: 14px; color: #444; margin-bottom: 6px; }
+ .tone-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
+ .tone-pill { background: #f0f0f0; border-radius: 4px; padding: 3px 10px; font-size: 12px; color: #555; }
+ .tone-row { font-size: 14px; color: #444; margin-bottom: 8px; }
+ .tone-row strong { color: #999; display: inline-block; min-width: 100px; }
+ .taglines ol, .titles ol { padding-left: 20px; }
+ .taglines li, .titles li { font-size: 15px; color: #333; margin-bottom: 8px; }
+</style>
+</head>
+<body>
+<div class="guide">
+ <div class="guide-header">
+ <h1>${esc(inputs.name || 'Brand')} Guidelines</h1>
+ <div class="meta">Generated ${date}${inputs.category ? ` · ${esc(inputs.category)}` : ''}${inputs.audience ? ` · ${esc(inputs.audience)}` : ''}</div>
+ </div>
+
+ <section>
+ <h2>Overview</h2>
+ <p>${esc(outputs.overview)}</p>
+ </section>
+
+ <section>
+ <h2>Positioning</h2>
+ <p>${esc(outputs.positioning)}</p>
+ </section>
+
+ <section>
+ <h2>Tone &amp; Voice</h2>
+ <div class="tone-pills">${outputs.tone.attributes.map(a => `<span class="tone-pill">${esc(a)}</span>`).join('')}</div>
+ <div class="tone-row"><strong>Voice</strong>${esc(outputs.tone.voiceNotes)}</div>
+ ${outputs.tone.avoidList.length ? `<div class="tone-row"><strong>Avoid</strong>${esc(outputs.tone.avoidList.join(', '))}</div>` : ''}
+ </section>
+
+ <section>
+ <h2>Messaging</h2>
+ <div class="titles">
+ <p><strong>Titles</strong></p>
+ <ol>${outputs.titles.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
+ </div>
+ <br>
+ <div class="taglines">
+ <p><strong>Taglines</strong></p>
+ <ol>${outputs.taglines.map(t => `<li>${esc(t)}</li>`).join('')}</ol>
+ </div>
+ </section>
+
+ <section>
+ <h2>Color Palette</h2>
+ <div class="swatches">${swatchesHtml}</div>
+ </section>
+
+ <section>
+ <h2>Typography</h2>
+ <table class="font-table">
+ <tr><td>Primary</td><td>${esc(outputs.typography.primary)}</td></tr>
+ ${secondaryRow}
+ <tr><td>Monospace</td><td>${esc(outputs.typography.mono)}</td></tr>
+ </table>
+ <p class="pair-note">${esc(outputs.typography.pairNote)}</p>
+ <br>
+ <table class="scale-table">
+ <thead><tr><th>Style</th><th>Size</th><th>Weight</th><th>Usage</th></tr></thead>
+ <tbody>${scaleRows}</tbody>
+ </table>
+ </section>
+
+ <section>
+ <h2>Visual Direction</h2>
+ ${dirsHtml}
+ </section>
+
+ <section>
+ <h2>Logo Concepts</h2>
+ ${logosHtml}
+ </section>
+
+ <section>
+ <h2>Usage Examples</h2>
+ ${usageHtml}
+ </section>
+
+ <section>
+ <h2>Constraints</h2>
+ <ul>${constraintsHtml}</ul>
+ </section>
+</div>
+</body>
+</html>`;
+}
+
+export function downloadFile(content: string, filename: string, mimeType: string): void {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..d63eb19
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,141 @@
+/**
+ * Defensive coercion helpers for AI-generated brand outputs.
+ * AI models sometimes return structured objects instead of plain strings/arrays;
+ * these helpers normalise any value to the expected type so React never receives
+ * an object where it expects a renderable child.
+ */
+import type { BrandOutputs } from '../types';
+import { TYPE_SCALE } from '../engine/generator';
+
+// ---------------------------------------------------------------------------
+// Primitive coercions
+// ---------------------------------------------------------------------------
+
+/** Coerce any value to a non-empty string. */
+export function str(v: unknown, fallback = ''): string {
+ if (typeof v === 'string') return v;
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
+ if (v && typeof v === 'object') {
+ const o = v as Record<string, unknown>;
+ for (const k of ['text', 'content', 'value', 'description', 'name', 'label']) {
+ if (typeof o[k] === 'string' && o[k]) return o[k] as string;
+ }
+ const vals = Object.values(o).filter(x => typeof x === 'string') as string[];
+ if (vals.length) return vals.join(' — ');
+ }
+ return fallback;
+}
+
+/** Coerce any value to an array of non-empty strings. */
+export function strArr(v: unknown, fallback: string[] = []): string[] {
+ if (Array.isArray(v)) return v.map(x => str(x)).filter(Boolean);
+ if (typeof v === 'string' && v) return [v];
+ if (v && typeof v === 'object') return [str(v)].filter(Boolean);
+ return fallback;
+}
+
+function normalizeHex(hex: string): string {
+ const h = hex.trim().toLowerCase();
+ const clean = h.startsWith('#') ? h : `#${h}`;
+ return /^#[0-9a-f]{6}$/.test(clean) ? clean : '#888888';
+}
+
+// ---------------------------------------------------------------------------
+// Full output sanitiser — safe to call on any untrusted value
+// ---------------------------------------------------------------------------
+
+/**
+ * Recursively coerce every field of a brand output object to its expected type.
+ * Returns `null` if the input is clearly not a valid brand output at all.
+ */
+export function sanitizeOutputs(raw: unknown): BrandOutputs | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record<string, unknown>;
+
+ // Must have at least the core keys to be worth keeping
+ if (!r.overview && !r.positioning && !r.tone) return null;
+
+ const tone = (r.tone && typeof r.tone === 'object' ? r.tone : {}) as Record<string, unknown>;
+ const typo = (r.typography && typeof r.typography === 'object' ? r.typography : {}) as Record<string, unknown>;
+ const palette = (r.palette && typeof r.palette === 'object' ? r.palette : {}) as Record<string, unknown>;
+
+ const swatches = Array.isArray(palette.swatches)
+ ? palette.swatches.map((s: unknown, i: number) => {
+ const sw = (s && typeof s === 'object' ? s : {}) as Record<string, unknown>;
+ return {
+ id: str(sw.id, `s${i}`),
+ name: str(sw.name, 'Color'),
+ hex: normalizeHex(str(sw.hex, '#888888')),
+ role: str(sw.role, 'accent'),
+ };
+ })
+ : [];
+
+ if (!swatches.length) return null;
+
+ const visualDirections = Array.isArray(r.visualDirections)
+ ? r.visualDirections.map((v: unknown, i: number) => {
+ const d = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ id: str(d.id, `v${i + 1}`),
+ name: str(d.name, `Direction ${i + 1}`),
+ description: str(d.description, ''),
+ palette: str(d.palette, ''),
+ typography: str(d.typography, ''),
+ references: str(d.references, ''),
+ };
+ })
+ : [];
+
+ const logoConcepts = Array.isArray(r.logoConcepts)
+ ? r.logoConcepts.map((v: unknown, i: number) => {
+ const c = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ id: str(c.id, `l${i + 1}`),
+ title: str(c.title, `Concept ${i + 1}`),
+ concept: str(c.concept, ''),
+ mark: str(c.mark, ''),
+ execution: str(c.execution, ''),
+ };
+ })
+ : [];
+
+ const usageExamples = Array.isArray(r.usageExamples)
+ ? r.usageExamples.map((v: unknown) => {
+ const e = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
+ return {
+ context: str(e.context, 'Example'),
+ text: str(e.text, ''),
+ };
+ })
+ : [];
+
+ // Preserve an existing valid scale (already-saved data) or fall back to default
+ const existingScale = Array.isArray(typo.scale) ? typo.scale : TYPE_SCALE;
+
+ return {
+ overview: str(r.overview, ''),
+ positioning: str(r.positioning, ''),
+ tone: {
+ attributes: strArr(tone.attributes, []),
+ voiceNotes: str(tone.voiceNotes, ''),
+ avoidList: strArr(tone.avoidList, []),
+ examplePhrases: strArr(tone.examplePhrases, []),
+ },
+ titles: strArr(r.titles, []),
+ subtitles: strArr(r.subtitles, []),
+ taglines: strArr(r.taglines, []),
+ palette: { swatches },
+ typography: {
+ primary: str(typo.primary, 'Inter'),
+ secondary: str(typo.secondary, 'Inter'),
+ mono: str(typo.mono, 'JetBrains Mono'),
+ pairNote: str(typo.pairNote, ''),
+ scale: existingScale,
+ },
+ visualDirections,
+ logoConcepts,
+ usageExamples,
+ constraints: strArr(r.constraints, []),
+ };
+}