lightloom / frontend /js /slate.js
Efradeca's picture
chore: deploy private lightloom build
a2525af verified
Raw
History Blame Contribute Delete
16.4 kB
// 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;
});
},
};