feat(sim): analytical Left HUD redesign + affect channel + no-cache

Left-brain HUD look-and-feel pass (judged by eye in the sim):
- corner-bracket targeting reticles (capped arms) replace plain boxes
- translucent label chips, legible over any frame
- two sensor channels: cyan detections (with deterministic confidence
  scores) vs amber measurements (center crosshair)
- bbox-sized "ANALYSIS . L{n} . {k} OBJ" status tag, escalating with Left

Affect channel (emotions in the HUD), per
docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md:
- surfaces only when BOTH knobs up; strength = min(left, right)
- stable per-scene palette, more words appear with combined strength
- soft glowing violet words on their own opacity layer (the dream
  leaking into the machine's read); math in player/alteration.py
- forest/cosmos/abyss palettes in the manifest; 5 new unit tests

Simulator no-cache middleware so by-eye iteration never serves a stale
app.js / api response (fixes the latent "plan has affect but clip.affect
empty" stale-/api/clips bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-22 23:11:02 -07:00
parent 9cf6324bb4
commit 6adb93407b
9 changed files with 315 additions and 16 deletions
+91 -10
View File
@@ -5,6 +5,7 @@
// the returned transition clip(s) then settles on the target scale's clip.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), tint = $("tint"), overlay = $("overlay"), black = $("black"), readout = $("readout");
const affectLayer = $("affect");
let clipsById = {}; // id -> clip manifest entry
let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null
@@ -63,25 +64,104 @@ function loadVariant(strength) {
}, 150);
}
const SVGNS = "http://www.w3.org/2000/svg";
function svg(tag, attrs, parent) {
const el = document.createElementNS(SVGNS, tag);
for (const k in attrs) el.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(el);
return el;
}
// Deterministic per-key "confidence" in [0.78, 0.99] — stable across renders so
// a detection always reports the same score (looks like a real model, not noise).
function confidence(key) {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
return (0.78 + (h % 2200) / 10000).toFixed(2);
}
// Four L-shaped corner brackets framing [x,y,w,h] — a targeting reticle rather
// than a plain rectangle. Arm length scales to the box but is clamped legible.
function reticle(x, y, w, h, cls, parent) {
const L = Math.min(Math.max(Math.min(w, h) * 0.28, 1.4), 4);
const corners = [
[[x + L, y], [x, y], [x, y + L]],
[[x + w - L, y], [x + w, y], [x + w, y + L]],
[[x + L, y + h], [x, y + h], [x, y + h - L]],
[[x + w - L, y + h], [x + w, y + h], [x + w, y + h - L]],
];
for (const pts of corners) {
svg("polyline", { points: pts.map((p) => p.join(",")).join(" "), class: cls }, parent);
}
}
// A label chip: translucent plate + monospace text, anchored top-left of the box.
// `conf` (detections only) is appended as a dimmer score tspan.
function chip(x, y, label, conf, kind, parent) {
const fs = 2.4, pad = 0.6;
const chars = label.length + (conf ? conf.length + 1 : 0);
const w = chars * fs * 0.6 + pad * 2, h = fs + pad * 1.4;
const cy = Math.max(y - h - 0.6, 0.4);
svg("rect", { x, y: cy, width: w, height: h, rx: 0.4, class: "hud-chip-bg " + kind }, parent);
const t = svg("text", { x: x + pad, y: cy + h - pad * 1.1, class: "hud-chip-text " + kind }, parent);
t.appendChild(document.createTextNode(label));
if (conf) {
const sp = svg("tspan", { class: "hud-conf" }, t);
sp.textContent = " " + conf;
}
}
function renderOverlay(level, intensity) {
const clip = activeClip();
overlay.innerHTML = "";
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
overlay.style.opacity = String(intensity);
const strings = (clip.strings && clip.strings.en) || {};
let shown = 0;
for (const a of clip.annotations) {
if (a.min_level > level) continue;
shown++;
const [x, y, w, h] = a.box.map((n) => n * 100);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x); rect.setAttribute("y", y);
rect.setAttribute("width", w); rect.setAttribute("height", h);
rect.setAttribute("class", "anno-box");
overlay.appendChild(rect);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", x + 0.5); text.setAttribute("y", Math.max(y - 0.5, 2));
text.setAttribute("class", "anno-label");
text.textContent = strings[a.key] || a.key;
overlay.appendChild(text);
const measure = a.key.startsWith("measure.");
const kind = measure ? "measure" : "detect";
reticle(x, y, w, h, "hud-reticle " + kind, overlay);
if (measure) {
// Point measurement: a center crosshair tick to read as "sampled here".
const cx = x + w / 2, cyc = y + h / 2;
svg("line", { x1: cx - 1, y1: cyc, x2: cx + 1, y2: cyc, class: "hud-reticle measure" }, overlay);
svg("line", { x1: cx, y1: cyc - 1, x2: cx, y2: cyc + 1, class: "hud-reticle measure" }, overlay);
}
chip(x, y, strings[a.key] || a.key, measure ? null : confidence(a.key), kind, overlay);
}
// Global status tag — the machine announcing it is analyzing, escalating with Left.
// Right-anchored at the frame edge; the plate is sized from the text's real bbox
// (the ◉/· glyphs are wider than monospace, so an estimate clips).
if (shown) {
const pad = 0.8;
const st = svg("text", { x: 98, y: 5, "text-anchor": "end", class: "hud-status" }, overlay);
st.textContent = "◉ ANALYSIS · L" + level + " · " + shown + " OBJ";
const b = st.getBBox();
const bg = svg("rect", { x: b.x - pad, y: b.y - pad * 0.6, width: b.width + pad * 2,
height: b.height + pad * 1.2, rx: 0.4, class: "hud-chip-bg detect" });
overlay.insertBefore(bg, st);
}
}
// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are
// up. `strength` = min(left, right); `intensity` is the layer opacity. Words are
// placed at authored scene points (no boxes — feelings are scene-level) and read
// softer than the clinical reticles — the dream leaking into the machine's read.
function renderAffect(strength, intensity) {
const clip = activeClip();
affectLayer.innerHTML = "";
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
affectLayer.style.opacity = String(intensity);
const strings = (clip.strings && clip.strings.en) || {};
for (const f of clip.affect) {
if (f.min_level > strength) continue;
const [x, y] = f.at.map((n) => n * 100);
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
t.textContent = strings[f.key] || f.key;
}
}
@@ -120,6 +200,7 @@ async function update() {
applyGrade(data.plan.grade.tone);
loadVariant(data.plan.restyle.variant);
renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
renderAffect(data.plan.affect.strength, data.plan.affect.intensity);
}
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
+2
View File
@@ -14,6 +14,7 @@
<video id="vid" loop muted playsinline></video>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<div id="black" class="black hidden"></div>
</div>
</section>
@@ -54,6 +55,7 @@
<legend>Calibration</legend>
<label>mood gain <input type="range" id="mood_gain" min="0" max="2" step="0.05" value="1" /></label>
<label>overlay gain <input type="range" id="overlay_gain" min="0" max="2" step="0.05" value="1" /></label>
<p class="hint">Gains, not effects: <b>mood gain</b> scales the Dark/Light grade, <b>overlay gain</b> the Left HUD &amp; affect words. Turn those knobs up first or these do nothing.</p>
</fieldset>
<fieldset>
+21 -2
View File
@@ -12,8 +12,27 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
transition: opacity 0.2s ease; }
#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; transition: opacity 0.2s ease; }
.anno-box { fill: none; stroke: #6cf; stroke-width: 0.4; vector-effect: non-scaling-stroke; }
.anno-label { fill: #6cf; font-size: 3px; font-family: monospace; }
/* Affect channel — its own layer so its opacity tracks min(left,right), not Left.
Soft violet glow, distinct from the clinical reticles (the dream leaking in). */
#affect { position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; transition: opacity 0.35s ease;
filter: drop-shadow(0 0 0.9px rgba(176, 120, 255, 0.95)); }
.hud-affect { fill: #e3cfff; font: italic 600 3.6px Georgia, "Times New Roman", serif;
letter-spacing: 0.06px; }
/* Left-brain analytical HUD. Two sensor channels: detection (cyan) + measurement
(amber). Corner-bracket reticles, translucent label chips, a status tag. */
.hud-reticle { fill: none; stroke-width: 0.6; vector-effect: non-scaling-stroke;
stroke-linecap: square; }
.hud-reticle.detect { stroke: #6cf; }
.hud-reticle.measure { stroke: #ffc46b; }
.hud-chip-bg { fill-opacity: 0.72; }
.hud-chip-bg.detect { fill: #001722; }
.hud-chip-bg.measure { fill: #1c1200; }
.hud-chip-text { font: 2.4px monospace; letter-spacing: 0.04px; }
.hud-chip-text.detect { fill: #aee6ff; }
.hud-chip-text.measure { fill: #ffd79a; }
.hud-conf { fill: #6cf; opacity: 0.8; }
.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; }
.black { position: absolute; inset: 0; background: #000; }
.hidden { display: none; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }