// slate.js — LL.slate: the film clapperboard (THE HERO of the <5s legibility goal). // // Renders into #slate a chalk/amber rodaje clapperboard that lets a judge read the // Director's decision at a glance: a BOLD chip CUT (red) vs CONTINUITY (amber), the // shot scale, a human camera label, the lighting line, and three palette swatches. // Also owns the micro-stage bar (OYENDO/TRADUCIENDO/DIRIGIENDO/PINTANDO/REVELANDO). // // Vanilla ES module, tokens-based, no deps. Bilingual ES/EN: every text node carries // data-es/data-en and is resolved through LL.i18n (falls back to ES). The decision chip // flips/scales when the call changes so the eye is drawn to the cut. const LL = (window.LL = window.LL || {}); // --- bilingual helpers -------------------------------------------------------- // The lang toggle (#lang-toggle) flips document language; we read it lazily so a // re-render after toggle picks up the new language. data-es/data-en stay on the DOM // so a global toggle pass can also swap text without a re-render. function currentLang() { if (LL.lang) return LL.lang === "en" ? "en" : "es"; const docLang = (document.documentElement.lang || "es").toLowerCase(); return docLang.startsWith("en") ? "en" : "es"; } // Build a span that knows both languages; resolved immediately and re-resolvable. function bilingual(es, en, cls) { const el = document.createElement("span"); if (cls) el.className = cls; el.setAttribute("data-es", es); el.setAttribute("data-en", en); el.textContent = currentLang() === "en" ? en : es; return el; } // --- static copy -------------------------------------------------------------- // Micro-stage chips in pipeline order. Each has ES/EN and the SSE stage keys that // should light it (setStage accepts either a chip id or a raw SSE stage name). const STAGES = [ { id: "oyendo", es: "OYENDO", en: "LISTENING", keys: ["oyendo", "listening", "warming", "ready"] }, { id: "traduciendo", es: "TRADUCIENDO", en: "TRANSLATING", keys: ["traduciendo", "translating"] }, { id: "dirigiendo", es: "DIRIGIENDO", en: "DIRECTING", keys: ["dirigiendo", "directing", "directed"] }, { id: "pintando", es: "PINTANDO", en: "PAINTING", keys: ["pintando", "painting", "depth"] }, { id: "revelando", es: "REVELANDO", en: "REVEALING", keys: ["revelando", "revealing", "painted"] }, ]; // Human-readable camera labels (contract camera_move enum -> chalk label). const CAMERA_LABELS = { static: { es: "fijo", en: "static" }, dolly_in: { es: "dolly-in", en: "dolly-in" }, dolly_out: { es: "dolly-out", en: "dolly-out" }, pan_left: { es: "pan izq.", en: "pan left" }, pan_right: { es: "pan der.", en: "pan right" }, tilt_up: { es: "tilt arriba", en: "tilt up" }, crane_down: { es: "grúa abajo", en: "crane down" }, }; // Shot-scale long names for the small subtitle under the big scale chip. const SCALE_NAMES = { ECU: { es: "primerísimo primer plano", en: "extreme close-up" }, CU: { es: "primer plano", en: "close-up" }, MS: { es: "plano medio", en: "medium shot" }, WS: { es: "plano general", en: "wide shot" }, EWS: { es: "gran plano general", en: "extreme wide shot" }, }; function cameraLabel(move) { const l = CAMERA_LABELS[move]; if (!l) return move ? String(move).replace(/_/g, "-") : "—"; return currentLang() === "en" ? l.en : l.es; } function scaleName(scale) { const s = SCALE_NAMES[scale]; if (!s) return ""; return currentLang() === "en" ? s.en : s.es; } // --- one-time style + DOM injection ------------------------------------------ let root = null; // #slate let els = null; // cached child refs let lastDecision = null; // to trigger the chip flip only on change function injectStyles() { if (document.getElementById("ll-slate-styles")) return; const css = ` /* Clapperboard panel — chalk on slate, warm amber accents, never blue-tech. */ #slate { font-family: var(--font-ui); color: var(--ink); background: linear-gradient(180deg, rgba(20,20,24,0.92), rgba(10,10,12,0.96)), repeating-linear-gradient(135deg, transparent 0 14px, rgba(255,255,255,0.012) 14px 28px); border: 1px solid var(--line); border-radius: var(--r-panel); box-shadow: var(--panel-shadow, 0 10px 40px rgba(0,0,0,.6)); padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 12px; user-select: none; } /* The clapper sticks: alternating amber/ink diagonal bars across the top. */ .ll-slate-clapper { height: 16px; border-radius: 6px 6px 0 0; background: repeating-linear-gradient( -52deg, var(--amber) 0 16px, #14140f 16px 32px ); box-shadow: var(--halo-soft, 0 0 18px rgba(232,163,61,.10)) inset; margin: -2px -2px 2px; opacity: .92; } .ll-slate-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .ll-slate-plano { font-family: var(--font-display); font-weight: 600; letter-spacing: .04em; font-size: 15px; color: var(--ink); } .ll-slate-plano b { color: var(--amber); font-weight: 700; } /* Decision chip: the eye-magnet. CUT = REC red, CONTINUITY = amber. */ .ll-chip { --chip-bg: var(--amber-soft); --chip-fg: var(--amber); --chip-br: var(--amber); display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-weight: 800; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; padding: 6px 12px; border-radius: var(--r-pill); color: var(--chip-fg); background: var(--chip-bg); border: 1.5px solid var(--chip-br); box-shadow: 0 0 0 0 transparent; transform: perspective(400px) rotateX(0deg) scale(1); transform-origin: center; } .ll-chip[data-decision="cut"] { --chip-fg: #fff; --chip-bg: var(--rec); --chip-br: var(--rec); box-shadow: 0 0 16px rgba(224,68,46,.45); } .ll-chip[data-decision="continuity"] { --chip-fg: var(--amber); --chip-bg: var(--amber-soft); --chip-br: var(--amber); } .ll-chip .ll-chip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .9; } /* Flip+scale when the call changes. Reduced motion = no flip. */ @keyframes ll-chip-flip { 0% { transform: perspective(400px) rotateX(0deg) scale(1); } 45% { transform: perspective(400px) rotateX(90deg) scale(1.12); } 55% { transform: perspective(400px) rotateX(-90deg) scale(1.12); } 100% { transform: perspective(400px) rotateX(0deg) scale(1); } } .ll-chip.ll-flip { animation: ll-chip-flip 420ms var(--ease) both; } body.reduced-motion .ll-chip.ll-flip { animation: none; } /* Big shot-scale block. */ .ll-slate-scale { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .ll-slate-scale-big { font-family: var(--font-display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--ink); letter-spacing: .02em; } .ll-slate-scale-name { font-size: 12px; color: var(--ink-dim); font-style: italic; } /* Rows: camera + lighting, chalk-handwritten feel via display serif italic. */ .ll-slate-row { display: flex; align-items: baseline; gap: 8px; font-size: 13px; } .ll-slate-row .ll-k { color: var(--ink-faint); text-transform: uppercase; letter-spacing: .1em; font-size: 10px; min-width: 58px; } .ll-slate-row .ll-v { color: var(--ink); font-family: var(--font-display); font-style: italic; } /* Palette swatches. */ .ll-slate-palette { display: flex; align-items: center; gap: 8px; } .ll-slate-palette .ll-k { color: var(--ink-faint); text-transform: uppercase; letter-spacing: .1em; font-size: 10px; } .ll-swatches { display: flex; gap: 6px; } .ll-swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,.18); box-shadow: 0 1px 6px rgba(0,0,0,.5); transition: transform var(--transition-ms) var(--ease); } /* Setting line (small, optional). */ .ll-slate-setting { font-size: 11px; color: var(--ink-dim); font-style: italic; } /* Micro-stage bar — thin amber chips under the canvas. Never a spinner. */ #stage-bar, .ll-stagebar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } .ll-stage-chip { font-family: var(--font-ui); font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; padding: 4px 9px; border-radius: var(--r-pill); color: var(--ink-faint); background: transparent; border: 1px solid var(--line); transition: color 240ms var(--ease), background 240ms var(--ease), border-color 240ms var(--ease), box-shadow 240ms var(--ease); } .ll-stage-chip.is-done { color: var(--ink-dim); border-color: var(--line); } .ll-stage-chip.is-active { color: #14140f; background: var(--amber); border-color: var(--amber); box-shadow: var(--halo-soft, 0 0 18px rgba(232,163,61,.10)); } body.reduced-motion .ll-stage-chip { transition: none; } `; const style = document.createElement("style"); style.id = "ll-slate-styles"; style.textContent = css; document.head.appendChild(style); } // Build the clapperboard skeleton once and cache references. function build() { if (els) return els; injectStyles(); root = document.getElementById("slate"); if (!root) return null; // DOM not ready; callers no-op gracefully root.innerHTML = ""; root.setAttribute("aria-live", "polite"); root.setAttribute("aria-label", "Director's slate"); const clapper = document.createElement("div"); clapper.className = "ll-slate-clapper"; // Head: PLANO NN + decision chip const head = document.createElement("div"); head.className = "ll-slate-head"; const plano = document.createElement("div"); plano.className = "ll-slate-plano"; const chip = document.createElement("span"); chip.className = "ll-chip"; chip.setAttribute("data-decision", "continuity"); head.append(plano, chip); // Scale block const scale = document.createElement("div"); scale.className = "ll-slate-scale"; const scaleBig = document.createElement("span"); scaleBig.className = "ll-slate-scale-big"; scaleBig.textContent = "—"; const scaleName = document.createElement("span"); scaleName.className = "ll-slate-scale-name"; scale.append(scaleBig, scaleName); // Camera row const camRow = document.createElement("div"); camRow.className = "ll-slate-row"; const camK = bilingual("CÁMARA", "CAMERA", "ll-k"); const camV = document.createElement("span"); camV.className = "ll-v"; camV.textContent = "—"; camRow.append(camK, camV); // Lighting row const litRow = document.createElement("div"); litRow.className = "ll-slate-row"; const litK = bilingual("LUZ", "LIGHT", "ll-k"); const litV = document.createElement("span"); litV.className = "ll-v"; litV.textContent = "—"; litRow.append(litK, litV); // Palette const pal = document.createElement("div"); pal.className = "ll-slate-palette"; const palK = bilingual("PALETA", "PALETTE", "ll-k"); const swatches = document.createElement("div"); swatches.className = "ll-swatches"; const swEls = []; for (let i = 0; i < 3; i++) { const s = document.createElement("span"); s.className = "ll-swatch"; s.style.background = "var(--line)"; swatches.appendChild(s); swEls.push(s); } pal.append(palK, swatches); // Setting (small) const setting = document.createElement("div"); setting.className = "ll-slate-setting"; root.append(clapper, head, scale, camRow, litRow, pal, setting); els = { root, plano, chip, scaleBig, scaleName, camV, litV, swEls, setting }; return els; } // Update the chip face (text + decision data attr + flip on change). function setDecision(decision) { const e = els; const norm = decision === "cut" ? "cut" : "continuity"; // contract: "cut" | "continuity" e.chip.setAttribute("data-decision", norm); // chip content: a dot + bilingual label e.chip.innerHTML = ""; const dot = document.createElement("span"); dot.className = "ll-chip-dot"; const label = norm === "cut" ? bilingual("CORTE", "CUT") : bilingual("CONTINUIDAD", "CONTINUITY"); e.chip.append(dot, label); if (lastDecision !== null && lastDecision !== norm) { e.chip.classList.remove("ll-flip"); // force reflow so the animation re-triggers even on a rapid re-call void e.chip.offsetWidth; e.chip.classList.add("ll-flip"); } lastDecision = norm; } // --- public API --------------------------------------------------------------- LL.slate = { // Render the Director's call for a beat. // shot — the {decision, shot_scale, camera_move, lighting, palette, setting, ...} // sceneId — scene number (1-based) for the PLANO/ESCENA line // shotIndex — shot number within the film (1-based) update(shot, sceneId, shotIndex) { const e = build(); if (!e || !shot) return; // PLANO line (the hero number); show scene too for context. const planoNum = String(shotIndex ?? "—").padStart(2, "0"); e.plano.innerHTML = ""; const planoLabel = currentLang() === "en" ? "SHOT " : "PLANO "; e.plano.append(document.createTextNode(planoLabel)); const b = document.createElement("b"); b.textContent = planoNum; e.plano.appendChild(b); // Decision chip. setDecision(shot.decision); // Shot scale. const scale = shot.shot_scale || "—"; e.scaleBig.textContent = scale; e.scaleName.textContent = scaleName(scale); // Camera (human label). e.camV.textContent = cameraLabel(shot.camera_move); // Lighting (free text from the Director). e.litV.textContent = shot.lighting ? String(shot.lighting) : "—"; // Palette swatches — exactly three, padded if the Director returned fewer. const palette = Array.isArray(shot.palette) ? shot.palette : []; for (let i = 0; i < 3; i++) { const c = palette[i]; const valid = typeof c === "string" && /^#?[0-9a-fA-F]{3,8}$/.test(c.trim()); e.swEls[i].style.background = valid ? c.trim().startsWith("#") ? c.trim() : "#" + c.trim() : "var(--line)"; e.swEls[i].title = valid ? c : ""; } // Setting line (optional context, italic chalk). e.setting.textContent = shot.setting ? String(shot.setting) : ""; }, // Light the active micro-stage chip; accepts a chip id (oyendo…) OR a raw SSE // stage name (warming/directing/painting/painted/…). Earlier chips mark "done". setStage(name) { const bar = document.getElementById("stage-bar"); if (!bar) return; injectStyles(); // Lazily build the chip set once. if (!bar.querySelector(".ll-stage-chip")) { bar.classList.add("ll-stagebar"); bar.innerHTML = ""; STAGES.forEach((st) => { const chip = document.createElement("span"); chip.className = "ll-stage-chip"; chip.dataset.stageId = st.id; chip.setAttribute("data-es", st.es); chip.setAttribute("data-en", st.en); chip.textContent = currentLang() === "en" ? st.en : st.es; bar.appendChild(chip); }); } const key = String(name || "").toLowerCase(); const idx = STAGES.findIndex((st) => st.id === key || st.keys.includes(key)); const chips = bar.querySelectorAll(".ll-stage-chip"); chips.forEach((chip, i) => { chip.classList.toggle("is-active", idx >= 0 && i === idx); chip.classList.toggle("is-done", idx >= 0 && i < idx); }); }, // Re-resolve all data-es/data-en text after a language toggle. retranslate() { const lang = currentLang(); document.querySelectorAll("#slate [data-es], #stage-bar [data-es]").forEach((el) => { const v = el.getAttribute(lang === "en" ? "data-en" : "data-es"); if (v != null) el.textContent = v; }); }, };