From af87fc1337254ec1978e6333b5acbbb5ffb9a3d0 Mon Sep 17 00:00:00 2001 From: calesthio Date: Sat, 18 Jul 2026 01:33:12 -0700 Subject: [PATCH] fix(backlot): surface approval artifacts before gates --- backlot/state.py | 12 +- backlot/ui/board.css | 96 +++++++++- backlot/ui/board.js | 295 +++++++++++++++++++++++++++++- tests/backlot/test_state.py | 3 + tests/backlot/test_ui_bug_bash.py | 154 ++++++++++++++++ 5 files changed, 546 insertions(+), 14 deletions(-) diff --git a/backlot/state.py b/backlot/state.py index 9ce50f7a..3cecfe3b 100644 --- a/backlot/state.py +++ b/backlot/state.py @@ -69,6 +69,10 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]: { "name": s["name"], "gated": bool(s.get("human_approval_default", False)), + "produces": [ + str(name) for name in (s.get("produces") or []) + if isinstance(name, str) and name + ], } for s in manifest.get("stages", []) if isinstance(s, dict) and s.get("name") @@ -83,7 +87,7 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]: pass return { "pipeline_type": pipeline_type or "unknown", - "stages": [{"name": s, "gated": False} for s in FALLBACK_STAGES], + "stages": [{"name": s, "gated": False, "produces": []} for s in FALLBACK_STAGES], "known": False, } @@ -152,6 +156,7 @@ def _build_stage_rail( entry: dict[str, Any] = { "name": name, "gated": stage_def["gated"], + "produces": list(stage_def.get("produces") or []), "status": status or "pending", "timestamp": cp.get("timestamp") if cp else None, "review": cp.get("review") if cp else None, @@ -188,6 +193,11 @@ def _build_stage_rail( entry = { "name": name, "gated": False, + "produces": [ + str(artifact_name) + for artifact_name in (cp.get("artifacts") or {}) + if isinstance(artifact_name, str) and artifact_name + ], "status": cp.get("status") or "unknown", "timestamp": cp.get("timestamp"), "review": cp.get("review"), diff --git a/backlot/ui/board.css b/backlot/ui/board.css index 1b017f8e..736f8439 100644 --- a/backlot/ui/board.css +++ b/backlot/ui/board.css @@ -496,6 +496,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 /* ---------- layout ---------- */ .board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; } +.board.solo { grid-template-columns: minmax(0, 1fr); } .main-col { min-width: 0; display: flex; flex-direction: column; gap: 22px; } .panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; } @@ -565,12 +566,92 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em; z-index: 2; } -.script-approved { +.script-status { position: absolute; top: 20px; right: 26px; 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; } +.script-approved { color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px; } +.script-pending { color: #9a5b00; border: 1.5px solid #9a5b00; border-radius: 3px; } +.script-draft { color: #59657a; border: 1.5px solid #59657a; border-radius: 3px; } + +/* ---------- approval review surface ---------- */ +.approval-review { + width: 100%; max-width: 1000px; margin: 0 auto; + border: 1px solid color-mix(in srgb, var(--amber) 52%, var(--border-soft)); + border-radius: 14px; + background: + radial-gradient(circle at 100% 0, color-mix(in srgb, var(--amber) 13%, transparent), transparent 42%), + var(--surface); + box-shadow: 0 28px 76px -54px rgba(0,0,0,.9); + overflow: hidden; + animation: rise .55s cubic-bezier(.2,.7,.3,1) .18s backwards; +} +.approval-review-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; + padding: 24px 26px 20px; border-bottom: 1px solid var(--border-soft); +} +.approval-review-head h2 { + margin: 4px 0 6px; color: var(--text); font-size: calc(21px * var(--fs-scale)); + line-height: 1.2; text-transform: capitalize; +} +.approval-review-head p { max-width: 650px; color: var(--text-2); font-size: calc(12.5px * var(--fs-scale)); line-height: 1.5; } +.approval-eyebrow, .approval-status, .approval-artifact-kicker, +.approval-selected, .approval-rationale b, .approval-review-note b, +.approval-review-foot, .approval-fact span { + font-family: var(--mono); letter-spacing: .11em; text-transform: uppercase; +} +.approval-eyebrow { color: var(--amber); font-size: calc(9.5px * var(--fs-scale)); } +.approval-status { + flex: none; color: var(--amber); border: 1px solid var(--amber); border-radius: 99px; + padding: 5px 9px; font-size: calc(9px * var(--fs-scale)); white-space: nowrap; +} +.approval-review-note { + margin: 18px 26px 0; padding: 10px 12px; border-left: 2px solid var(--amber); + background: color-mix(in srgb, var(--amber) 7%, var(--surface-2)); + color: var(--text-2); font-size: calc(11.5px * var(--fs-scale)); line-height: 1.5; +} +.approval-review-note b { color: var(--amber); font-size: calc(9px * var(--fs-scale)); } +.approval-artifacts { display: grid; gap: 14px; padding: 20px 26px 22px; } +.approval-artifact { + padding: 20px; border: 1px solid var(--border-soft); border-radius: 10px; + background: color-mix(in srgb, var(--surface-2) 72%, transparent); +} +.approval-artifact-kicker { color: var(--amber); font-size: calc(9px * var(--fs-scale)); } +.approval-artifact > h2 { margin: 5px 0 14px; font-size: calc(18px * var(--fs-scale)); line-height: 1.25; } +.approval-lead { color: var(--text-2); font-size: calc(13px * var(--fs-scale)); line-height: 1.55; margin: 0 0 14px; } +.approval-facts { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } +.approval-fact { + display: grid; gap: 2px; min-width: 112px; padding: 8px 10px; + border: 1px solid var(--border-soft); border-radius: 7px; background: var(--surface); +} +.approval-fact span { color: var(--text-3); font-size: calc(8px * var(--fs-scale)); } +.approval-fact b { color: var(--text-2); font-size: calc(11px * var(--fs-scale)); font-weight: 600; overflow-wrap: anywhere; } +.approval-items { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 8px; } +.approval-items li { padding: 10px 12px; border-left: 2px solid var(--border); background: var(--surface); } +.approval-items li.selected { border-left-color: var(--amber); } +.approval-item-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--text); font-size: calc(12px * var(--fs-scale)); font-weight: 600; } +.approval-items p { color: var(--text-3); font-size: calc(11px * var(--fs-scale)); line-height: 1.45; margin-top: 3px; } +.approval-selected { color: var(--amber); font-size: calc(8px * var(--fs-scale)); font-weight: 500; } +.approval-rationale, .approval-guidance { color: var(--text-3); font-size: calc(10.5px * var(--fs-scale)); line-height: 1.5; margin-top: 12px; } +.approval-rationale b { color: var(--text-2); font-size: calc(8.5px * var(--fs-scale)); } +.approval-guidance { color: var(--amber); } +.approval-missing { + padding: 16px; border: 1px solid color-mix(in srgb, var(--red) 45%, var(--border)); + border-radius: 9px; background: var(--red-dim); color: var(--text-2); font-size: calc(12px * var(--fs-scale)); +} +.approval-missing b { color: var(--red); } +.approval-review-foot { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 14px 26px; border-top: 1px solid var(--border-soft); + color: var(--text-3); font-size: calc(9px * var(--fs-scale)); +} +.approval-review-foot button { + border: 1px solid var(--border); border-radius: 99px; padding: 6px 11px; + background: var(--surface-2); color: var(--text-2); font: inherit; letter-spacing: inherit; + cursor: pointer; +} +.approval-review-foot button:hover, .approval-review-foot button:focus-visible { color: var(--amber); border-color: var(--amber); outline: none; } /* ---------- right rail: decisions & activity ---------- */ .decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); } @@ -866,7 +947,8 @@ a { color: inherit; } body:not(.first) .slate, body:not(.first) .rail .stage, body:not(.first) .script-card, body:not(.first) .notice, body:not(.first) aside .panel, body:not(.first) .scene-card, -body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } +body:not(.first) .lib-card, body:not(.first) .drawer, +body:not(.first) .approval-review { animation: none; } /* stages that ran but aren't declared by the pipeline manifest */ .stage.undeclared .node { border-style: dashed; opacity: .85; } @@ -919,6 +1001,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } } .section-title { flex-wrap: wrap; } .section-title .meta { margin-left: 0; } + .approval-review-head { flex-wrap: wrap; gap: 12px; } } @media (max-width: 520px) { @@ -940,7 +1023,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } padding: 24px 20px 28px; border-radius: 5px; } - .script-approved { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; } + .script-status { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; } .script-card .sp-title { font-size: calc(14px * var(--fs-scale)); padding-right: 58px; } .script-card .sp-meta { margin-bottom: 18px; } .script-card .sp-slug .tc { float: none; display: block; margin-top: 2px; } @@ -951,4 +1034,9 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } .drawer .drawer-head { flex-wrap: wrap; } .drawer pre { font-size: calc(10.5px * var(--fs-scale)); } .scene-card { max-width: calc(100vw - 42px); } + .approval-review-head, .approval-artifacts, .approval-review-foot { padding-left: 16px; padding-right: 16px; } + .approval-review-note { margin-left: 16px; margin-right: 16px; } + .approval-review-foot { align-items: flex-start; flex-direction: column; } + .approval-review-foot button { width: 100%; } + .approval-status { white-space: normal; } } diff --git a/backlot/ui/board.js b/backlot/ui/board.js index edf82dd4..b87ecea6 100644 --- a/backlot/ui/board.js +++ b/backlot/ui/board.js @@ -169,6 +169,36 @@ const STAGE_ARTIFACTS = { publish: ["publish_log"], }; +function artifactNamesForStage(st) { + const declared = Array.isArray(st.produces) ? st.produces : []; + const fallback = STAGE_ARTIFACTS[st.name] || []; + return [...new Set([...declared, ...fallback].filter(Boolean))]; +} + +function reviewMetrics(review) { + const nested = review && review.summary && typeof review.summary === "object" + ? review.summary : {}; + return { + critical: Number((review && review.critical) ?? nested.critical ?? 0), + suggestions: Number((review && review.suggestions) ?? nested.suggestions ?? 0), + nitpicks: Number((review && review.nitpicks) ?? nested.nitpicks ?? 0), + }; +} + +function reviewSummaryText(review) { + if (!review) return ""; + if (typeof review.summary === "string") return review.summary; + const nested = review.summary && typeof review.summary === "object" ? review.summary : {}; + const counts = reviewMetrics(review); + return [ + review.decision, + `${counts.critical} critical`, + `${counts.suggestions} suggestion${counts.suggestions === 1 ? "" : "s"}`, + nested.review_focus_met ? `review focus ${nested.review_focus_met}` : null, + nested.schema_validation, + ].filter(Boolean).join(" · "); +} + function renderDrawer(s) { if (!selectedStage) return null; const st = s.stages.find((x) => x.name === selectedStage); @@ -177,15 +207,17 @@ function renderDrawer(s) { const body = el("div", { class: "drawer-body" }); if (st.review) { + const metrics = reviewMetrics(st.review); + const summary = reviewSummaryText(st.review); body.append(el("div", { class: "findings", style: "margin-bottom:12px" }, - el("span", { class: `f ${st.review.critical ? "crit" : ""}` }, `${st.review.critical ?? 0} critical`), - el("span", { class: `f ${st.review.suggestions ? "sugg" : ""}` }, `${st.review.suggestions ?? 0} suggestions`), - el("span", { class: "f" }, `${st.review.nitpicks ?? 0} nitpicks`), - typeof st.review.summary === "string" ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, st.review.summary) : null, + el("span", { class: `f ${metrics.critical ? "crit" : ""}` }, `${metrics.critical} critical`), + el("span", { class: `f ${metrics.suggestions ? "sugg" : ""}` }, `${metrics.suggestions} suggestions`), + el("span", { class: "f" }, `${metrics.nitpicks} nitpicks`), + summary ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, summary) : null, )); } - const names = STAGE_ARTIFACTS[st.name] || []; + const names = artifactNamesForStage(st); let shown = false; for (const name of names) { const artifact = s.artifacts[name]; @@ -243,10 +275,17 @@ function renderScriptCard(s) { const script = s.artifacts.script; if (!script) return null; const scriptStage = s.stages.find((x) => x.name === "script"); - const approved = scriptStage && scriptStage.status === "completed"; + const status = scriptStage ? scriptStage.status : "unknown"; + const stamp = status === "completed" + ? el("span", { class: "script-status script-approved" }, "APPROVED") + : status === "awaiting_human" + ? el("span", { class: "script-status script-pending" }, "PENDING APPROVAL") + : status === "in_progress" + ? el("span", { class: "script-status script-draft" }, "DRAFTING") + : null; const card = el("div", { class: "script-card script-preview", title: "Click to expand full script", onclick: openScriptModal }, - approved ? el("span", { class: "script-approved" }, "APPROVED") : null, + stamp, el("div", { class: "sp-title" }, script.title || s.title), el("div", { class: "sp-meta" }, `script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`), @@ -256,6 +295,238 @@ function renderScriptCard(s) { return card; } +function humanize(value) { + return String(value || "artifact").replaceAll("_", " "); +} + +function shortText(value, limit = 180) { + const text = String(value || "").trim(); + return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; +} + +function reviewFact(label, value) { + if (value == null || value === "") return null; + return el("div", { class: "approval-fact" }, + el("span", {}, label), + el("b", {}, value), + ); +} + +function reviewFacts(items) { + const facts = items.filter(Boolean); + return facts.length ? el("div", { class: "approval-facts" }, facts) : null; +} + +function titledItems(items, selectedId = null) { + const rows = (items || []).slice(0, 4).map((item, index) => { + if (item == null) return null; + if (typeof item !== "object") { + return el("li", {}, shortText(item)); + } + const id = item.id || item.concept_id || item.option_id; + const title = item.title || item.name || item.display_name || item.label || id || item.path || item.platform || item.description || `Item ${index + 1}`; + const detail = item.hook || item.why_this_works || item.summary || item.description || item.silhouette_notes; + return el("li", { class: id && id === selectedId ? "selected" : "" }, + el("div", { class: "approval-item-title" }, shortText(title, 100), + id && id === selectedId ? el("span", { class: "approval-selected" }, "SELECTED") : null), + detail && detail !== title ? el("p", {}, shortText(detail)) : null, + ); + }).filter(Boolean); + return rows.length ? el("ul", { class: "approval-items" }, rows) : null; +} + +function genericArtifactSummary(artifact) { + const facts = []; + const items = []; + for (const [key, value] of Object.entries(artifact || {})) { + if (["version", "decision_log_ref"].includes(key)) continue; + if (["string", "number", "boolean"].includes(typeof value)) { + facts.push(reviewFact(humanize(key), shortText(value, 90))); + } else if (Array.isArray(value)) { + facts.push(reviewFact(humanize(key), `${value.length} item${value.length === 1 ? "" : "s"}`)); + if (!items.length && value.length) items.push(titledItems(value)); + } + if (facts.length >= 6) break; + } + return [reviewFacts(facts), ...items].filter(Boolean); +} + +function artifactReviewContent(name, artifact) { + if (name === "brief") { + return [ + artifact.hook ? el("p", { class: "approval-lead" }, artifact.hook) : null, + reviewFacts([ + reviewFact("platform", artifact.target_platform), + reviewFact("duration", artifact.target_duration_seconds != null ? fmtDuration(artifact.target_duration_seconds) : null), + reviewFact("tone", artifact.tone), + reviewFact("style", artifact.style), + ]), + titledItems(artifact.key_points), + ].filter(Boolean); + } + if (name === "proposal_packet") { + const selected = (artifact.selected_concept || {}).concept_id; + const plan = artifact.production_plan || {}; + const cost = artifact.cost_estimate || {}; + return [ + reviewFacts([ + reviewFact("runtime", plan.render_runtime), + reviewFact("pipeline", plan.pipeline), + reviewFact("estimated cost", cost.total_estimated_usd != null ? fmtMoney(cost.total_estimated_usd) : null), + reviewFact("concepts", Array.isArray(artifact.concept_options) ? artifact.concept_options.length : null), + ]), + titledItems(artifact.concept_options, selected), + (artifact.selected_concept || {}).rationale + ? el("p", { class: "approval-rationale" }, + el("b", {}, "WHY THIS CONCEPT "), shortText(artifact.selected_concept.rationale)) + : null, + ].filter(Boolean); + } + if (name === "research_brief") { + return [ + artifact.topic ? el("p", { class: "approval-lead" }, artifact.topic) : null, + reviewFacts([ + reviewFact("sources", Array.isArray(artifact.sources) ? artifact.sources.length : null), + reviewFact("data points", Array.isArray(artifact.data_points) ? artifact.data_points.length : null), + reviewFact("angles", Array.isArray(artifact.angles_discovered) ? artifact.angles_discovered.length : null), + ]), + titledItems(artifact.angles_discovered), + ].filter(Boolean); + } + if (name === "script") { + const first = (artifact.sections || [])[0]; + return [ + reviewFacts([ + reviewFact("duration", fmtDuration(artifact.total_duration_seconds)), + reviewFact("sections", (artifact.sections || []).length), + ]), + first && first.text ? el("p", { class: "approval-lead" }, shortText(first.text, 220)) : null, + el("p", { class: "approval-guidance" }, "The complete script preview is shown directly below."), + ].filter(Boolean); + } + if (name === "scene_plan") { + const scenes = artifact.scenes || []; + const end = scenes.reduce((max, scene) => Math.max(max, Number(scene.end_seconds) || 0), 0); + return [ + reviewFacts([ + reviewFact("scenes", scenes.length), + reviewFact("duration", end ? fmtDuration(end) : null), + ]), + titledItems(scenes), + el("p", { class: "approval-guidance" }, "Review timing and shot coverage in the storyboard below."), + ].filter(Boolean); + } + if (name === "asset_manifest") { + const assets = artifact.assets || []; + const types = [...new Set(assets.map((asset) => asset.type).filter(Boolean))]; + return [ + reviewFacts([ + reviewFact("assets", assets.length), + reviewFact("types", types.join(", ")), + reviewFact("generation cost", artifact.total_cost_usd != null ? fmtMoney(artifact.total_cost_usd) : null), + ]), + titledItems(assets), + el("p", { class: "approval-guidance" }, "Inspect every generated take in the filmstrip below before approving compose."), + ].filter(Boolean); + } + if (name === "edit_decisions") { + return [ + reviewFacts([ + reviewFact("cuts", Array.isArray(artifact.cuts) ? artifact.cuts.length : null), + reviewFact("runtime", artifact.render_runtime || (artifact.metadata || {}).render_runtime), + ]), + titledItems(artifact.cuts), + ].filter(Boolean); + } + if (name === "render_report") { + return [ + reviewFacts([ + reviewFact("outputs", Array.isArray(artifact.outputs) ? artifact.outputs.length : null), + reviewFact("duration", artifact.duration_seconds != null ? fmtDuration(artifact.duration_seconds) : null), + ]), + titledItems(artifact.outputs), + ].filter(Boolean); + } + if (name === "publish_log") { + return [ + reviewFacts([reviewFact("destinations", Array.isArray(artifact.entries) ? artifact.entries.length : null)]), + titledItems((artifact.entries || []).map((entry) => ({ + title: entry.platform || entry.destination || "Publish destination", + description: [entry.status, entry.url].filter(Boolean).join(" · "), + }))), + ].filter(Boolean); + } + return genericArtifactSummary(artifact); +} + +function artifactReviewTitle(name, artifact, s) { + if (name === "proposal_packet") { + const selected = (artifact.selected_concept || {}).concept_id; + const concept = (artifact.concept_options || []).find((item) => item.id === selected); + return (concept && concept.title) || "Production proposal"; + } + if (name === "research_brief") return artifact.topic || "Research brief"; + if (name === "scene_plan") return "Scene plan"; + if (name === "asset_manifest") return "Generated assets"; + if (name === "edit_decisions") return "Edit decisions"; + if (name === "render_report") return "Render report"; + if (name === "publish_log") return "Publish plan"; + return artifact.title || artifact.name || s.title; +} + +function renderApprovalReview(s) { + const awaiting = s.stages.find((item) => item.status === "awaiting_human"); + if (!awaiting) return null; + + const names = artifactNamesForStage(awaiting); + const entries = names + .filter((name) => name !== "decision_log") + .map((name) => [name, s.artifacts[name]]) + .filter(([, artifact]) => artifact && typeof artifact === "object"); + const stageIndex = s.stages.findIndex((item) => item.name === awaiting.name); + const nextStage = stageIndex >= 0 ? s.stages[stageIndex + 1] : null; + const review = awaiting.review || {}; + const reviewSummary = reviewSummaryText(review); + + const artifacts = entries.map(([name, artifact]) => el("article", { + class: "approval-artifact", + "data-artifact": name, + }, + el("div", { class: "approval-artifact-kicker" }, humanize(name)), + el("h2", {}, artifactReviewTitle(name, artifact, s)), + ...artifactReviewContent(name, artifact), + )); + + if (!artifacts.length) { + artifacts.push(el("div", { class: "approval-missing", role: "alert" }, + el("b", {}, "Nothing reviewable was found. "), + names.length + ? `The ${awaiting.name} checkpoint declares ${names.map(humanize).join(", ")}, but Backlot could not load it.` + : `The ${awaiting.name} checkpoint does not declare an artifact.`, + )); + } + + return el("section", { class: "approval-review", "data-stage": awaiting.name }, + el("div", { class: "approval-review-head" }, + el("div", {}, + el("div", { class: "approval-eyebrow" }, "REVIEW GATE"), + el("h2", {}, `${humanize(awaiting.name)} is ready for your review`), + el("p", {}, "Review the artifact here, then reply in chat to approve it or request changes."), + ), + el("span", { class: "approval-status" }, "PENDING APPROVAL"), + ), + reviewSummary ? el("div", { class: "approval-review-note" }, + el("b", {}, "SELF-REVIEW "), shortText(reviewSummary, 260)) : null, + el("div", { class: "approval-artifacts" }, artifacts), + el("div", { class: "approval-review-foot" }, + el("span", {}, nextStage + ? `Approval unlocks ${humanize(nextStage.name)}.` + : "This is the final approval gate."), + el("button", { type: "button", onclick: () => toggleDrawer(awaiting.name) }, "OPEN FULL ARTIFACT"), + ), + ); +} + function openScriptModal() { const script = state && state.artifacts.script; if (!script) return; @@ -799,6 +1070,8 @@ function render() { if (noState) app.append(noState); const main = el("div", { class: "main-col" }); + const approvalReview = renderApprovalReview(s); + if (approvalReview) main.append(approvalReview); const script = renderScriptCard(s); if (script) main.append(script); const aside = el("aside", {}); @@ -813,11 +1086,12 @@ function render() { const found = renderFoundMedia(s); const renders = renderRenders(s); - if (script || decisions || activity) { + if (approvalReview || script || decisions || activity) { for (const section of [storyboard, found, renders]) { if (section) main.append(section); } - app.append(el("div", { class: "board" }, main, aside)); + const hasAside = Boolean(decisions || activity); + app.append(el("div", { class: `board${hasAside ? "" : " solo"}` }, main, hasAside ? aside : null)); } else { for (const section of [storyboard, found, renders]) { if (section) app.append(section); @@ -830,6 +1104,9 @@ function render() { function normalize(s) { s.pipeline = s.pipeline || { pipeline_type: "unknown", stages: [], known: false }; s.stages = Array.isArray(s.stages) ? s.stages : []; + for (const stage of s.stages) { + stage.produces = Array.isArray(stage.produces) ? stage.produces : []; + } s.artifacts = s.artifacts || {}; s.media = s.media || {}; s.media.renders = Array.isArray(s.media.renders) ? s.media.renders : []; diff --git a/tests/backlot/test_state.py b/tests/backlot/test_state.py index 86fbce9b..000123c2 100644 --- a/tests/backlot/test_state.py +++ b/tests/backlot/test_state.py @@ -91,6 +91,9 @@ class TestBoardState: assert sc2["visual"]["exists"] is False # missing file flagged script_stage = next(x for x in s["stages"] if x["name"] == "script") assert script_stage["status"] == "completed" + assert script_stage["produces"] == ["script"] + proposal_stage = next(x for x in s["stages"] if x["name"] == "proposal") + assert proposal_stage["produces"] == ["proposal_packet", "decision_log"] def test_gate_skip_detection(self, projects_root): p = _make_project(projects_root, "sneaky") diff --git a/tests/backlot/test_ui_bug_bash.py b/tests/backlot/test_ui_bug_bash.py index 16ead774..8f5cf3e5 100644 --- a/tests/backlot/test_ui_bug_bash.py +++ b/tests/backlot/test_ui_bug_bash.py @@ -10,16 +10,102 @@ import urllib.request import pytest +from lib.checkpoint import init_project, write_checkpoint from scripts import backlot_screenshot_stage +from tests.contracts.test_phase0_contracts import sample_artifact pytest.importorskip("playwright.sync_api") from playwright.sync_api import sync_playwright # noqa: E402 +APPROVAL_CASES = [ + ("gate-research", "framework-smoke", "research", "research_brief", "Test Topic"), + ("gate-idea", "hybrid", "idea", "brief", "Did you know?"), + ("gate-proposal", "cinematic", "proposal", "proposal_packet", "The Surprising Truth About X"), + ("gate-script", "cinematic", "script", "script", "Hello world"), + ("gate-scene-plan", "cinematic", "scene_plan", "scene_plan", "Host on camera"), + ("gate-assets", "cinematic", "assets", "asset_manifest", "asset-1"), + ("gate-edit", "documentary-montage", "edit", "edit_decisions", "cut-1"), + ("gate-compose", "cinematic", "compose", "render_report", "renders/output.mp4"), + ("gate-publish", "cinematic", "publish", "publish_log", "youtube"), +] + + +def _build_approval_projects() -> None: + root = backlot_screenshot_stage.STAGE_DIR + for project_id, pipeline_type, stage, artifact_name, _visible_text in APPROVAL_CASES: + artifact = sample_artifact(artifact_name) + if artifact_name == "edit_decisions": + artifact["render_runtime"] = "ffmpeg" + review_summary = ( + { + "critical": 0, + "suggestions": 1, + "nitpicks": 0, + "review_focus_met": "9/9", + "schema_validation": "proposal_packet PASS", + } + if stage == "proposal" + else "Artifact is ready for human review." + ) + init_project( + project_id, + title=f"Approval fixture: {stage}", + pipeline_type=pipeline_type, + pipeline_dir=root, + ) + write_checkpoint( + root, + project_id, + stage, + "awaiting_human", + {artifact_name: artifact}, + pipeline_type=pipeline_type, + review={ + "round": 1, + "decision": "pass", + "critical": 0, + "suggestions": 1, + "nitpicks": 0, + "summary": review_summary, + }, + ) + + # A manifest-declared custom stage/artifact proves the fallback is driven + # by the stage contract rather than a hardcoded canonical-stage list. + init_project( + "gate-character-design", + title="Approval fixture: character design", + pipeline_type="character-animation", + pipeline_dir=root, + ) + write_checkpoint( + root, + "gate-character-design", + "character_design", + "awaiting_human", + {"character_design": { + "version": "1.0", + "characters": [{ + "id": "ada", + "display_name": "Ada", + "role": "explorer", + "body_type": "round", + "style": "flat graphic", + "silhouette_notes": "Round explorer with a bright orange field jacket", + "required_emotions": ["curious"], + "required_actions": ["wave"], + }], + }}, + pipeline_type="character-animation", + ) + + @pytest.fixture(scope="module") def staged_backlot_server(): backlot_screenshot_stage.build_stage() + _build_approval_projects() port = 4897 env = dict(os.environ) env["OPENMONTAGE_PROJECTS_DIR"] = str(backlot_screenshot_stage.STAGE_DIR) @@ -57,6 +143,8 @@ def test_project_pages_fit_mobile_and_tablet_widths(staged_backlot_server): "/p/the-slow-orchard?static=1", "/p/the-last-lighthouse?static=1", "/p/paper-boats?static=1", + "/p/gate-proposal?static=1", + "/p/gate-character-design?static=1", ] viewports = [ {"width": 390, "height": 844}, @@ -108,3 +196,69 @@ def test_static_navigation_invalid_route_and_active_takes(staged_backlot_server) assert page.locator(".takes .tk.active").count() >= 1 finally: browser.close() + + +@pytest.mark.parametrize( + ("project_id", "_pipeline_type", "stage", "artifact_name", "visible_text"), + APPROVAL_CASES, +) +def test_every_canonical_gate_promotes_its_artifact_before_approval( + staged_backlot_server, + project_id, + _pipeline_type, + stage, + artifact_name, + visible_text, +): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + try: + page.goto( + staged_backlot_server + f"/p/{project_id}?static=1", + wait_until="networkidle", + ) + review = page.locator(f'.approval-review[data-stage="{stage}"]') + assert review.is_visible() + assert review.get_by_text("PENDING APPROVAL", exact=True).is_visible() + assert "[object Object]" not in review.inner_text() + artifact = review.locator(f'[data-artifact="{artifact_name}"]') + assert artifact.is_visible() + assert visible_text in artifact.inner_text() + + review.get_by_role("button", name="OPEN FULL ARTIFACT").click() + assert page.locator(".drawer").is_visible() + assert visible_text in page.locator(".drawer").inner_text() + finally: + browser.close() + + +def test_script_gate_keeps_script_visible_and_marks_pending_approval(staged_backlot_server): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + try: + page.goto(staged_backlot_server + "/p/gate-script?static=1", wait_until="networkidle") + assert page.locator(".script-card").is_visible() + assert page.locator(".script-pending").inner_text() == "PENDING APPROVAL" + finally: + browser.close() + + +def test_manifest_declared_custom_gate_uses_generic_review_fallback(staged_backlot_server): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + try: + page.goto( + staged_backlot_server + "/p/gate-character-design?static=1", + wait_until="networkidle", + ) + review = page.locator('.approval-review[data-stage="character_design"]') + assert review.is_visible() + artifact = review.locator('[data-artifact="character_design"]') + assert artifact.is_visible() + assert "Ada" in artifact.inner_text() + assert "Round explorer" in artifact.inner_text() + finally: + browser.close()