feat(sim): Dev Mode — pool clip picker + live analysis under one toggle
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 <select> of the current altitude's pool members — choose any clip to override the random landing pick; a "Re-roll random" button re-runs the server-canonical pick. Rebuilds on each landing. - Active clip metadata (id, title, source, license, base_file). - Annotations & affect: keys with salience/window/tier counts (read-only; editing stays in /author.html). - RenderPlan JSON — relocated here from the always-visible panel. - Cache & playback: preload status (N/total), memory-vs-network for the active clip, live resolution/duration/loop position. Frontend only — no API changes; everything is read from data the renderer already has. Factored pickRandomMember() out of landScale() so the initial landing, ring advances, and the re-roll button share one pick. Design: docs/superpowers/specs/2026-06-25-simulator-dev-mode-design.md 269 passed, 2 skipped; node --check + server smoke clean. Frontend behavior to be verified by-eye by the operator (no headless browser in repo yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+145
-7
@@ -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 = "<dt>—</dt><dd>no clip</dd>"; 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]) =>
|
||||
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v != null ? v : "—")}</dd>`).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(`<div class="anno-row"><span class="anno-key ${measure ? "measure" : ""}">${escapeHtml(a.key)}</span>` +
|
||||
`<span class="anno-meta"> · ${sal} · ${tiers} tier${tiers > 1 ? "s" : ""}${win(a)}</span></div>`);
|
||||
}
|
||||
for (const f of (c && c.affect) || []) {
|
||||
const tiers = tierCount(strings[f.key]);
|
||||
rows.push(`<div class="anno-row"><span class="anno-key affect">${escapeHtml(f.key)}</span>` +
|
||||
`<span class="anno-meta"> · min L${f.min_level ?? 1} · ${tiers} tier${tiers > 1 ? "s" : ""}</span></div>`);
|
||||
}
|
||||
box.innerHTML = rows.length ? rows.join("") : `<div class="anno-empty">no annotations or affect</div>`;
|
||||
}
|
||||
|
||||
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 =
|
||||
`<dt>cache</dt><dd>${cached}/${total} cached</dd>` +
|
||||
`<dt>active source</dt><dd class="${fromMem ? "mem" : "net"}">${fromMem ? "memory (blob)" : "network"}</dd>` +
|
||||
`<dt>resolution</dt><dd>${res}</dd>` +
|
||||
`<dt>duration</dt><dd>${dur}</dd>` +
|
||||
`<dt>loop pos</dt><dd>${loop}</dd>`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -50,11 +50,40 @@
|
||||
<label>Mood — dark ◀ 0 ▶ light <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
|
||||
<label class="dev-switch" for="dev-mode">
|
||||
<input type="checkbox" id="dev-mode" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label">Dev Mode</span>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>RenderPlan readout</legend>
|
||||
<pre id="readout">—</pre>
|
||||
</fieldset>
|
||||
<div id="dev-panel" class="dev-panel hidden">
|
||||
<fieldset>
|
||||
<legend>Pool — pick a clip</legend>
|
||||
<select id="pool-select" aria-label="Choose a clip from this altitude's pool"></select>
|
||||
<button type="button" id="pool-reroll" class="dev-btn">🎲 Re-roll random</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Active clip</legend>
|
||||
<dl id="clip-meta" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Annotations & affect</legend>
|
||||
<div id="clip-anno" class="dev-anno"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>RenderPlan</legend>
|
||||
<pre id="readout">—</pre>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cache & playback</legend>
|
||||
<dl id="dev-stats" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user