Theme & branding: standalone editor with live preview

Promotes the per-brief theme picker from a buried section at the bottom
of the JSON-textarea edit page to a dedicated /briefs/:id/theme route
that feels like a real edit tool.

New route layout (5fr / 4fr split, sticky preview on xl+):
- Left: ThemeEditor (8 accent presets + custom hex; 4 heading-font
  tiles each rendering "The Branded Glass Moment" in the candidate
  font; 3 background presets; agency-name input; save / reset).
- Right: ThemePreview — slice of the per-report dashboard styled by
  the picked theme, updates LIVE on every tweak before save.

ThemePreview renders a mock dashboard topbar (with agency name + accent
eyebrow), 3 KPI tiles, leaderboard row (rank + format dot + accent
bar + plays), sample trend card (maturity pill + format chip + truth
quote + KPI strip), primary/secondary buttons, accent-2 swatch.
Inline-styled with full hex values so changing the picker doesn't
bleed into the operator app's chrome.

ThemeEditor refactored to expose live state via optional onPreview
callback. Internal save/reset behaviour unchanged.

Discoverability:
- Brief detail page: "Theme & branding" button in the header action row
  next to "Export JSON" and "Run pipeline".
- Brief edit page: footer link "Theme & branding ↗" replaces the
  inline editor that lived at the bottom (now redundant).
- Brief list rows: small accent-dot indicator in the right-side
  metadata column when a theme is set, plus a per-row "Add theme" /
  "Edit theme" link in the action footer.

Operator app's index.html now also loads Fraunces / Playfair /
Space Grotesk / Inter / JetBrains Mono so the preview is WYSIWYG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-05 14:08:46 -04:00
parent 8c63a8f7fe
commit c9dedec7da
8 changed files with 347 additions and 15 deletions

View file

@ -10,6 +10,11 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Fonts for ThemePreview — same set the per-report dashboard SPA loads. -->
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Playfair+Display:wght@400..700&family=Space+Grotesk:wght@400..700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View file

@ -7,6 +7,7 @@ import BriefsList from './routes/briefs/list';
import BriefNew from './routes/briefs/new';
import BriefDetail from './routes/briefs/detail';
import BriefEdit from './routes/briefs/edit';
import BriefTheme from './routes/briefs/theme';
import ReportDetail from './routes/reports/detail';
import TeamsList from './routes/teams/list';
import TeamDetail from './routes/teams/detail';
@ -31,6 +32,7 @@ export default function App() {
<Route path="/briefs/new" element={<BriefNew />} />
<Route path="/briefs/:id" element={<BriefDetail />} />
<Route path="/briefs/:id/edit" element={<BriefEdit />} />
<Route path="/briefs/:id/theme" element={<BriefTheme />} />
<Route path="/reports/:id" element={<ReportDetail />} />
<Route path="/teams" element={<TeamsList />} />
<Route path="/teams/:id" element={<TeamDetail />} />

View file

@ -33,7 +33,7 @@ const BACKGROUNDS: { key: BriefTheme['background']; label: string; bg: string; i
{ key: 'ink', label: 'Ink (dark)', bg: '#1a1614', ink: '#f5f0e6' },
];
const DEFAULT_THEME: BriefTheme = {
export const DEFAULT_THEME: BriefTheme = {
accent_hex: '#c2602a',
heading_font: 'fraunces',
background: 'cream',
@ -42,9 +42,11 @@ const DEFAULT_THEME: BriefTheme = {
export interface ThemeEditorProps {
briefId: string;
initialTheme: BriefTheme | null;
/** Fires on every tweak BEFORE save — lets a sibling preview track live. */
onPreview?: (theme: BriefTheme) => void;
}
export function ThemeEditor({ briefId, initialTheme }: ThemeEditorProps) {
export function ThemeEditor({ briefId, initialTheme, onPreview }: ThemeEditorProps) {
const update = useUpdateBriefTheme(briefId);
const reset = useResetBriefTheme(briefId);
const [theme, setTheme] = useState<BriefTheme>(initialTheme ?? DEFAULT_THEME);
@ -56,6 +58,11 @@ export function ThemeEditor({ briefId, initialTheme }: ThemeEditorProps) {
if (initialTheme) setTheme(initialTheme);
}, [initialTheme]);
// Push every tweak to a sibling preview pane.
useEffect(() => {
onPreview?.(theme);
}, [theme, onPreview]);
function patch(partial: Partial<BriefTheme>) {
setTheme((cur) => ({ ...cur, ...partial }));
}

View file

@ -0,0 +1,218 @@
// Live preview pane for the theme editor. Renders a small slice of what the
// per-report dashboard will look like (topbar with agency name, KPI tile,
// leaderboard-style row with format dot + bar, sample trend card with truth
// quote), all styled by the picked theme.
//
// Implementation: scope the CSS custom properties to a single wrapper div
// (style={{ ...vars }}) so changing the picker doesn't bleed into the
// operator app's chrome. Fonts come from the same Google Fonts link tag the
// dashboard SPA uses — Inter + Fraunces + Playfair + Space Grotesk are all
// loaded into the operator app via the index.html below.
import type { BriefTheme } from '../api/briefs';
const FONT_STACKS: Record<BriefTheme['heading_font'], string> = {
fraunces: '"Fraunces", Georgia, serif',
playfair: '"Playfair Display", Georgia, serif',
inter: '"Inter", system-ui, sans-serif',
'space-grotesk': '"Space Grotesk", system-ui, sans-serif',
};
interface BgPalette {
bg: string;
paper: string;
paperSoft: string;
ink: string;
ink2: string;
ink3: string;
muted: string;
line: string;
}
const BG_PALETTES: Record<BriefTheme['background'], BgPalette> = {
cream: {
bg: '#f5f0e6', paper: '#fbf7ef', paperSoft: '#f0e9dc',
ink: '#1a1614', ink2: '#4a3f37', ink3: '#74675c',
muted: '#9b8d80', line: '#e2d9c8',
},
paper: {
bg: '#fbf7ef', paper: '#ffffff', paperSoft: '#f6f1e6',
ink: '#1a1614', ink2: '#4a3f37', ink3: '#74675c',
muted: '#9b8d80', line: '#e2d9c8',
},
ink: {
bg: '#1a1614', paper: '#22201d', paperSoft: '#2c2924',
ink: '#f5f0e6', ink2: '#d8cdb6', ink3: '#9b8d80',
muted: '#74675c', line: '#3a342d',
},
};
function deriveAccent2(hex: string): string {
// Cheap HSL-darken; keep it independent from the pipeline's helper so the
// preview doesn't need to round-trip through the server.
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
if (!m) return hex;
const n = parseInt(m[1]!, 16);
const r = (n >> 16) & 0xff, g = (n >> 8) & 0xff, b = n & 0xff;
const dim = (c: number) => Math.max(0, Math.round(c * 0.7));
const c = (x: number) => x.toString(16).padStart(2, '0');
return `#${c(dim(r))}${c(dim(g))}${c(dim(b))}`;
}
export function ThemePreview({ theme }: { theme: BriefTheme }) {
const palette = BG_PALETTES[theme.background] ?? BG_PALETTES.cream;
const fontStack = FONT_STACKS[theme.heading_font] ?? FONT_STACKS.fraunces;
const accent = /^#[0-9a-f]{6}$/i.test(theme.accent_hex) ? theme.accent_hex : '#c2602a';
const accent2 = theme.accent_2_hex && /^#[0-9a-f]{6}$/i.test(theme.accent_2_hex)
? theme.accent_2_hex
: deriveAccent2(accent);
const wrap: React.CSSProperties = {
background: palette.bg,
color: palette.ink,
fontFamily: '"Inter", system-ui, sans-serif',
padding: 16,
borderRadius: 8,
border: `1px solid ${palette.line}`,
};
const card: React.CSSProperties = {
background: palette.paper,
border: `1px solid ${palette.line}`,
borderRadius: 8,
padding: 14,
};
return (
<div style={wrap}>
{/* Topbar */}
<div style={{ borderBottom: `1px solid ${palette.line}`, paddingBottom: 12, marginBottom: 14 }}>
<div style={{
color: accent, fontSize: 10, fontWeight: 600, letterSpacing: '0.15em',
textTransform: 'uppercase', fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}>
{theme.agency_name || 'Social Listening'}
</div>
<div style={{ fontFamily: fontStack, fontSize: 26, color: palette.ink, marginTop: 2, lineHeight: 1.1 }}>
The Branded Glass Moment
</div>
<div style={{ fontSize: 12, color: palette.muted, marginTop: 4 }}>
What are the cultural moments emerging in beauty on TikTok right now?
</div>
</div>
{/* KPI tile row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginBottom: 14 }}>
{[
['Trends', '47'],
['Plays', '566M'],
['Avg STL', '4.3%'],
].map(([k, v]) => (
<div key={k} style={{ ...card, padding: 10 }}>
<div style={{
fontSize: 9, color: palette.muted, textTransform: 'uppercase', letterSpacing: '0.15em',
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}>{k}</div>
<div style={{ fontFamily: fontStack, fontSize: 22, color: palette.ink, marginTop: 2 }}>{v}</div>
</div>
))}
</div>
{/* Leaderboard row sample */}
<div style={{ ...card, marginBottom: 14, padding: 0 }}>
<div style={{
padding: '10px 14px', borderBottom: `1px solid ${palette.line}`,
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
}}>
<span style={{ fontFamily: fontStack, fontSize: 16 }}>
Top by <em style={{ fontStyle: 'italic', color: accent }}>plays</em>
</span>
<span style={{
fontSize: 9, color: palette.muted, textTransform: 'uppercase', letterSpacing: '0.15em',
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}>
Lane Leaderboard
</span>
</div>
<div style={{
padding: '10px 14px', display: 'grid', gridTemplateColumns: '20px 1fr 80px 50px',
alignItems: 'center', gap: 10,
}}>
<span style={{
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
fontSize: 11, color: palette.muted,
}}>01</span>
<div>
<div style={{ fontFamily: fontStack, fontSize: 14, color: palette.ink }}>The Ceremonial Hair Wash</div>
<div style={{
fontSize: 10, color: palette.muted, marginTop: 2, display: 'flex', alignItems: 'center', gap: 4,
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}>
<span style={{ display: 'inline-block', height: 6, width: 6, borderRadius: '50%', background: '#c2602a' }} />
<span>routine · 84 vids</span>
</div>
</div>
<div style={{ height: 4, background: palette.line, borderRadius: 999, overflow: 'hidden' }}>
<div style={{ height: '100%', width: '100%', background: accent, borderRadius: 999 }} />
</div>
<span style={{ fontFamily: fontStack, fontSize: 13, textAlign: 'right' }}>566M</span>
</div>
</div>
{/* Sample trend card */}
<div style={card}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 9, fontFamily: '"JetBrains Mono", ui-monospace, monospace',
textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 8,
}}>
<span style={{ background: accent, color: palette.paper, padding: '2px 6px', borderRadius: 4 }}>
big anchor
</span>
<span style={{ color: palette.ink2 }}>
<span style={{ display: 'inline-block', height: 6, width: 6, borderRadius: '50%', background: '#c2602a', marginRight: 4 }} />
routine
</span>
<span style={{ color: palette.muted, marginLeft: 'auto' }}>Hair Rituals</span>
</div>
<div style={{ fontFamily: fontStack, fontSize: 18, lineHeight: 1.2, color: palette.ink }}>
The Ceremonial Hair Wash
</div>
<div style={{ fontFamily: fontStack, fontStyle: 'italic', fontSize: 13, color: palette.ink2, marginTop: 6, lineHeight: 1.4 }}>
"The bathroom is the only room where no one can interrupt you."
</div>
<div style={{
display: 'flex', gap: 10, marginTop: 10,
fontSize: 11, color: palette.ink3,
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}>
<span>84 vids</span><span>·</span><span>566M</span><span>·</span><span>STL 4.1%</span>
</div>
</div>
{/* Button sample */}
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<button
type="button"
style={{
background: accent, color: palette.background === 'ink' ? '#1a1614' : '#000',
padding: '8px 14px', borderRadius: 6, fontSize: 13, fontWeight: 500, border: 'none', cursor: 'default',
}}
>
Primary action
</button>
<button
type="button"
style={{
background: 'transparent', color: palette.ink, border: `1px solid ${palette.line}`,
padding: '8px 14px', borderRadius: 6, fontSize: 13, cursor: 'default',
}}
>
Secondary
</button>
<span style={{ color: accent2, fontSize: 11, alignSelf: 'center', marginLeft: 'auto' }}>
accent_2 swatch
</span>
</div>
</div>
);
}

View file

@ -228,7 +228,14 @@ function DetailBody({ brief, canDelete, canRun, onDelete, deleting }: {
<p className="text-xs text-text-muted mt-0.5">slug: {brief.slug}</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap justify-end">
<Link
to={`/briefs/${brief.id}/theme`}
className="border border-border-input hover:border-accent text-text-body px-3 py-2 rounded text-sm"
title="Pick the per-report dashboard theme for this brief"
>
Theme &amp; branding
</Link>
<button
type="button"
onClick={() => downloadBriefAsJson(brief)}

View file

@ -3,11 +3,10 @@
// or to `full` being missing — falls back to reconstructing the brief shape
// from the public columns. Doesn't clobber user-typed content on data refetch.
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useBrief, useUpdateBrief, type BriefCreateInput, type BriefSummary } from '../../api/briefs';
import { ApiError } from '../../api/client';
import { useMe } from '../../auth/useMe';
import { ThemeEditor } from '../../components/ThemeEditor';
function reconstructFromPublic(b: BriefSummary): BriefCreateInput {
// When the server's `full` field is absent (older briefs, or any stored
@ -148,12 +147,17 @@ export default function BriefEdit() {
>
Cancel
</button>
{id && (
<Link
to={`/briefs/${id}/theme`}
className="ml-auto border border-border-input hover:border-accent text-text-body px-4 py-2 rounded text-sm"
title="Open the theme & branding editor with a live preview"
>
Theme &amp; branding
</Link>
)}
</div>
</form>
{id && (
<ThemeEditor briefId={id} initialTheme={data.brief.theme ?? null} />
)}
</div>
);
}

View file

@ -47,14 +47,30 @@ function BriefRow({ brief, canDelete }: { brief: BriefSummary; canDelete: boolea
</p>
</div>
<div className="text-right text-xs text-text-muted shrink-0 space-y-1">
<div>{formatBudget(brief.budget_usd)} / {brief.date_window_days}d</div>
<div className="flex items-center justify-end gap-1.5">
{brief.theme && (
<span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: brief.theme.accent_hex }}
title={`Themed: ${brief.theme.accent_hex}${brief.theme.agency_name ? ` · ${brief.theme.agency_name}` : ''}`}
/>
)}
<span>{formatBudget(brief.budget_usd)} / {brief.date_window_days}d</span>
</div>
<div>{formatDate(brief.created_at)}</div>
</div>
</div>
</Link>
{canDelete && (
<div className="px-4 pb-3 -mt-1 flex items-center justify-end gap-3">
{err && <span className="text-red-400 text-xs">{err}</span>}
<div className="px-4 pb-3 -mt-1 flex items-center justify-end gap-3">
{err && <span className="text-red-400 text-xs">{err}</span>}
<Link
to={`/briefs/${brief.id}/theme`}
className="text-xs text-text-dim hover:text-accent"
title="Edit dashboard theme & branding for this brief"
>
{brief.theme ? 'Edit theme' : 'Add theme'}
</Link>
{canDelete && (
<button
type="button"
onClick={onDelete}
@ -64,8 +80,8 @@ function BriefRow({ brief, canDelete }: { brief: BriefSummary; canDelete: boolea
>
{del.isPending ? 'Deleting…' : 'Delete'}
</button>
</div>
)}
)}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
// Dedicated brief theme & branding tool. The same ThemeEditor that lives on
// the brief edit page, but with a live preview pane to its right and proper
// page chrome (title, breadcrumb, back-to-brief link) so it feels like a
// real edit tool.
//
// Path: /briefs/:id/theme — linked from the brief detail page header,
// brief edit page, and brief list rows.
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useBrief, type BriefTheme } from '../../api/briefs';
import { ThemeEditor, DEFAULT_THEME } from '../../components/ThemeEditor';
import { ThemePreview } from '../../components/ThemePreview';
export default function BriefThemeRoute() {
const { id } = useParams();
const { data, isLoading, error } = useBrief(id);
const [livePreview, setLivePreview] = useState<BriefTheme>(
data?.brief.theme ?? DEFAULT_THEME,
);
if (isLoading) return <div className="text-text-muted text-sm">Loading</div>;
if (error || !data?.brief) {
return <div className="text-red-400 text-sm">Could not load brief.</div>;
}
const brief = data.brief;
return (
<div className="space-y-5 max-w-7xl">
<header className="flex items-baseline justify-between gap-3 flex-wrap">
<div>
<div className="text-xs text-text-muted">
<Link to="/briefs" className="hover:text-text-body">Briefs</Link>
<span className="mx-1.5 text-text-dim">/</span>
<Link to={`/briefs/${brief.id}`} className="hover:text-text-body">{brief.client_name}</Link>
<span className="mx-1.5 text-text-dim">/</span>
<span className="text-text-body">Theme &amp; branding</span>
</div>
<h1 className="text-2xl font-semibold mt-1">Theme &amp; branding</h1>
<p className="text-sm text-text-muted mt-0.5">
Pick the look the per-report dashboard renders for <strong className="text-text-body">{brief.client_name}</strong>.
Reports rebuilt after a change pick up the new theme.
</p>
</div>
<Link
to={`/briefs/${brief.id}`}
className="border border-border-input hover:border-accent text-text-body px-3 py-1.5 rounded text-sm"
>
Back to brief
</Link>
</header>
<div className="grid grid-cols-1 xl:grid-cols-[5fr_4fr] gap-5">
<div>
<ThemeEditor
briefId={brief.id}
initialTheme={brief.theme ?? null}
onPreview={setLivePreview}
/>
</div>
<div className="xl:sticky xl:top-4 self-start">
<div className="text-xs text-text-muted uppercase tracking-wider mb-2">Live preview</div>
<ThemePreview theme={livePreview} />
<p className="text-[11px] text-text-dim mt-2 leading-relaxed">
This pane updates live as you tweak the controls it's a slice of what the per-report
dashboard will render. Changes only persist once you click <strong>Save theme</strong>;
existing finished reports keep their old theme until rebuilt.
</p>
</div>
</div>
</div>
);
}