From f633b5f428b9be9a2afecba851dfddd101619756 Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 10 Jul 2026 23:39:33 -0700 Subject: [PATCH] Improve Backlot light theme --- backlot/server.py | 36 ++++- backlot/ui/board.css | 343 ++++++++++++++++++++++++++++++++++++++++-- backlot/ui/board.js | 53 +++++-- backlot/ui/library.js | 27 ++++ 4 files changed, 431 insertions(+), 28 deletions(-) diff --git a/backlot/server.py b/backlot/server.py index f2220d14..7e40e8fc 100644 --- a/backlot/server.py +++ b/backlot/server.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state, summarize_project @@ -29,6 +29,16 @@ _IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"} SSE_HEARTBEAT_SECONDS = 15 +def _ui_html(name: str, assets: tuple[str, ...]) -> HTMLResponse: + html = (UI_DIR / name).read_text(encoding="utf-8") + for asset in assets: + path = UI_DIR / asset + if path.is_file(): + version = str(int(path.stat().st_mtime)) + html = html.replace(f"/ui/{asset}", f"/ui/{asset}?v={version}") + return HTMLResponse(html) + + class ChangeHub: """Fan-out of project-change notifications to SSE subscribers. @@ -263,20 +273,32 @@ def create_app() -> FastAPI: # ---- UI ------------------------------------------------------------ @app.get("/p/{project_id}") - async def board_page(project_id: str) -> FileResponse: - return FileResponse(UI_DIR / "board.html") + async def board_page(project_id: str) -> HTMLResponse: + return _ui_html("board.html", ("board.css", "board.js")) @app.get("/p/{project_path:path}") - async def board_page_path(project_path: str) -> FileResponse: - return FileResponse(UI_DIR / "board.html") + async def board_page_path(project_path: str) -> HTMLResponse: + return _ui_html("board.html", ("board.css", "board.js")) @app.get("/") - async def library_page() -> FileResponse: - return FileResponse(UI_DIR / "index.html") + async def library_page() -> HTMLResponse: + return _ui_html("index.html", ("board.css", "library.js")) if UI_DIR.is_dir(): app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui") + # The board is a long-lived SPA: a tab keeps running whatever board.js it + # loaded, and browsers heuristically cache /ui assets. no-cache forces a + # conditional revalidation (cheap 304 via ETag) on every load so UI fixes + # show up on a plain refresh. Media/thumb responses keep normal caching. + @app.middleware("http") + async def ui_no_cache(request, call_next): + response = await call_next(request) + path = request.url.path + if path == "/" or path.startswith("/ui") or path.startswith("/p/"): + response.headers["Cache-Control"] = "no-cache" + return response + return app diff --git a/backlot/ui/board.css b/backlot/ui/board.css index e4de79ff..1b017f8e 100644 --- a/backlot/ui/board.css +++ b/backlot/ui/board.css @@ -26,6 +26,17 @@ --cream-shade: #e5d9be; --cream-ink: #29231a; --cream-ink-2: #6b5f4a; + --bg-vignette: radial-gradient(1200px 600px at 50% -100px, #111116 0%, var(--bg) 70%); + --grain-opacity: .035; + --scroll-thumb: #26262e; + --scroll-thumb-hover: #34343e; + --clapper-a: #2c2c33; + --clapper-b: #101013; + --script-shadow: 0 18px 50px -18px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) inset; + --modal-backdrop: rgba(5,5,7,.82); + --media-bg: #000; + --textcard-bg: #0d0d10; + --badge-bg: rgba(8,8,10,.72); --sans: 'Inter', -apple-system, sans-serif; --mono: 'JetBrains Mono', ui-monospace, monospace; --screenplay: 'Courier Prime', 'Courier New', monospace; @@ -35,13 +46,52 @@ --fs-scale: 1.16; } +:root[data-theme="light"] { + color-scheme: light; + --bg: #efe4c9; + --surface: #fff9ec; + --surface-2: #f7edda; + --surface-3: #ead8b5; + --border: #d7c291; + --border-soft: #e7d7b1; + --text: #2c2419; + --text-2: #766750; + --text-3: #a39170; + --amber: #b8522d; + --amber-dim: rgba(184, 82, 45, 0.12); + --green: #2f7b4e; + --green-dim: rgba(47, 123, 78, 0.12); + --red: #b84a3b; + --red-dim: rgba(184, 74, 59, 0.11); + --blue: #3f6f8f; + --cream: #fff1c8; + --cream-shade: #ead39d; + --cream-ink: #2b251b; + --cream-ink-2: #806f50; + --bg-vignette: + radial-gradient(1200px 560px at 50% -120px, rgba(255,248,224,.8), transparent 72%), + linear-gradient(180deg, rgba(255,248,229,.96) 0%, rgba(244,230,195,.94) 54%, rgba(232,213,171,.98) 100%), + repeating-linear-gradient(90deg, rgba(116,83,41,.04) 0 1px, transparent 1px 34px), + repeating-linear-gradient(0deg, rgba(116,83,41,.032) 0 1px, transparent 1px 28px); + --grain-opacity: .025; + --scroll-thumb: #c5ad78; + --scroll-thumb-hover: #9b7b47; + --clapper-a: #2f2619; + --clapper-b: #d9a836; + --script-shadow: 0 34px 80px -48px rgba(93,68,35,.78), 0 1px 0 rgba(255,252,238,.86) inset; + --modal-backdrop: rgba(52,38,22,.42); + --media-bg: #201a13; + --textcard-bg: #2b2419; + --badge-bg: rgba(42,31,20,.78); +} + * { margin: 0; padding: 0; box-sizing: border-box; } html { color-scheme: dark; } ::-webkit-scrollbar { width: 10px; height: 8px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #26262e; border-radius: 6px; border: 2px solid var(--bg); } -::-webkit-scrollbar-thumb:hover { background: #34343e; } +::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: 6px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); } body { background: var(--bg); @@ -51,7 +101,7 @@ body { line-height: 1.5; min-height: 100vh; /* faint vignette so media pops */ - background-image: radial-gradient(1200px 600px at 50% -100px, #111116 0%, var(--bg) 70%); + background-image: var(--bg-vignette); } .wrap { max-width: 1440px; margin: 0 auto; padding: 0 28px 80px; } @@ -60,7 +110,7 @@ body { body::after { content: ''; position: fixed; inset: -50%; pointer-events: none; z-index: 90; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); - opacity: .035; animation: grain 1.2s steps(4) infinite; + opacity: var(--grain-opacity); animation: grain 1.2s steps(4) infinite; } @keyframes grain { 0%, 100% { transform: translate(0,0); } @@ -101,7 +151,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 } .clapper { width: 34px; height: 26px; border-radius: 4px; flex: none; - background: repeating-linear-gradient(-45deg, #2c2c33 0 6px, #101013 6px 12px); + background: repeating-linear-gradient(-45deg, var(--clapper-a) 0 6px, var(--clapper-b) 6px 12px); border: 1px solid var(--border); } .slate h1 { @@ -143,6 +193,256 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 .cost .bar i.warn { background: var(--amber); } .cost .label { font-size: calc(10px * var(--fs-scale)); color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; } +.theme-toggle { + width: 34px; + height: 34px; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid var(--border); + color: var(--text-2); + background: + radial-gradient(circle at 35% 25%, rgba(255,255,255,.16), transparent 42%), + var(--surface-2); + cursor: pointer; + transition: transform .16s ease, border-color .16s ease, color .16s ease, background .16s ease; +} +.theme-toggle:hover { + color: var(--amber); + border-color: rgba(240,168,60,.42); + transform: translateY(-1px); +} +.theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; } +.theme-toggle-icon { font-size: calc(15px * var(--fs-scale)); line-height: 1; transform: translateY(-.5px); } + +:root[data-theme="light"] .theme-toggle { + background: + radial-gradient(circle at 35% 24%, rgba(255,255,255,.9), transparent 42%), + linear-gradient(145deg, #ffffff, #e9eef6); + box-shadow: 0 10px 22px -18px rgba(33,45,70,.75); +} +:root[data-theme="light"] body { + background-size: auto, auto, 34px 34px, 28px 28px; + background-position: 0 0, 0 0, 12px 8px, 10px 6px; +} +:root[data-theme="light"] .wrap { + max-width: 1480px; +} +:root[data-theme="light"] .slate { + margin: 22px 0 0; + padding: 22px 0 26px; + border: none; + background: transparent; + box-shadow: none; +} +:root[data-theme="light"] .slate h1 { + color: #2c2419; + font-size: calc(24px * var(--fs-scale)); + letter-spacing: .18em; + text-shadow: 0 1px 0 rgba(255,247,222,.75); +} +:root[data-theme="light"] .slate .wordmark { + color: #aa9368; + font-weight: 700; +} +:root[data-theme="light"] .chip { + background: rgba(236,216,169,.46); + border-color: #d8c391; + color: #746144; + box-shadow: 0 1px 0 rgba(255,248,224,.86) inset; +} +:root[data-theme="light"] .live.idle .dot { + background: var(--green); + box-shadow: 0 0 0 4px rgba(47,123,78,.12); +} +:root[data-theme="light"] .live.idle { + color: #8c7958; +} +:root[data-theme="light"] .cost .bar { + background: #dfc994; +} +:root[data-theme="light"] .rail { + margin: 24px 0 28px; + padding: 32px 28px 30px; + border: 1px solid rgba(214,193,142,.9); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255,250,235,.82), rgba(248,237,211,.7)), + repeating-linear-gradient(90deg, transparent 0 52px, rgba(159,122,61,.055) 52px 53px); + box-shadow: 0 24px 55px -42px rgba(100,75,37,.7); +} +:root[data-theme="light"] .panel, +:root[data-theme="light"] .drawer, +:root[data-theme="light"] .replay-bar, +:root[data-theme="light"] .lib-card { + border-color: rgba(214,193,142,.9); + box-shadow: + 0 24px 60px -44px rgba(99,73,35,.72), + 0 1px 0 rgba(255,249,228,.82) inset; +} +:root[data-theme="light"] .panel { + background: + repeating-linear-gradient(0deg, rgba(112,84,42,.025) 0 1px, transparent 1px 30px), + linear-gradient(180deg, rgba(255,250,236,.96), rgba(249,239,214,.92)), + var(--surface); +} +:root[data-theme="light"] .panel-head, +:root[data-theme="light"] .drawer .drawer-head { + background: + linear-gradient(90deg, rgba(255,249,230,.96), rgba(245,232,199,.72)), + linear-gradient(180deg, rgba(184,82,45,.055), transparent); +} +:root[data-theme="light"] .panel-head h2, +:root[data-theme="light"] .section-title { + color: #7c6340; +} +:root[data-theme="light"] .stage .node { + width: 34px; + height: 34px; + border-width: 2px; + background: linear-gradient(180deg, #fff8e7, #ead8b5); + box-shadow: 0 12px 24px -18px rgba(64,47,23,.88); +} +:root[data-theme="light"] .stage .line { + top: 17px; + left: calc(-50% + 17px); + right: calc(50% + 17px); + border-top: 1px dotted rgba(47,123,78,.24); + background: transparent; +} +:root[data-theme="light"] .stage:hover { background: rgba(184,82,45,.045); } +:root[data-theme="light"] .stage.done .node { + background: linear-gradient(180deg, #f7fff5, #dcefdc); + border-color: #3f8f5a; + color: var(--green); +} +:root[data-theme="light"] .stage.done .line { + border-color: rgba(47,123,78,.22); + background: transparent; +} +:root[data-theme="light"] .stage.active .line, +:root[data-theme="light"] .stage.await .line { + border-color: rgba(184,82,45,.36); + background: transparent; +} +:root[data-theme="light"] .stage .name { + color: #6a5537; + font-weight: 700; +} +:root[data-theme="light"] .stage .sub { + color: #9d875f; +} +:root[data-theme="light"] .stage.done .sub { + color: #8a7858; +} +:root[data-theme="light"] .script-card { + background: + linear-gradient(178deg, rgba(255,250,226,.28), transparent 38%), + repeating-linear-gradient(0deg, rgba(93,72,35,.036) 0 1px, transparent 1px 24px), + linear-gradient(178deg, var(--cream) 0%, var(--cream-shade) 132%); + border: 1px solid rgba(198,158,84,.58); + box-shadow: + 0 38px 90px -50px rgba(88,61,24,.78), + 0 1px 0 rgba(255,251,232,.84) inset; +} +:root[data-theme="light"] .script-card::after { + background: rgba(112,84,40,.16); +} +:root[data-theme="light"] .script-preview::before { + background: linear-gradient(to bottom, rgba(255,241,200,0), rgba(234,211,157,.96) 74%); +} +:root[data-theme="light"] .script-approved { + background: rgba(242,253,238,.66); + color: #3f8f5a; + border-color: #3f8f5a; +} +:root[data-theme="light"] .filmstrip { + background: + radial-gradient(circle 3.5px, #ceb982 97%, transparent) 0 6px / 26px 10px repeat-x, + radial-gradient(circle 3.5px, #ceb982 97%, transparent) 0 calc(100% - 16px) / 26px 10px repeat-x, + linear-gradient(180deg, rgba(255,246,222,.72), rgba(237,220,181,.6)); + border-radius: 14px; + border: 1px solid rgba(214,193,142,.84); + scrollbar-color: #c5ad78 transparent; +} +:root[data-theme="light"] .scene-card .sc-slate { + color: #9a845e; +} +:root[data-theme="light"] .scene-card .sc-slate .num { + color: #5f4b30; +} +:root[data-theme="light"] .thumb { + border-color: rgba(181,143,78,.75); + box-shadow: 0 16px 34px -24px rgba(82,57,25,.72); +} +:root[data-theme="light"] .thumb.generating { + background: linear-gradient(100deg, #f7ecd2 32%, #ead8b5 48%, #f7ecd2 64%); +} +:root[data-theme="light"] .thumb.spec { border-color: #d8bf83; } +:root[data-theme="light"] .thumb.spec .spec-shot, +:root[data-theme="light"] .empty .big, +:root[data-theme="light"] .lib-poster .lp-txt { + color: #ad9469; +} +:root[data-theme="light"] .wave i { background: #d8c391; } +:root[data-theme="light"] .wave.played i { background: #aa9368; } +:root[data-theme="light"] .drawer pre { background: #fff5dc; } +:root[data-theme="light"] .replay-bar { + background: + repeating-linear-gradient(90deg, transparent 0 19px, rgba(171,139,80,.24) 19px 20px), + linear-gradient(90deg, rgba(255,250,235,.9), rgba(246,232,199,.78)); +} +:root[data-theme="light"] .replay-bar .rp-btn { + color: #fff9ed; + background: linear-gradient(180deg, #c15a34, #a84224); + border-color: rgba(120,55,31,.42); + box-shadow: 0 10px 22px -14px rgba(126,56,28,.9), 0 1px 0 rgba(255,225,197,.38) inset; +} +:root[data-theme="light"] .replay-bar .rp-btn:hover { + color: #fff; + border-color: rgba(120,55,31,.55); + background: linear-gradient(180deg, #cc6239, #a84224); +} +:root[data-theme="light"] .replay-bar input[type=range] { + accent-color: var(--amber); +} +:root[data-theme="light"] .render-hero { + box-shadow: 0 22px 55px -38px rgba(64,45,21,.68); +} +:root[data-theme="light"] .lib-card:hover { + border-color: #b9894e; + box-shadow: + 0 28px 68px -44px rgba(93,64,28,.8), + 0 1px 0 rgba(255,249,228,.82) inset; +} +:root[data-theme="light"] .decision { + border-color: rgba(214,193,142,.72); +} +:root[data-theme="light"] .decision .d-cat { + color: #b8522d; +} +:root[data-theme="light"] .decision .d-pick { + color: #312719; +} +:root[data-theme="light"] .decision .d-pick .arrow { + color: var(--green); +} +:root[data-theme="light"] .act-row { + border-color: rgba(214,193,142,.58); +} +:root[data-theme="light"] .clapper { + width: 46px; + height: 46px; + border-radius: 10px; + background: + linear-gradient(135deg, transparent 39%, #d9a836 40% 44%, transparent 45%), + linear-gradient(180deg, #352a1a, #251d13); + border-color: rgba(66,49,24,.32); + box-shadow: 0 14px 28px -18px rgba(58,42,20,.8); +} + /* ---------- stage rail ---------- */ .rail { display: flex; align-items: flex-start; padding: 26px 0 22px; } .stage { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; min-width: 0; } @@ -196,7 +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; } -.main-col { min-width: 0; } +.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; } .panel + .panel { margin-top: 18px; } @@ -217,10 +517,26 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 max-width: 700px; /* screenplay pages are narrow — paper on a dark desk */ margin: 0 auto; font-family: var(--screenplay); - box-shadow: 0 18px 50px -18px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) inset; + box-shadow: var(--script-shadow); position: relative; cursor: pointer; } +.script-preview { + max-height: min(430px, calc(100vh - 360px)); + min-height: 320px; + overflow: hidden; +} +.script-preview::before { + content: ''; + position: absolute; + left: 0; + right: 8px; + bottom: 0; + height: 88px; + pointer-events: none; + background: linear-gradient(to bottom, rgba(242,233,213,0), var(--cream-shade) 72%); + z-index: 1; +} .script-card::after { /* page edge */ content: ''; position: absolute; right: 7px; top: 7px; bottom: 7px; width: 1px; background: rgba(0,0,0,.07); @@ -247,6 +563,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 .script-card .sp-expand { position: absolute; right: 16px; bottom: 12px; font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em; + z-index: 2; } .script-approved { position: absolute; top: 20px; right: 26px; @@ -319,7 +636,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 .thumb .badge { position: absolute; left: 7px; bottom: 7px; font-family: var(--mono); font-size: calc(9px * var(--fs-scale)); letter-spacing: .06em; - background: rgba(8,8,10,.72); color: var(--text-2); + background: var(--badge-bg); color: #eef2f8; padding: 2px 7px; border-radius: 3px; backdrop-filter: blur(4px); } .thumb .play { @@ -362,7 +679,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 .thumb.missing .spec-desc { color: var(--amber); -webkit-line-clamp: 2; } /* text-card scene (typographic placeholder) */ -.thumb.textcard { display: flex; align-items: center; justify-content: center; background: #0d0d10; } +.thumb.textcard { display: flex; align-items: center; justify-content: center; background: var(--textcard-bg); } .thumb.textcard .tc-copy { font-family: var(--mono); font-weight: 500; text-align: center; letter-spacing: .2em; font-size: calc(11px * var(--fs-scale)); color: #d8d8de; padding: 0 10px; @@ -413,7 +730,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2 /* ---------- modal ---------- */ .modal-bg { - position: fixed; inset: 0; background: rgba(5,5,7,.82); backdrop-filter: blur(6px); + position: fixed; inset: 0; background: var(--modal-backdrop); backdrop-filter: blur(6px); display: none; align-items: flex-start; justify-content: center; overflow-y: auto; padding: 48px 20px; z-index: 50; } @@ -505,8 +822,10 @@ a.backlink:hover { color: var(--text-2); } } /* render section */ -.render-hero { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #000; } -.render-hero video { width: 100%; display: block; max-height: 560px; } +.render-hero { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: var(--media-bg); display: flex; justify-content: center; } +/* natural width up to the container: a 9:16 render shows as a centered + portrait frame instead of stretching into a letterboxed landscape box */ +.render-hero video { display: block; width: auto; max-width: 100%; max-height: 560px; } .render-meta { font-family: var(--mono); font-size: calc(10.5px * var(--fs-scale)); color: var(--text-3); padding: 8px 2px; display: flex; gap: 14px; flex-wrap: wrap; } .render-meta .v { color: var(--text-2); cursor: pointer; } .render-meta .v.active { color: var(--amber); } diff --git a/backlot/ui/board.js b/backlot/ui/board.js index f8ed62be..edf82dd4 100644 --- a/backlot/ui/board.js +++ b/backlot/ui/board.js @@ -12,12 +12,37 @@ const app = document.getElementById("app"); const modal = document.getElementById("modal"); const player = document.getElementById("player"); +const THEME_KEY = "backlot.theme"; +let currentTheme = localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; let state = null; let selectedStage = null; // stage drawer open for this stage name let activeRender = 0; let replay = null; // {t0, t1, t, playing} — replay mode when non-null let firstPaint = true; +function applyTheme(theme) { + currentTheme = theme === "light" ? "light" : "dark"; + document.documentElement.dataset.theme = currentTheme; + localStorage.setItem(THEME_KEY, currentTheme); +} + +function renderThemeToggle() { + const next = currentTheme === "light" ? "dark" : "light"; + return el("button", { + class: "theme-toggle", + type: "button", + title: `Switch to ${next} theme`, + "aria-label": `Switch to ${next} theme`, + "aria-pressed": currentTheme === "light" ? "true" : "false", + onclick: () => { + applyTheme(next); + render(); + }, + }, el("span", { class: "theme-toggle-icon", "aria-hidden": "true" }, currentTheme === "light" ? "☾" : "☀")); +} + +applyTheme(currentTheme); + // --------------------------------------------------------------------------- // header slate // --------------------------------------------------------------------------- @@ -72,6 +97,7 @@ function renderSlate(s) { ), ...chips, el("div", { class: "spacer" }), + renderThemeToggle(), liveEl, cost, ); @@ -219,7 +245,7 @@ function renderScriptCard(s) { const scriptStage = s.stages.find((x) => x.name === "script"); const approved = scriptStage && scriptStage.status === "completed"; - const card = el("div", { class: "script-card", title: "Click to expand full script", onclick: openScriptModal }, + 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, el("div", { class: "sp-title" }, script.title || s.title), el("div", { class: "sp-meta" }, @@ -528,7 +554,10 @@ function renderRenders(s) { // watch: carry playback position/state over to the recreated element. const prev = document.querySelector(".render-hero video"); const src = mediaURL(s.project_id, current.path); - const video = el("video", { src, controls: "", preload: "none" }); + // preload="metadata" gives the element its intrinsic aspect ratio (and a + // poster frame) before playback — without it a portrait 9:16 render sits + // in a letterboxed 100%-wide black box that reads as landscape. + const video = el("video", { src, controls: "", preload: "metadata" }); // Click the frame to start playback (controls handle pause/scrub) — the // big player was inert to a click on the picture itself. video.addEventListener("click", () => { if (video.paused) video.play().catch(() => {}); }); @@ -778,16 +807,22 @@ function render() { if (decisions) aside.append(decisions); if (activity) aside.append(activity); - if (script || decisions || activity) { - app.append(el("div", { class: "board" }, main, aside)); - } - + // Media sections live INSIDE the main column so a tall decisions rail + // never pushes them below the fold — the column flows beside the rail. const storyboard = renderStoryboard(s); - if (storyboard) app.append(storyboard); const found = renderFoundMedia(s); - if (found) app.append(found); const renders = renderRenders(s); - if (renders) app.append(renders); + + if (script || decisions || activity) { + for (const section of [storyboard, found, renders]) { + if (section) main.append(section); + } + app.append(el("div", { class: "board" }, main, aside)); + } else { + for (const section of [storyboard, found, renders]) { + if (section) app.append(section); + } + } } // Defensive normalization (F-02): the server contract guarantees these diff --git a/backlot/ui/library.js b/backlot/ui/library.js index 5852f866..3ae3a82c 100644 --- a/backlot/ui/library.js +++ b/backlot/ui/library.js @@ -1,6 +1,33 @@ import { el, fmtAgo, getJSON, subscribe, thumbURL } from "/ui/lib.js"; const grid = document.getElementById("grid"); +const THEME_KEY = "backlot.theme"; +let currentTheme = localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; + +function applyTheme(theme) { + currentTheme = theme === "light" ? "light" : "dark"; + document.documentElement.dataset.theme = currentTheme; + localStorage.setItem(THEME_KEY, currentTheme); +} + +function renderThemeToggle() { + const next = currentTheme === "light" ? "dark" : "light"; + return el("button", { + class: "theme-toggle", + type: "button", + title: `Switch to ${next} theme`, + "aria-label": `Switch to ${next} theme`, + "aria-pressed": currentTheme === "light" ? "true" : "false", + onclick: () => { + applyTheme(next); + const replacement = renderThemeToggle(); + document.querySelector(".theme-toggle").replaceWith(replacement); + }, + }, el("span", { class: "theme-toggle-icon", "aria-hidden": "true" }, currentTheme === "light" ? "☾" : "☀")); +} + +applyTheme(currentTheme); +document.getElementById("liveBadge").before(renderThemeToggle()); function miniRail(states) { const rail = el("div", { class: "mini-rail" });