From a1c9c5c7603be66f51c692b2a1dbd9799a0239bf Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 25 Jun 2026 08:19:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Dev=20Mode=20=E2=80=94=20pool=20cl?= =?UTF-8?q?ip=20picker=20+=20live=20analysis=20under=20one=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single "Dev Mode" switch at the bottom of the control panel (off by default, persisted in localStorage). When on, a panel appears below it with all dev controls and data; when off the default view is just the experience + the three control fieldsets. Dev panel: - Pool picker: a ` of the current altitude's pool members + (`clip_id · title`), its value reflecting the clip currently playing. Choosing a + member sets `activeClipId` to it and reloads the base media, overriding the random + landing pick. A `🎲 Re-roll random` button re-runs the server-canonical random + pick for the current scale. The dropdown is rebuilt on every scale landing, so it + always lists the current pool; single-clip scales (cosmos/orbit) show one entry. +2. **Active clip** — `id`, `title`, `source`, `license`, `base_file`. +3. **Annotations & affect** — each annotation key with salience (or legacy + `min_level`), time window (`appear`–`disappear`), and tier count; each affect key + with `min_level` and tier count. Read-only. +4. **RenderPlan** — the relocated `#readout` (the `/api/alteration` JSON). +5. **Cache & playback** — preload status (`N/total cached`), whether the active + clip is served **from memory** (a preloaded blob) vs the network, and live video + resolution / duration / loop position. + +## Behavior + +- **Pool override is per-landing.** Changing altitude re-rolls randomly as today; + the operator then picks again from the new scale's pool. The pick is not persisted + across landings (YAGNI — "select any video while on an altitude"). +- **Refresh points.** Pool picker + clip metadata + annotations rebuild whenever the + scale lands (hooked into `renderScaleReadout()`) and after a manual pick/re-roll. + The RenderPlan readout updates on each `update()` (knob change) as today. The + cache/playback stats refresh on a light interval (~500 ms) while Dev Mode is on, + and the cache count also ticks up as the preloader fills. +- **`pickRandomMember()`** is factored out of `landScale()` so the initial landing, + ring advances, and the Re-roll button all share the one server-canonical pick. + +## Testing + +No JS unit harness in the repo and no API change, so verification is: `node --check` +on `app.js`, the Python suite stays green (unaffected), and by-eye review by the +operator (no headless browser yet — §9 E2E policy noted, machinery pending). diff --git a/simulator/static/app.js b/simulator/static/app.js index 51a6082..376d3d5 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -52,22 +52,28 @@ async function loadData() { ringIndex = 0; } -// Land on the current scale: pick a random pool member (content-pipeline §11.1). -// A real server ring picks via a delta=0 advance (Python-canonical pick); the -// synthesized fallback ring has a pool of one, so it just takes that member. -async function landScale() { +// Server-canonical random pick of a pool member for the current scale: a delta=0 +// advance (content-pipeline §11.1, Python owns the randomness). The synthesized +// fallback ring has a pool of one, so it just returns that member. Returns a +// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button. +async function pickRandomMember() { const scale = ring && ring.scales[ringIndex]; - if (!scale) { activeClipId = null; return; } + if (!scale) return null; if (serverRing) { try { const resp = await fetch("/api/ring/advance", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ from_index: ringIndex, delta: 0 }), }); - if (resp.ok) { activeClipId = (await resp.json()).target_clip_id; currentClipId = null; return; } + if (resp.ok) return (await resp.json()).target_clip_id; } catch (_) { /* fall through to the primary member */ } } - activeClipId = scale.clip_id; + return scale.clip_id; +} + +// Land on the current scale: pick a random pool member and force its media to load. +async function landScale() { + activeClipId = await pickRandomMember(); currentClipId = null; } @@ -462,6 +468,7 @@ function renderScaleReadout() { const poolTag = poolN > 1 ? ` · pool ${poolN}` : ""; $("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`; renderDial(); + refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing } function controls() { @@ -685,6 +692,136 @@ function jumpToScale(idx) { if (d) advance(d); } +// --- Dev Mode: pool picker + live analysis, all under one toggle --- +// Off by default, state persisted in localStorage. Everything here is read from +// data the renderer already has (clips, ring, the alteration response, the preload +// cache) — no server endpoints. Editing labels stays in /author.html. +const DEV_KEY = "hef.devMode"; +let devMode = false; +let devStatsTimer = null; + +const escapeHtml = (s) => String(s).replace(/[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + +function applyDevVisibility() { + const panel = $("dev-panel"); + if (panel) panel.classList.toggle("hidden", !devMode); + if (devMode) { refreshDevClip(); renderDevStats(); } +} + +function initDev() { + const toggle = $("dev-mode"); + if (!toggle) return; + try { devMode = localStorage.getItem(DEV_KEY) === "1"; } catch (_) { devMode = false; } + toggle.checked = devMode; + toggle.addEventListener("change", () => { + devMode = toggle.checked; + try { localStorage.setItem(DEV_KEY, devMode ? "1" : "0"); } catch (_) {} + applyDevVisibility(); + }); + $("pool-select").addEventListener("change", onPoolSelect); + $("pool-reroll").addEventListener("click", reroll); + // Cache + playback stats are live-ish; cheap to repaint twice a second when shown. + devStatsTimer = setInterval(() => { if (devMode) renderDevStats(); }, 500); + applyDevVisibility(); +} + +// Rebuild the pool picker for the current scale and repaint the clip data. Cheap +// (pools are <= a handful), so just rebuild on every landing/pick. +function refreshDevClip() { + if (!devMode) return; + const sel = $("pool-select"); + const scale = ring && ring.scales[ringIndex]; + if (sel && scale) { + const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id, title: scale.title }]; + sel.innerHTML = ""; + for (const m of pool) { + const o = document.createElement("option"); + o.value = m.clip_id; + o.textContent = `${m.clip_id} · ${m.title || (clipsById[m.clip_id] || {}).title || ""}`; + sel.appendChild(o); + } + if (activeClipId) sel.value = activeClipId; + sel.disabled = pool.length < 2; + } + renderDevMeta(); + renderDevAnno(); +} + +function renderDevMeta() { + const dl = $("clip-meta"); + const c = activeClip(); + if (!dl) return; + if (!c) { dl.innerHTML = "
no clip
"; return; } + const rows = [["id", c.id], ["title", c.title], ["source", c.source], + ["license", c.license], ["base_file", c.base_file]]; + dl.innerHTML = rows.map(([k, v]) => + `
${escapeHtml(k)}
${escapeHtml(v != null ? v : "—")}
`).join(""); +} + +function renderDevAnno() { + const box = $("clip-anno"); + const c = activeClip(); + if (!box) return; + const strings = (c && c.strings && c.strings.en) || {}; + const win = (a) => (typeof a.appear === "number" || typeof a.disappear === "number") + ? ` win ${(a.appear ?? 0).toFixed(2)}–${(a.disappear ?? 1).toFixed(2)}` : ""; + const rows = []; + for (const a of (c && c.annotations) || []) { + const measure = a.key.startsWith("measure."); + const sal = typeof a.salience === "number" ? `sal ${a.salience}` + : typeof a.min_level === "number" ? `min L${a.min_level}` : "sal 1"; + const tiers = tierCount(strings[a.key]); + rows.push(`
${escapeHtml(a.key)}` + + ` · ${sal} · ${tiers} tier${tiers > 1 ? "s" : ""}${win(a)}
`); + } + for (const f of (c && c.affect) || []) { + const tiers = tierCount(strings[f.key]); + rows.push(`
${escapeHtml(f.key)}` + + ` · min L${f.min_level ?? 1} · ${tiers} tier${tiers > 1 ? "s" : ""}
`); + } + box.innerHTML = rows.length ? rows.join("") : `
no annotations or affect
`; +} + +function renderDevStats() { + const dl = $("dev-stats"); + if (!dl) return; + const c = activeClip(); + const total = preloadList().length; + const cached = Object.keys(mediaBlobs).length; + const fromMem = !!(c && mediaBlobs[c.base_file]); + const res = vid.videoWidth ? `${vid.videoWidth}×${vid.videoHeight}` : "—"; + const dur = vid.duration ? `${vid.duration.toFixed(1)}s` : "—"; + const loop = vid.duration ? `${Math.round(loopT() * 100)}%` : "—"; + dl.innerHTML = + `
cache
${cached}/${total} cached
` + + `
active source
${fromMem ? "memory (blob)" : "network"}
` + + `
resolution
${res}
` + + `
duration
${dur}
` + + `
loop pos
${loop}
`; +} + +// Pick a specific pool member (Dev Mode override of the random landing pick). +function onPoolSelect() { + const id = $("pool-select").value; + if (!id || id === activeClipId) return; + activeClipId = id; + currentClipId = null; // force the base media to reload + renderScaleReadout(); // refresh scale name + dev clip data + update(); // reload media + plan +} + +// Re-roll: a fresh server-canonical random pick for the current scale. +async function reroll() { + if (busy) return; + const id = await pickRandomMember(); + if (!id) return; + activeClipId = id; + currentClipId = null; + renderScaleReadout(); + update(); +} + // Dev live-reload: poll the asset version and reload when it changes, so an open // tab never keeps running a stale renderer while we iterate (the readout updates // from the live API and masks it otherwise). Reloads on my edits AND on a server @@ -707,6 +844,7 @@ async function main() { await landScale(); // pick the initial scale's pool member before first render preloadAllMedia(); // background: cache every clip in memory for instant altitude swaps buildDial(); // draw the altitude knob from the ring's scales + initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state) renderScaleReadout(); for (const id of ["content", "left", "right", "mood"]) { $(id).addEventListener("input", debounced); diff --git a/simulator/static/index.html b/simulator/static/index.html index 68e91ab..1417144 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -50,11 +50,40 @@ + + -
- RenderPlan readout -
-
+ diff --git a/simulator/static/style.css b/simulator/static/style.css index 4e41e39..69cfe18 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -66,3 +66,34 @@ input[type=range], select { width: 100%; } .dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; } .scale-name { display: block; text-align: center; font-size: 12px; color: #cde; } .hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; } + +/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */ +.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; + padding: 0.5rem 0.2rem 0.2rem; border-top: 1px solid #222; user-select: none; } +.dev-switch input { position: absolute; opacity: 0; width: 0; height: 0; } +.dev-switch-track { position: relative; width: 34px; height: 18px; border-radius: 9px; + background: #2a2a2a; border: 1px solid #444; transition: background 0.15s; } +.dev-switch-thumb { position: absolute; top: 1px; left: 1px; width: 14px; height: 14px; + border-radius: 50%; background: #888; transition: transform 0.15s, background 0.15s; } +.dev-switch input:checked + .dev-switch-track { background: #1d3a2a; border-color: #2e6; } +.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; } +.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; } +.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; } +.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; } +.dev-panel legend { color: #4e9; } +.dev-btn { margin-top: 0.5rem; width: 100%; padding: 0.4rem; cursor: pointer; + background: #182028; color: #cde; border: 1px solid #2c3c5c; border-radius: 4px; } +.dev-btn:hover { background: #20303f; } +.dev-dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem; font-size: 12px; } +.dev-dl dt { color: #789; } +.dev-dl dd { margin: 0; color: #dde; word-break: break-word; } +.dev-dl dd.mem { color: #4e9; } +.dev-dl dd.net { color: #fc6; } +.dev-anno { font-size: 11px; max-height: 220px; overflow: auto; + font-family: ui-monospace, monospace; } +.dev-anno .anno-row { padding: 0.2rem 0; border-bottom: 1px solid #1d1d1d; } +.dev-anno .anno-key { color: #aee6ff; } +.dev-anno .anno-key.affect { color: #e3cfff; } +.dev-anno .anno-key.measure { color: #ffd79a; } +.dev-anno .anno-meta { color: #678; } +.dev-anno .anno-empty { color: #567; font-style: italic; }