// ==========================================================================
// Generative-UI vocabulary — the card library
// Every card follows the same chrome: mono header row + content + optional footer
// ==========================================================================
const genStyles = {
card: {
background: 'var(--card-solid)',
border: '1px solid var(--card-border)',
borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow-md)',
overflow: 'hidden',
position: 'relative',
},
header: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 18px',
borderBottom: '1px solid var(--line-soft)',
fontFamily: 'var(--font-mono)',
fontSize: 10,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: 'var(--ink-mute)',
},
body: { padding: '18px' },
};
function CardShell({ kind, title, children, actions, flush, style }) {
return (
◆
{kind}
{title && — {title} }
{actions}
{children}
);
}
// ============================================================
// 1. FILE UPLOAD CARD — shows parsed context from a file
// ============================================================
function FileCard({ file }) {
return (
{file.parsed.title}
{file.type === 'pdf' ? `${file.pages}p` : `${file.rows} rows`}
{file.parsed.summary}
{file.parsed.tags.map(t => (
{t}
))}
);
}
// ============================================================
// 2. CONTEXT CARD — "here's what I understood"
// ============================================================
function ContextCard({ files, onOpenFile }) {
return (
I've read both files. You're launching a Dry January push for BRAME — the botanical aperitif, 3 variants. Last quarter your Craft Heritage series drove most of your growth (CTR 3.2%), and two creatives are flagged for fatigue.
{files.map((f,i) => (
onOpenFile && onOpenFile(f)} style={{
padding: '10px 12px', background: 'var(--bg)', border: '1px solid var(--line-soft)', borderRadius: 8,
display: 'flex', gap: 10, alignItems: 'center', cursor: 'pointer',
}}>
{f.name}
))}
);
}
// ============================================================
// 3. AESTHETIC TILE PICKER
// ============================================================
function AestheticTiles({ tiles, value, onChange }) {
return (
{tiles.map(t => {
const active = value === t.id;
return (
onChange(t.id)} style={{
height: 128, borderRadius: 10,
background: t.bg, position: 'relative', cursor: 'pointer',
overflow: 'hidden',
border: active ? '2px solid var(--moss-deep)' : '1px solid var(--line)',
outline: active ? '3px solid var(--moss-glow)' : 'none',
outlineOffset: -5,
transition: 'all .25s',
}}>
{t.sample.text}
{t.label}
{active && (
)}
);
})}
);
}
// ============================================================
// 4. AUDIENCE SLIDER (Belgian ↔ international)
// ============================================================
function AudienceSlider({ value, onChange, labels = ['Hyper-local', 'International'] }) {
return (
);
}
// ============================================================
// 5. TONE SLIDER (named poles)
// ============================================================
function ToneSlider({ poles, value, onChange, kind = 'enrichment' }) {
return (
);
}
// ============================================================
// 6. CHANNEL MATRIX
// ============================================================
function ChannelMatrix({ channels, value, onChange, kind = 'enrichment · 3 of 4', title = 'where should this live' }) {
return (
{channels.map(c => {
const active = value.includes(c.id);
return (
{
onChange(active ? value.filter(v => v !== c.id) : [...value, c.id]);
}} style={{
padding: '12px 10px',
border: active ? '1.5px solid var(--moss-deep)' : '1px solid var(--line)',
background: active ? 'var(--moss-glow)' : 'var(--bg)',
borderRadius: 10, cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-start',
transition: 'all .2s',
color: active ? 'var(--moss-deep)' : 'var(--ink)',
}}>
{c.name}
{c.formats.map(f => (
{f}
))}
);
})}
);
}
// ============================================================
// 7. PERSONA CHIP RACK
// ============================================================
function PersonaRack({ personas, value, onChange, kind = 'enrichment · 2 of 3' }) {
return (
{personas.map(p => {
const active = value.includes(p.id);
return (
{
onChange(active ? value.filter(v => v !== p.id) : [...value, p.id]);
}} style={{
minWidth: 170, textAlign: 'left',
padding: 12,
border: active ? '1.5px solid var(--moss-deep)' : '1px solid var(--line)',
background: active ? 'var(--moss-glow)' : 'var(--bg)',
borderRadius: 10, cursor: 'pointer',
transition: 'all .2s',
color: active ? 'var(--moss-deep)' : 'var(--ink)',
}}>
{p.emoji}
{p.name}
{p.age}
{p.psy}
);
})}
);
}
// ============================================================
// 8. BUDGET DIAL — radial control
// ============================================================
function BudgetDial({ value, onChange, kind = 'enrichment · 3 of 3' }) {
const tiers = [
{ id: 'small', label: '$', note: '< €25K', angle: -120 },
{ id: 'mid', label: '$$', note: '€25–100K', angle: -40 },
{ id: 'large', label: '$$$', note: '€100–500K', angle: 40 },
{ id: 'xl', label: '$$$$', note: '€500K+', angle: 120 },
];
const active = tiers.find(t => t.id === value) || tiers[1];
return (
{tiers.map(t => {
const x = 70 * Math.cos((t.angle - 90) * Math.PI / 180);
const y = 70 * Math.sin((t.angle - 90) * Math.PI / 180);
return ;
})}
{active.label}
{active.note}
{tiers.map(t => (
onChange(t.id)} style={{
padding: '8px 12px', textAlign: 'left',
border: t.id === value ? '1.5px solid var(--moss-deep)' : '1px solid var(--line-soft)',
background: t.id === value ? 'var(--moss-glow)' : 'transparent',
borderRadius: 8, cursor: 'pointer',
display: 'flex', justifyContent: 'space-between',
fontSize: 13,
}}>
{t.label} {t.note}
))}
);
}
// ============================================================
// 9. CONSTRAINT CHIPS
// ============================================================
function ConstraintChips({ value, onChange, kind = 'constraints' }) {
const chips = [
"Dutch-language primary",
"Must include product in hero frame",
"Must not feature alcohol",
"18+ targeting",
"No human faces",
"Founder not on-camera",
"Vegan-certified callout",
];
return (
{chips.map(c => {
const on = value.includes(c);
return (
{
onChange(on ? value.filter(v => v !== c) : [...value, c]);
}} style={{
padding: '6px 12px', borderRadius: 99,
border: on ? '1px solid var(--moss-deep)' : '1px solid var(--line)',
background: on ? 'var(--moss-deep)' : 'transparent',
color: on ? 'var(--bg)' : 'var(--ink-soft)',
fontSize: 12, cursor: 'pointer',
transition: 'all .2s',
}}>
{on && } {on && ' '}{c}
);
})}
);
}
// ============================================================
// 10. BRIEF CARD — structured brief
// ============================================================
// Small card showing a past ad variant: thumbnail + ROAS chip + caption.
// Used inside BriefCard to surface real evidence next to the new brief.
function ReferenceVariantCard({ ref_ }) {
const [imgErr, setImgErr] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const url = ref_ && ref_.visual_url;
const roas = ref_ && typeof ref_.roas === 'number' ? ref_.roas : null;
const isVideo = url && /\.(mp4|mov|webm)(\?|$)/i.test(url);
const signals = Array.isArray(ref_ && ref_.match_signals) ? ref_.match_signals : [];
const rank = ref_ && ref_.rank;
const winPattern = ref_ && ref_.win_pattern;
// adaptation_note is the new field; why_referenced is the legacy field — render whichever is present.
const adaptationNote = (ref_ && (ref_.adaptation_note || ref_.why_referenced)) || '';
const selectionReasoning = (ref_ && ref_.selection_reasoning) || '';
const hasMore = Boolean(winPattern || adaptationNote || signals.length > 0);
return (
{url && !imgErr && !isVideo && (
setImgErr(true)}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
)}
{(imgErr || !url || isVideo) && (
{isVideo ? '▶ video reference' : 'no preview'}
)}
{rank && typeof rank.position === 'number' && typeof rank.of === 'number' && (
#{rank.position} of {rank.of}
)}
{roas !== null && (
ROAS {roas.toFixed(2)}
)}
{selectionReasoning && (
Why this card
{selectionReasoning}
)}
{expanded && (
<>
{signals.length > 0 && (
{signals.map((s, i) => (
{s}
))}
)}
{winPattern && (
)}
{adaptationNote && (
How to adapt
{adaptationNote}
)}
>
)}
{ref_.ad_id ? (
{ref_.ad_id}
) :
}
{hasMore && (
setExpanded(v => !v)}
style={{
fontFamily: 'var(--font-mono)', fontSize: 9, letterSpacing: '.06em',
padding: '2px 6px', borderRadius: 3,
background: 'transparent', color: 'var(--ink-soft)',
border: '1px solid var(--line-soft)', cursor: 'pointer',
textTransform: 'uppercase',
}}
>
{expanded ? '− Less' : '+ More'}
)}
);
}
function BriefCard({ brief, critique, onOpen, generating }) {
const warnings = (critique && Array.isArray(critique.warnings)) ? critique.warnings : [];
const sevColor = (sev) => {
if (sev === 'error') return { bg: '#fdecec', border: '#e08a8a', text: '#a72020' };
if (sev === 'warning') return { bg: '#fdf4e3', border: '#d6a85a', text: '#8a5a10' };
return { bg: '#eef3fb', border: '#9bb6dc', text: '#345583' };
};
return (
}>
{generating ? (
) : (
{warnings.length > 0 && (
Grounding check · {warnings.length} flag{warnings.length === 1 ? '' : 's'}
{warnings.map((w, i) => {
const c = sevColor(w.severity);
return (
{w.severity || 'warning'} · {w.field || 'unknown'}
"{w.claim}"
{w.gap}
);
})}
)}
{brief.why_now && (
Why now
{typeof brief.why_now === 'string' ? (
{brief.why_now}
) : (
<>
{brief.why_now.hook && (
{brief.why_now.hook}
)}
{Array.isArray(brief.why_now.signals) && brief.why_now.signals.length > 0 && (
{brief.why_now.signals.map((s, i) => {s} )}
)}
>
)}
)}
{brief.objective && (
Objective
{brief.objective}
)}
{brief.audience && (
Audience
{typeof brief.audience === 'string' ? (
{brief.audience}
) : (
<>
{brief.audience.who && (
{brief.audience.who}
)}
{Array.isArray(brief.audience.pains) && brief.audience.pains.length > 0 && (
Pains
{brief.audience.pains.map((p, i) => {p} )}
)}
{Array.isArray(brief.audience.desires) && brief.audience.desires.length > 0 && (
Desires
{brief.audience.desires.map((d, i) => {d} )}
)}
>
)}
)}
{[
{ k: 'Core insight', v: brief.insight, serif: true },
{ k: 'Message', v: brief.message, big: true },
].map(row => (
))}
{brief.reference_variants && brief.reference_variants.length > 0 && (
Grounded in · {brief.reference_variants.length} past performer{brief.reference_variants.length === 1 ? '' : 's'}
{brief.selection_logic && (
Selection logic
{brief.selection_logic}
)}
{brief.reference_variants.map((ref, i) => (
))}
)}
Channels
{brief.channels.map(c => (
{c}
))}
KPIs
{brief.kpis.map(k => {k} )}
)}
);
}
// ============================================================
// 11. MESSAGING ARCHITECTURE — hierarchy diagram
// ============================================================
function MessagingArchitecture({ pillars, onOpen }) {
return (
}>
{pillars.map((p,i) => (
Pillar 0{i+1}
{p.pillar}
Activation
{p.activation}
))}
);
}
// ============================================================
// 12. AD CONCEPT CARD — single ad mockup
// ============================================================
function AdConcept({ concept, onClick, mini = false }) {
const h = mini ? 180 : 280;
const isVert = concept.channel.includes('9:16') || concept.channel.includes('Story');
const aspect = isVert ? 9/16 : (concept.channel.includes('OOH') ? 16/9 : 1);
const w = h * aspect;
return (
e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}>
{/* bottle illustration absolute */}
BRAME · {concept.hook}
{concept.headline}
{!mini &&
{concept.sub}
}
{concept.channel}
);
}
// ============================================================
// 12. AD CONCEPT GRID
// ============================================================
//
// TODO[image-pipeline, Claude Code]:
// Each concept currently renders with `c.bg` as a CSS gradient/solid color —
// a placeholder for the generated creative. When the image pipeline is live:
// 1. Extend the `concept` shape from api.generateConcepts() to include:
// imageUrl : string (signed URL to the generated image asset)
// imagePrompt: string (the prompt used — kept for regen + transparency)
// imageModel : string ('imagen-4' / 'flux-pro' / 'internal-brand-lora')
// 2. Replace the `background: c.bg` inline style below with a layered
// `background: url(${c.imageUrl}) center/cover, ${c.bg}` (bg as fallback
// while the image loads or if gen fails).
// 3. The "Open in Inku" handoff on HandoffCard should carry imagePrompt +
// imageModel so the creative team can regenerate variants downstream.
// 4. If the brand-LoRA is trained (per-org), route gen through that
// endpoint and tag each artifact with the LoRA version for audit.
// Seam lives in api.js → generateConcepts() and generateVariants().
function AdConceptGrid({ concepts, onOpen }) {
return (
}>
{concepts.map((c, i) => (
))}
);
}
// ============================================================
// 13. VARIANT MATRIX — 12 variants laid out in a grid
// ============================================================
// TODO[image-pipeline, Claude Code]: same seam as AdConceptGrid above —
// each variant's `bg` is a placeholder for a real generated image. Extend
// the variant shape with imageUrl / imagePrompt / imageModel and swap the
// background in the grid cell below. Per-org brand-LoRA routing applies.
function VariantMatrix({ variants, onOpen }) {
return (
}>
{variants.map((v, i) => (
onOpen && onOpen(v)}>
BRAME
{v.copy}
{v.persona}
{v.channel} · {v.hook}
))}
);
}
// ============================================================
// 14. CONTENT CALENDAR
// ============================================================
function ContentCalendar({ onOpen }) {
// 4 weeks × 7 days = 28 cells. Mix of filled/empty
const posts = [
{ day: 1, ch: 'Meta', label: 'Teaser' },
{ day: 3, ch: 'TikTok', label: 'Hero' },
{ day: 4, ch: 'Email', label: 'Drop' },
{ day: 6, ch: 'Pinterest', label: 'Pin' },
{ day: 8, ch: 'Meta', label: 'Reel' },
{ day: 10, ch: 'TikTok', label: 'Creator' },
{ day: 12, ch: 'OOH', label: 'Wild' },
{ day: 14, ch: 'Email', label: 'Week2' },
{ day: 15, ch: 'Meta', label: 'UGC' },
{ day: 17, ch: 'TikTok', label: 'Ritual' },
{ day: 19, ch: 'Pinterest', label: 'Pin' },
{ day: 22, ch: 'Meta', label: 'Proof' },
{ day: 23, ch: 'Email', label: 'Recap' },
{ day: 25, ch: 'TikTok', label: 'Story' },
{ day: 28, ch: 'OOH', label: 'Refresh' },
{ day: 30, ch: 'Email', label: 'Finale' },
];
const channelColor = { Meta: '#1877F2', TikTok: '#1F1A13', Pinterest: '#E60023', OOH: '#C67B4A', Email: '#4E5A2F' };
const dayNames = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
return (
}>
{dayNames.map((d, i) => (
{d}
))}
{Array.from({ length: 31 }).map((_, i) => {
const day = i + 1;
const dayPosts = posts.filter(p => p.day === day);
return (
{day}
{dayPosts.slice(0,2).map((p, j) => (
{p.label}
))}
);
})}
{Object.entries(channelColor).map(([ch, color]) => (
{ch}
))}
);
}
// ============================================================
// 15. HANDOFF CARD — Open in Inku
// ============================================================
function HandoffCard({ brief, onOpen }) {
return (
Ready to produce
Send this package to Inku production
1 brief · 3 pillars · 6 concepts · 31-day calendar · 12 variants queued
Open in Inku
);
}
// ============================================================
// Open-in-Inku mini button (used in many card actions)
// ============================================================
function OpenInInku({ onClick, label = 'Open in Inku' }) {
return (
{ e.currentTarget.style.background = 'var(--moss-glow)'; e.currentTarget.style.color = 'var(--moss-deep)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--ink-soft)'; }}>
{label}
);
}
// ============================================================
// Performance hypothesis card — predicted uplift
// ============================================================
function PerfHypothesis({ concept, lift = 23, because }) {
return (
"{because}"
Based on 412 rows of last-quarter performance + 3 matched cohorts
);
}
// ============================================================
// Helper: a streaming "drafting..." loader
// ============================================================
function GeneratingStream({ lines }) {
const [idx, setIdx] = React.useState(0);
React.useEffect(() => {
if (idx >= lines.length) return;
const t = setTimeout(() => setIdx(idx + 1), 600);
return () => clearTimeout(t);
}, [idx, lines.length]);
return (
{lines.map((l, i) => (
{i < idx ? : (i === idx ? : )}
{l}
))}
);
}
Object.assign(window, {
CardShell, FileCard, ContextCard, AestheticTiles, AudienceSlider, ToneSlider,
ChannelMatrix, PersonaRack, BudgetDial, ConstraintChips, BriefCard,
MessagingArchitecture, AdConcept, AdConceptGrid, VariantMatrix, ContentCalendar,
HandoffCard, OpenInInku, PerfHypothesis, GeneratingStream,
});