diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md
index 488a03a1..c664c0d3 100644
--- a/AGENT_GUIDE.md
+++ b/AGENT_GUIDE.md
@@ -114,6 +114,12 @@ The agent must ask the user before changing any major production choice, includi
Minor prompt refinements inside an already approved provider/model path do not require separate approval unless they materially change the creative direction.
+### Re-log Changed Decisions (Binding)
+
+The `decision_log` is the board's Decisions rail and the run's audit trail. It is **append-only history, not a scratchpad.** When a choice you already logged changes mid-run — the user swaps the voice, you switch provider/model/runtime/music, or a fallback overrides an earlier pick — you MUST **append a new `decision_log` entry** for the new choice (same `category`, e.g. `voice_selection`), with the superseded option moved into `options_considered` and `rejected_because` noting it was changed.
+
+Editing only a downstream artifact (the `asset_manifest`, a prop) while leaving the old decision in the log is a defect: the board keeps showing the stale choice (e.g. `voice → openai_onyx` after the user moved to Chirp3). The board renders the **latest** entry per `category` as current — so the fix is to write the new entry, never to silently mutate the old one. This applies at every stage, not just `idea`.
+
### Present Both Composition Runtimes (HARD RULE)
When both Remotion and HyperFrames are available on the machine (check `video_compose.get_info()["render_engines"]`), the agent **MUST present both options to the user** before locking `render_runtime` at the proposal stage. The agent MAY recommend one with rationale — but silently picking a "default" is forbidden even when the pipeline manifest or a director skill suggests one.
diff --git a/backlot/state.py b/backlot/state.py
index ee891878..c28d60b1 100644
--- a/backlot/state.py
+++ b/backlot/state.py
@@ -311,12 +311,20 @@ def _asset_entry(project_dir: Path, asset: dict) -> dict:
kind = "video"
elif ext in MEDIA_AUDIO_EXT:
kind = "audio"
+ # A visual is only *renderable* on the board if the file it points at is
+ # actually a raster image or a video. Bespoke/atelier assets (type
+ # "animation" pointing at a .tsx composition) exist on disk but can't be
+ # thumbnailed — routing them to
yields a broken image. The board
+ # falls back to a per-scene snapshot or the shot-spec placeholder instead.
+ ext = file_path.suffix.lower()
+ renderable = exists and ext in (MEDIA_IMAGE_EXT | MEDIA_VIDEO_EXT)
return {
"id": asset.get("id"),
"type": kind,
"scene_id": asset.get("scene_id"),
"path": _rel(project_dir, file_path) if exists else raw_path,
"exists": exists,
+ "renderable": renderable,
"prompt": asset.get("prompt"),
"model": asset.get("model"),
"source_tool": asset.get("source_tool"),
@@ -328,6 +336,36 @@ def _asset_entry(project_dir: Path, asset: dict) -> dict:
}
+def _find_scene_snapshot(project_dir: Path, scene_id: str) -> Optional[dict]:
+ """A per-scene review still, if the run wrote one.
+
+ Atelier/animation scenes have no thumbnailable asset file, so the
+ assets-stage snapshot (`snapshots/.png`) is what the filmstrip
+ shows. Accept exact `.` and `_*.` forms.
+ """
+ snap_dir = project_dir / "snapshots"
+ if not scene_id or not snap_dir.is_dir():
+ return None
+ try:
+ for f in sorted(snap_dir.iterdir()):
+ if not f.is_file() or f.suffix.lower() not in MEDIA_IMAGE_EXT:
+ continue
+ stem = f.stem
+ if stem == scene_id or stem.startswith(f"{scene_id}_"):
+ return {
+ "id": f"snap_{scene_id}",
+ "type": "image",
+ "scene_id": scene_id,
+ "path": _rel(project_dir, f),
+ "exists": True,
+ "renderable": True,
+ "snapshot": True,
+ }
+ except OSError:
+ return None
+ return None
+
+
def _find_script_section(scene: dict, sections: list[dict]) -> Optional[dict]:
"""Join scene → script section by id, falling back to timing overlap."""
sid = scene.get("script_section_id")
@@ -396,8 +434,12 @@ def _build_storyboard(
scene_assets = assets_by_scene.get(sid, [])
visuals = [a for a in scene_assets if a["type"] in ("image", "video", "diagram", "animation")]
audio = [a for a in scene_assets if a["type"] in ("audio", "narration", "music", "sfx")]
- # Takes: multiple visual assets for the same slot, ordered as listed.
- active_visual = visuals[-1] if visuals else None
+ # Only files that can actually be shown (raster/video) are takes; a
+ # bespoke composition asset (.tsx animation) is real but not showable.
+ renderable = [a for a in visuals if a.get("renderable")]
+ # Active visual: newest renderable take, else a per-scene review
+ # snapshot, else nothing (card falls to the shot-spec placeholder).
+ active_visual = renderable[-1] if renderable else _find_scene_snapshot(project_dir, sid)
cards.append({
"id": sid,
"type": scene.get("type"),
@@ -418,7 +460,7 @@ def _build_storyboard(
"section_label": (section or {}).get("label"),
"required_assets": scene.get("required_assets") or [],
"visual": active_visual,
- "takes": visuals,
+ "takes": renderable,
"audio": audio,
"generating": generating.get(sid) is not None,
"generating_tool": (generating.get(sid) or {}).get("tool"),
diff --git a/backlot/ui/board.css b/backlot/ui/board.css
index 45655970..e4de79ff 100644
--- a/backlot/ui/board.css
+++ b/backlot/ui/board.css
@@ -29,6 +29,10 @@
--sans: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--screenplay: 'Courier Prime', 'Courier New', monospace;
+
+ /* Global type scale. Every font-size is calc( * var(--fs-scale)), so this
+ one number scales all text proportionally for readability. 1 = original. */
+ --fs-scale: 1.16;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -43,7 +47,7 @@ body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
- font-size: 14px;
+ font-size: calc(14px * var(--fs-scale));
line-height: 1.5;
min-height: 100vh;
/* faint vignette so media pops */
@@ -101,15 +105,15 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
border: 1px solid var(--border);
}
.slate h1 {
- font-family: var(--mono); font-size: 17px; font-weight: 600;
+ font-family: var(--mono); font-size: calc(17px * var(--fs-scale)); font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
}
.slate .wordmark {
- font-family: var(--mono); font-size: 11px; letter-spacing: 0.22em;
+ font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); letter-spacing: 0.22em;
color: var(--text-3); text-transform: uppercase; margin-right: 2px;
}
.chip {
- font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.06em;
+ font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); letter-spacing: 0.06em;
padding: 3px 9px; border-radius: 99px;
border: 1px solid var(--border); color: var(--text-2);
white-space: nowrap;
@@ -119,7 +123,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.live {
display: inline-flex; align-items: center; gap: 7px;
- font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
+ font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); letter-spacing: 0.14em;
color: var(--amber);
}
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s ease-in-out infinite; }
@@ -131,13 +135,13 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
}
.cost { text-align: right; }
-.cost .nums { font-family: var(--mono); font-size: 13px; }
+.cost .nums { font-family: var(--mono); font-size: calc(13px * var(--fs-scale)); }
.cost .nums b { color: var(--text); font-weight: 600; }
.cost .nums span { color: var(--text-3); }
.cost .bar { width: 150px; height: 3px; background: var(--surface-3); border-radius: 3px; margin-top: 5px; overflow: hidden; }
.cost .bar i { display: block; height: 100%; background: var(--green); border-radius: 3px; }
.cost .bar i.warn { background: var(--amber); }
-.cost .label { font-size: 10px; color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
+.cost .label { font-size: calc(10px * var(--fs-scale)); color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
/* ---------- stage rail ---------- */
.rail { display: flex; align-items: flex-start; padding: 26px 0 22px; }
@@ -145,7 +149,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.stage .node {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
- font-size: 12px; z-index: 2; position: relative;
+ font-size: calc(12px * var(--fs-scale)); z-index: 2; position: relative;
background: var(--surface-2); border: 1.5px solid var(--border);
color: var(--text-3);
}
@@ -155,10 +159,10 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
}
.stage:first-child .line { display: none; }
.stage .name {
- margin-top: 10px; font-family: var(--mono); font-size: 11px;
+ margin-top: 10px; font-family: var(--mono); font-size: calc(11px * var(--fs-scale));
letter-spacing: 0.05em; color: var(--text-3);
}
-.stage .sub { font-size: 10.5px; color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; }
+.stage .sub { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; }
.stage.done .node { background: var(--surface-3); border-color: #3a3a42; color: var(--green); }
.stage.done .line { background: #3a3a42; }
@@ -200,8 +204,8 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
display: flex; align-items: baseline; gap: 10px;
padding: 13px 16px 11px; border-bottom: 1px solid var(--border-soft);
}
-.panel-head h2 { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; }
-.panel-head .meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: auto; }
+.panel-head h2 { font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; }
+.panel-head .meta { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-left: auto; }
.panel-body { padding: 14px 16px; }
/* ---------- screenplay card ---------- */
@@ -222,31 +226,31 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
background: rgba(0,0,0,.07);
}
.script-card .sp-title {
- text-align: center; font-weight: 700; font-size: 16px;
+ text-align: center; font-weight: 700; font-size: calc(16px * var(--fs-scale));
letter-spacing: 0.12em; text-transform: uppercase;
margin-bottom: 4px;
}
-.script-card .sp-meta { text-align: center; font-size: 11.5px; color: var(--cream-ink-2); margin-bottom: 26px; }
+.script-card .sp-meta { text-align: center; font-size: calc(11.5px * var(--fs-scale)); color: var(--cream-ink-2); margin-bottom: 26px; }
.script-card .sp-slug {
- font-weight: 700; font-size: 12.5px; text-transform: uppercase;
+ font-weight: 700; font-size: calc(12.5px * var(--fs-scale)); text-transform: uppercase;
letter-spacing: 0.04em; margin: 18px 0 6px;
}
-.script-card .sp-slug .tc { color: var(--cream-ink-2); font-weight: 400; float: right; font-size: 11px; }
-.script-card .sp-action { font-size: 13px; line-height: 1.62; }
-.script-card .sp-paren { font-size: 11.5px; font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; }
+.script-card .sp-slug .tc { color: var(--cream-ink-2); font-weight: 400; float: right; font-size: calc(11px * var(--fs-scale)); }
+.script-card .sp-action { font-size: calc(13px * var(--fs-scale)); line-height: 1.62; }
+.script-card .sp-paren { font-size: calc(11.5px * var(--fs-scale)); font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; }
.script-card .sp-cue {
- display: inline-block; font-family: var(--mono); font-size: 9.5px; font-style: normal;
+ display: inline-block; font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); font-style: normal;
background: rgba(0,0,0,.06); border-radius: 3px; padding: 1px 6px; margin: 6px 0 0;
color: #7d6f52; letter-spacing: .03em;
}
-.script-card .sp-fade { text-align: right; font-size: 12px; font-weight: 700; margin-top: 20px; text-transform: uppercase; }
+.script-card .sp-fade { text-align: right; font-size: calc(12px * var(--fs-scale)); font-weight: 700; margin-top: 20px; text-transform: uppercase; }
.script-card .sp-expand {
position: absolute; right: 16px; bottom: 12px;
- font-family: var(--mono); font-size: 10px; color: var(--cream-ink-2); letter-spacing: .06em;
+ font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em;
}
.script-approved {
position: absolute; top: 20px; right: 26px;
- font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .14em;
+ font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); font-weight: 600; letter-spacing: .14em;
color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px;
padding: 3px 8px; transform: rotate(6deg); opacity: .8;
}
@@ -255,19 +259,20 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); }
.decision:last-child { border-bottom: none; }
.decision .d-head { display: flex; gap: 8px; align-items: baseline; }
-.decision .d-cat { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; }
-.decision .d-pick { font-size: 12.5px; font-weight: 600; margin-top: 3px; }
+.decision .d-cat { font-family: var(--mono); font-size: calc(9.5px * var(--fs-scale)); color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; }
+.decision .d-revised { color: var(--amber); }
+.decision .d-pick { font-size: calc(12.5px * var(--fs-scale)); font-weight: 600; margin-top: 3px; }
.decision .d-pick .arrow { color: var(--amber); font-weight: 400; }
-.decision .d-why { font-size: 11.5px; color: var(--text-2); margin-top: 3px; line-height: 1.45; }
-.decision .d-alt { font-size: 10.5px; color: var(--text-3); margin-top: 4px; }
+.decision .d-why { font-size: calc(11.5px * var(--fs-scale)); color: var(--text-2); margin-top: 3px; line-height: 1.45; }
+.decision .d-alt { font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); margin-top: 4px; }
.decision .d-alt s { opacity: .8; }
-.act-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); font-size: 11px; }
+.act-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); font-size: calc(11px * var(--fs-scale)); }
.act-row:last-child { border-bottom: none; }
-.act-row .t { color: var(--text-3); font-size: 10px; flex: none; }
+.act-row .t { color: var(--text-3); font-size: calc(10px * var(--fs-scale)); flex: none; }
.act-row .tool { color: var(--text-2); }
.act-row .target { color: var(--text-3); }
-.act-row .status { margin-left: auto; flex: none; font-size: 10.5px; }
+.act-row .status { margin-left: auto; flex: none; font-size: calc(10.5px * var(--fs-scale)); }
.act-row .status.ok { color: var(--green); }
.act-row .status.run { color: var(--amber); animation: blink 1.4s ease-in-out infinite; }
.act-row .status.err { color: var(--red); }
@@ -286,7 +291,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
.scene-card { flex: none; display: flex; flex-direction: column; position: relative; }
.scene-card .sc-slate {
display: flex; align-items: baseline; gap: 8px;
- font-family: var(--mono); font-size: 10px; letter-spacing: .05em;
+ font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); letter-spacing: .05em;
color: var(--text-3); padding: 0 2px 6px;
}
.scene-card .sc-slate .num { color: var(--text-2); font-weight: 600; }
@@ -300,15 +305,26 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
border: 1px solid var(--border);
}
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+/* Videos must fill the thumb box exactly — without this the