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:
parent
8c63a8f7fe
commit
c9dedec7da
8 changed files with 347 additions and 15 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
218
v2/operator-app/src/components/ThemePreview.tsx
Normal file
218
v2/operator-app/src/components/ThemePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & branding
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadBriefAsJson(brief)}
|
||||
|
|
|
|||
|
|
@ -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 & branding ↗
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{id && (
|
||||
<ThemeEditor briefId={id} initialTheme={data.brief.theme ?? null} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
73
v2/operator-app/src/routes/briefs/theme.tsx
Normal file
73
v2/operator-app/src/routes/briefs/theme.tsx
Normal 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 & branding</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mt-1">Theme & 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue