// frontend/js/stage.js — Lightloom "La Sala" · the CONTROLLER (LL.controller) // // IMPORTANT: this file does NOT define LL.stage. The WebGL renderer lives in // frontend/js/stage-gl.js and owns window.LL.stage. Here we own LL.controller: // the screen state machine + the SSE orchestration that drives stage / slate / // subtitles / hud from the events streamed by LL.api.streamRecital. // // State machine (docs/01 §6.2): // Lobby -> Listening/Processing -> Revealing -> Theater // ...with a soft ErrorSoft branch when a beat is "fogged". // // Vanilla ES module. No frameworks, no build step. We read/write only the ids // defined by the DOM contract in index.html and call sibling modules through // the shared window.LL namespace. import "./api.js"; // ensures LL.api exists before the controller wires up const LL = (window.LL = window.LL || {}); // --------------------------------------------------------------------------- // Bilingual micro-copy. Subtitles come from the poem itself; these are only the // system/toast strings the controller emits. Kept here so the controller is // self-contained even if a given data-es/data-en node isn't present. // --------------------------------------------------------------------------- const COPY = { warming: { es: "Encendiendo el proyector…", en: "Warming up the projector…" }, ready: { es: "Rodando — te escucho.", en: "Rolling — I'm listening." }, directing: { es: "La directora decide el plano…", en: "The director calls the shot…" }, painting: { es: "Pintando el plano…", en: "Painting the shot…" }, fogged: { es: "El plano se veló; seguimos rodando.", en: "The shot got fogged; rolling on." }, empty: { es: "Pega un poema antes de rodar.", en: "Paste a poem before rolling." }, listening: { es: "Te escucho… habla y pulsa para parar.", en: "Listening… speak, then click to stop." }, transcribing: { es: "Transcribiendo tu voz…", en: "Transcribing your voice…" }, micDenied: { es: "No pude usar el micrófono. Escribe el poema y pulsa Rodar.", en: "Couldn't use the mic. Type your poem and hit Roll." }, noSpeech: { es: "No escuché nada. Inténtalo otra vez o escribe el poema.", en: "I didn't catch that. Try again or type the poem." }, connect: { es: "No se pudo abrir la sala. Reintenta en un momento.", en: "Couldn't open the theater. Try again in a moment.", }, stream: { es: "Se cortó la proyección. Reintenta.", en: "The projection dropped. Try again.", }, threeFogged: { es: "Tres planos seguidos se velaron — prueba el Showcase.", en: "Three shots fogged in a row — try the Showcase.", }, }; // Micro-stage chip names (the #stage-bar lights these in order). Maps the // backend stage -> the human label LL.slate.setStage shows (bilingual; the // controller picks the current language). English-default for the hackathon. const STAGE_CHIP = { warming: { es: "OYENDO", en: "LISTENING" }, ready: { es: "OYENDO", en: "LISTENING" }, directing: { es: "DIRIGIENDO", en: "DIRECTING" }, directed: { es: "DIRIGIENDO", en: "DIRECTING" }, painting: { es: "PINTANDO EL MUNDO", en: "PAINTING THE WORLD" }, painted: { es: "REVELANDO", en: "REVEALING" }, depth: { es: "REVELANDO", en: "REVEALING" }, }; // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- const controller = { // --- runtime state --- state: "lobby", // lobby | processing | revealing | theater | error lang: "en", // current UI language (English-default for the hackathon); synced to + localStorage reducedMotion: false, // body.reduced-motion mirror running: false, // a recital stream is live _shotIndex: 0, // 1-based plano counter across the whole film _sceneId: 0, // last scene id from a 'directed' event _consecutiveFogged: 0, // for the "three in a row" hint _pending: new Map(), // beat -> { image?, depth?, shot?, cameraMove?, transition? } _revealChain: Promise.resolve(), // serializes revealBeat() so beats show in order _toastTimer: null, /** * Wire the DOM and restore persisted preferences. Safe to call once on load. * Tolerates missing optional nodes so a partial index.html still boots. */ init() { this._restorePrefs(); this._wireLobby(); this._wireControls(); this._wireLangToggle(); this._initStageRenderer(); this._initRecorder(); this._applyLang(this.lang); // paint all data-es/data-en nodes once this.show("lobby"); return this; }, // ---- screen state machine ------------------------------------------------- /** * Activate exactly one screen section (.is-active). Sections are #lobby/#set/ * #theater/#showcase/#about. #set hosts the live stage; we reveal it on start. */ show(screen) { const ids = ["lobby", "set", "theater", "showcase", "about"]; for (const id of ids) { const el = document.getElementById(id); if (el) el.classList.toggle("is-active", id === screen); } }, // ---- lobby + controls wiring --------------------------------------------- _wireLobby() { on("btn-recital", "click", () => this.startRecital()); on("btn-recital-go", "click", () => this.startRecital()); on("btn-narrate", "click", () => { // Live narration: capture the mic, transcribe (Cohere), then film the world. // If the recorder isn't available/supported, fall back to the typed recital // so the button is never dead. if (LL.recorder && LL.recorder.supported && typeof LL.recorder.toggle === "function") { LL.recorder.toggle(); } else { const ta = document.getElementById("recital-text"); if (ta && ta.value.trim()) this.startRecital(); else { this.toast(t(COPY.micDenied, this.lang)); if (ta) ta.focus(); } } }); on("btn-showcase", "click", () => this.startShowcase()); }, _wireControls() { // ⏹ Cut & watch — stop the stream and open the Theater with what we have. on("ctl-cut", "click", () => this.cut()); // 🔇 bed on/off — toggle ambient bed if the audio module is present. on("ctl-bed", "click", (e) => { const btn = e.currentTarget; const muted = btn.classList.toggle("is-muted"); btn.setAttribute("aria-pressed", String(!muted)); if (LL.audio && typeof LL.audio.setMuted === "function") LL.audio.setMuted(muted); }); // ♿ reduce motion — manual toggle, persisted; OS preference also honored. on("ctl-reduce-motion", "click", () => this.setReducedMotion(!this.reducedMotion)); const mq = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)"); if (mq) { if (mq.matches) this.setReducedMotion(true, /*fromOS*/ true); const onChange = (e) => this.setReducedMotion(e.matches, true); if (mq.addEventListener) mq.addEventListener("change", onChange); else if (mq.addListener) mq.addListener(onChange); // Safari < 14 } }, _wireLangToggle() { // Two possible entry points per the DOM contract: a lobby #lang-pill and an // in-stage #lang-toggle. Both flip ES<->EN. const flip = () => this.setLang(this.lang === "es" ? "en" : "es"); on("lang-toggle", "click", flip); on("lang-pill", "click", flip); }, _initStageRenderer() { // The immersive PANORAMA world (stage-pano.js -> LL.pano) is the stage: an // equirectangular sphere the camera lives inside. It owns #stage-canvas. If // it isn't present we fall back to the beat renderer (LL.stage / stage-gl). const canvas = document.getElementById("stage-canvas"); if (!canvas) return; const r = this._renderer(); if (r && typeof r.init === "function") { try { r.init(canvas); } catch (err) { console.error("[LL.controller] stage renderer init failed", err); } } }, /** The active stage renderer: prefer the immersive world (LL.pano), else the * beat renderer (LL.stage). Looked up at call time so load order can't matter. */ _renderer() { return LL.pano || LL.stage || null; }, /** Wire the voice recorder (LL.recorder): mic -> Cohere Transcribe -> the * transcript fills the recital and films the world. Mic state drives toasts + * a recording class on the Narrate button. No-op if the recorder is absent. */ _initRecorder() { if (!LL.recorder || typeof LL.recorder.init !== "function") return; LL.recorder.init({ getLang: () => this.lang, onState: (s) => { const btn = document.getElementById("btn-narrate"); if (btn) { btn.classList.toggle("is-recording", s === "recording"); btn.classList.toggle("is-busy", s === "transcribing"); btn.setAttribute("aria-pressed", String(s === "recording")); } if (s === "recording") this.toast(t(COPY.listening, this.lang)); else if (s === "transcribing") this.toast(t(COPY.transcribing, this.lang)); }, onTranscript: (text, error) => { const ta = document.getElementById("recital-text"); const clean = (text || "").trim(); if (clean) { if (ta) ta.value = clean; this.startRecital(); } else { const denied = error === "mic_denied" || error === "unsupported"; this.toast(t(denied ? COPY.micDenied : COPY.noSpeech, this.lang)); if (ta) ta.focus(); } }, }); }, // ---- the main flow: start a recital --------------------------------------- /** * Read #recital-text, switch to the Set, and stream the recital. Orchestrates * every SSE event into slate / subtitles / hud / stage. Idempotent guard: * ignores re-entry while a stream is already running. */ async startRecital() { if (this.running) return; const textarea = document.getElementById("recital-text"); const text = textarea ? textarea.value : ""; if (!text || !text.trim()) { this.toast(t(COPY.empty, this.lang)); // Nudge the user to the recital input if we have it. if (textarea && typeof textarea.focus === "function") textarea.focus(); return; } this._resetFilmState(); this.running = true; this.state = "processing"; this.show("set"); this.setStageBar("warming"); if (LL.subtitles) LL.subtitles.clear && LL.subtitles.clear(); try { await LL.api.streamPanorama(text, this.lang, (ev) => this._onEvent(ev)); } catch (err) { // streamPanorama itself converts most failures into {stage:"error"} events; // this only catches truly unexpected throws. console.error("[LL.controller] streamPanorama threw", err); this.toast(t(COPY.stream, this.lang)); } finally { this.running = false; // The immersive world IS the destination — we stay in #set inhabiting it, // no separate theater handoff. The world keeps drifting until a new Roll. await this._revealChain.catch(() => {}); } }, /** ⏹ Cut: stop the GPU stream. If a world is already up, stay inside it; if * nothing has revealed yet, return to the lobby rather than a blank set. The * Showcase reel exits to the lobby (it's a reel, not a generation to keep). */ cut() { if (LL.api && typeof LL.api.cancel === "function") LL.api.cancel(); this._showcaseStop = true; this.running = false; this.setStageBar(null); if (this._showcasePlaying || !this._worldShown) { this._showcasePlaying = false; this._hideBadge(); this.show("lobby"); } }, // ---- the Showcase reel: an instant, GPU-free journey through pre-rendered // worlds (one per stanza). Everything here was produced by this same app's // /panorama endpoint — see scripts/build_showcase.py + the honest badge. ------- /** * Play the pre-rendered immersive journey. Loads the manifest of worlds and * cross-fades through them on the live sphere with each stanza as a caption, * looping until Cut. Falls back to the static grid if no manifest is present. */ async startShowcase() { if (this.running) return; const manifest = await this._loadShowcaseManifest(); if (!manifest.length) { this.show("showcase"); // graceful static-grid fallback return; } this._resetFilmState(); this.running = true; this._showcasePlaying = true; this._showcaseStop = false; this.show("set"); this.setStageBar(null); this._setLiveChrome(false); // hide the live slate + pipeline bar for the reel if (LL.subtitles && LL.subtitles.clear) LL.subtitles.clear(); this._showBadge( t({ es: "✦ SHOWCASE · pre-renderizado con esta app", en: "✦ SHOWCASE · pre-rendered with this app" }, this.lang), ); try { const dwell = this.reducedMotion ? 3400 : 7000; // Loop the reel so a judge who just watches sees the whole journey cycle. while (!this._showcaseStop) { for (const w of manifest) { if (this._showcaseStop) break; if (LL.subtitles && LL.subtitles.show) LL.subtitles.show(w.title || w.stanza); if (LL.pano && typeof LL.pano.show === "function") { await LL.pano.show({ panoramaUrl: absUrl(w.panorama), depthUrl: w.depth ? absUrl(w.depth) : null, }); } this._worldShown = true; await this._sleep(dwell); } if (this.reducedMotion) break; // don't auto-loop under reduced motion } } catch (err) { console.error("[LL.controller] showcase failed", err); } finally { this.running = false; } }, async _loadShowcaseManifest() { try { const res = await fetch("/frontend/assets/showcase/manifest.json", { cache: "no-store" }); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data.filter((w) => w && w.panorama) : []; } catch (_) { return []; } }, /** A cancellable sleep that wakes early if the showcase is stopped. */ _sleep(ms) { return new Promise((resolve) => { const start = performance.now(); const tick = () => { if (this._showcaseStop || performance.now() - start >= ms) return resolve(); requestAnimationFrame(tick); }; tick(); }); }, /** Small honest chip shown while the Showcase reel plays. */ _showBadge(text) { let el = document.getElementById("ll-showcase-badge"); if (!el) { el = document.createElement("div"); el.id = "ll-showcase-badge"; el.setAttribute("role", "note"); el.style.cssText = "position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:7;" + "font:600 12px/1.2 var(--font-ui,system-ui);letter-spacing:.08em;color:var(--ink,#f0e9da);" + "background:rgba(10,10,14,.55);border:1px solid rgba(240,233,218,.18);border-radius:999px;" + "padding:6px 14px;backdrop-filter:blur(6px);pointer-events:none;white-space:nowrap;"; const set = document.getElementById("set") || document.body; set.appendChild(el); } el.textContent = text; el.hidden = false; }, _hideBadge() { const el = document.getElementById("ll-showcase-badge"); if (el) el.hidden = true; }, /** Show/hide the live-only chrome (the Director's slate, pipeline bar, scene/ * shot counter, GPU ledger). The pre-rendered Showcase has no live pipeline, * so we hide them for a clean reel — only the brand, caption + honest badge. */ _setLiveChrome(visible) { for (const id of ["slate", "stage-bar", "hud-counter", "hud-budget"]) { const el = document.getElementById(id); if (el) el.style.visibility = visible ? "" : "hidden"; } }, // ---- SSE event orchestration ---------------------------------------------- _onEvent(ev) { if (!ev || typeof ev !== "object") return; const stage = ev.stage; // Light the matching micro-stage chip for any stage we recognize. this.setStageBar(stage); switch (stage) { case "warming": this.state = "processing"; if (LL.hud && LL.hud.setBudget) LL.hud.setBudget(0); break; case "ready": this.state = "processing"; break; case "directing": // Show the beat text as a subtitle the moment direction begins, so the // room reads while the Director decides. if (typeof ev.text === "string" && LL.subtitles && LL.subtitles.show) { LL.subtitles.show(ev.text); } break; case "directed": this._onDirected(ev); break; case "painting": // purely a micro-stage signal; chip already lit above. break; case "painted": // The immersive world's equirectangular panorama is ready. Reveal it now // (with a short grace for its depth map); never make the room wait blank. this._worldPano = absUrl(ev.panorama || ev.image); if (this._worldTimer) clearTimeout(this._worldTimer); this._worldTimer = setTimeout(() => this._revealWorld(true), 1800); this._revealWorld(false); break; case "depth": // Its depth map drives the in-sphere relief/parallax. Reveal with both. this._worldDepth = absUrl(ev.depth); this._revealWorld(false); break; case "fogged": this._onFogged(ev); break; case "done": this._onDone(ev); break; case "error": this._onError(ev); break; default: // Unknown stage — ignore gracefully (forward-compatible). break; } }, _onDirected(ev) { const shot = ev.shot || {}; this._shotIndex += 1; this._sceneId = typeof ev.scene_id === "number" ? ev.scene_id : this._sceneId; // Stash the directing decision so revealBeat() (which fires later, once the // image+depth pair arrives) gets the right camera move + refined transition. // ev.transition is the *refined* transition (continuity hard_cut->crossfade). this._stash(ev.beat, { shot, cameraMove: shot.camera_move || "static", transition: ev.transition || shot.transition || "crossfade", }); // The Slate is the hero: render the Director's decision now. if (LL.slate && LL.slate.update) { try { LL.slate.update(shot, this._sceneId, this._shotIndex); } catch (err) { console.error("[LL.controller] slate.update failed", err); } } if (LL.hud && LL.hud.setCounter) LL.hud.setCounter(this._sceneId, this._shotIndex); }, _onFogged(ev) { this._consecutiveFogged += 1; // Drop any half-collected pieces for this beat so it can't reveal later. if (typeof ev.beat === "number") this._pending.delete(ev.beat); this.toast(t(COPY.fogged, this.lang)); if (this._consecutiveFogged >= 3) { this.toast(t(COPY.threeFogged, this.lang)); } }, _onDone() { // The world is painted and we are inhabiting it on the #set canvas — no // separate theater screen. Force the reveal in case depth never arrived. this._revealWorld(true); this.setStageBar(null); }, _onError(ev) { const reason = ev && ev.error ? String(ev.error) : ""; if (reason.startsWith("empty")) this.toast(t(COPY.empty, this.lang)); else if (reason.startsWith("connect")) this.toast(t(COPY.connect, this.lang)); else this.toast(t(COPY.stream, this.lang)); // A hard error ends the run; surface whatever beats we managed to reveal. this.running = false; if (this.state !== "theater") this._maybeOpenTheater(); }, // ---- beat reveal: pair painted+depth, then drive the renderer ------------- _stash(beat, patch) { if (typeof beat !== "number") return; const cur = this._pending.get(beat) || {}; this._pending.set(beat, Object.assign(cur, patch)); }, /** * Reveal a beat once BOTH its image and depth maps are available. The two * arrive as separate events ('painted' then 'depth'); we only fire once both * are present. Reveals are serialized through _revealChain so they always play * in beat order even if events interleave. */ _tryReveal(beat) { const data = this._pending.get(beat); if (!data || !data.image || !data.depth) return; // wait for the pair this._pending.delete(beat); this._consecutiveFogged = 0; // a successful reveal breaks the fogged streak this.state = "revealing"; const job = { imageUrl: data.image, depthUrl: data.depth, cameraMove: data.cameraMove || (data.shot && data.shot.camera_move) || "static", transition: data.transition || (data.shot && data.shot.transition) || "crossfade", reducedMotion: this.reducedMotion, }; // Chain so beats reveal sequentially; swallow per-beat errors so one bad // texture upload doesn't stall the whole film. this._revealChain = this._revealChain .then(() => { if (LL.stage && typeof LL.stage.revealBeat === "function") { return LL.stage.revealBeat(job); } }) .catch((err) => console.error("[LL.controller] revealBeat failed", err)); }, /** * Reveal the immersive panorama WORLD into the sphere. Called from 'painted' * (panorama ready) and 'depth' (relief ready). Shows once, as soon as the * panorama exists and EITHER its depth has arrived OR the grace timer forced * it — so the world never blocks on a missing/slow depth map. */ _revealWorld(force) { if (this._worldShown || !this._worldPano) return; if (!this._worldDepth && !force) return; // brief grace for the depth map this._worldShown = true; if (this._worldTimer) { clearTimeout(this._worldTimer); this._worldTimer = null; } this.state = "revealing"; const pano = LL.pano; if (!pano || typeof pano.show !== "function") { console.error("[LL.controller] LL.pano renderer unavailable"); return; } this._revealChain = this._revealChain .then(() => pano.show({ panoramaUrl: this._worldPano, depthUrl: this._worldDepth || null }), ) .then(() => { this.state = "theater"; // inhabiting the world (still on the #set canvas) }) .catch((err) => console.error("[LL.controller] pano.show failed", err)); }, // ---- theater handoff ------------------------------------------------------ _maybeOpenTheater() { // Open the Theater only after every queued reveal has resolved, so the last // frame is actually on screen before the curtain. this._revealChain.then(() => this._openTheater()).catch(() => this._openTheater()); }, _openTheater() { const theaterEl = document.getElementById("theater"); if (this.state === "theater" && theaterEl && theaterEl.classList.contains("is-active")) { return; // already there } this.state = "theater"; this.setStageBar(null); // clear the micro-stage bar // Hand off to the theater module if present; otherwise just show the screen. if (LL.theater && typeof LL.theater.open === "function") { try { LL.theater.open(); } catch (err) { console.error("[LL.controller] theater.open failed", err); } } this.show("theater"); }, // ---- micro-stage bar ------------------------------------------------------ /** Light the chip for a backend stage (or clear when given a falsy stage). */ setStageBar(stage) { if (!LL.slate || typeof LL.slate.setStage !== "function") return; const pair = stage ? STAGE_CHIP[stage] : null; const chip = pair ? t(pair, this.lang) : null; if (chip || stage === null) { try { LL.slate.setStage(chip); } catch (err) { console.error("[LL.controller] slate.setStage failed", err); } } }, // ---- language + reduced motion -------------------------------------------- setLang(lang) { this.lang = lang === "en" ? "en" : "es"; try { localStorage.setItem("ll.lang", this.lang); } catch (_) {} this._applyLang(this.lang); }, /** Apply the language across BOTH i18n patterns used in the app and update pills. * Pattern A (index.html shell): sibling spans `..` * — the attribute is empty, the text is the content -> show only the matching-lang span. * Pattern B (hud.js / slate.js): a single element with valued `data-es="X" data-en="Y"` * attributes -> swap textContent. */ _applyLang(lang) { document.documentElement.setAttribute("lang", lang); document.querySelectorAll("[data-es],[data-en]").forEach((node) => { const esVal = node.getAttribute("data-es"); const enVal = node.getAttribute("data-en"); const valued = (esVal && esVal.length) || (enVal && enVal.length); if (valued) { const v = lang === "en" ? enVal : esVal; if (v) node.textContent = v; // Pattern B } else if (node.hasAttribute("data-en") && !node.hasAttribute("data-es")) { node.hidden = lang !== "en"; // Pattern A (English span) } else if (node.hasAttribute("data-es") && !node.hasAttribute("data-en")) { node.hidden = lang !== "es"; // Pattern A (Spanish span) } }); // Reflect current language on the toggle controls (inner [data-current-lang] span). const label = lang === "en" ? "EN" : "ES"; document.querySelectorAll("[data-current-lang]").forEach((el) => (el.textContent = label)); }, setReducedMotion(on, fromOS = false) { this.reducedMotion = !!on; document.body.classList.toggle("reduced-motion", this.reducedMotion); const btn = document.getElementById("ctl-reduce-motion"); if (btn) { btn.classList.toggle("is-active", this.reducedMotion); btn.setAttribute("aria-pressed", String(this.reducedMotion)); } // Persist only explicit user choices, not transient OS-driven ones. if (!fromOS) { try { localStorage.setItem("ll.reducedMotion", this.reducedMotion ? "1" : "0"); } catch (_) {} } // Let the active renderer drop parallax/drift to 0 if it exposes a setter. const r = this._renderer(); if (r && typeof r.setReducedMotion === "function") { r.setReducedMotion(this.reducedMotion); } }, _restorePrefs() { try { const savedLang = localStorage.getItem("ll.lang"); if (savedLang === "es" || savedLang === "en") this.lang = savedLang; else this.lang = (navigator.language || "es").toLowerCase().startsWith("en") ? "en" : "es"; } catch (_) { this.lang = "es"; } try { const rm = localStorage.getItem("ll.reducedMotion"); if (rm === "1") this.setReducedMotion(true); } catch (_) {} }, // ---- misc ----------------------------------------------------------------- _resetFilmState() { this._shotIndex = 0; this._sceneId = 0; this._consecutiveFogged = 0; this._pending.clear(); this._revealChain = Promise.resolve(); // Immersive-world reveal state. this._worldPano = null; this._worldDepth = null; this._worldShown = false; if (this._worldTimer) clearTimeout(this._worldTimer); this._worldTimer = null; // Stop any running Showcase reel and clear its chrome before a new run. this._showcaseStop = true; this._showcasePlaying = false; this._hideBadge(); this._setLiveChrome(true); // restore the live slate + pipeline bar }, /** Soft amber toast (#toast if present; otherwise a created element). */ toast(message) { let el = document.getElementById("toast"); if (!el) { el = document.createElement("div"); el.id = "toast"; el.className = "ll-toast"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); document.body.appendChild(el); } el.textContent = message; el.classList.add("is-visible"); if (this._toastTimer) clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => el.classList.remove("is-visible"), 3200); }, }; // --------------------------------------------------------------------------- // Small DOM helpers (kept local; no extra globals). // --------------------------------------------------------------------------- function on(id, evt, handler) { const el = document.getElementById(id); if (el) el.addEventListener(evt, handler); } function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } function t(pair, lang) { return (pair && pair[lang]) || (pair && pair.es) || ""; } // Frame URLs from the backend are same-origin absolute paths ("/frames/..."), // which need no auth. Normalize defensively in case a relative form arrives. function absUrl(u) { if (!u) return u; try { return new URL(u, window.location.origin).href; } catch (_) { return u; } } LL.controller = controller; // Boot once the DOM is parsed. The renderer module sets LL.stage on its own // load; if it loads after us, init() still wires everything and a later reveal // will use LL.stage if/when it appears (we look it up at call time). if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => controller.init(), { once: true }); } else { controller.init(); } export default controller;