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:
@@ -0,0 +1,85 @@
|
||||
# Affect channel — emotions in the Left-brain HUD
|
||||
|
||||
**Status:** approved (session 0013, 2026-06-22)
|
||||
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
|
||||
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
|
||||
pass (same session).
|
||||
|
||||
## Idea
|
||||
|
||||
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
|
||||
glowing **emotion-words** that surface the feelings the experience may invoke.
|
||||
They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are
|
||||
up — the dream leaking into the machine's reading. This is the uncanny edge of the
|
||||
thesis: the machine trying to quantify not just what is there, but how you are
|
||||
meant to feel.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Trigger / intensity.** Affect strength = `min(left, right)` (0–4). Either knob
|
||||
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher combined
|
||||
strength reveals *more* words at higher opacity. Each word carries a `min_level`
|
||||
(the same mechanic the detection channel uses), but compared against the
|
||||
*combined* `min(left, right)` strength rather than Left alone.
|
||||
- **Rendering.** Floating words at authored scene positions — **no boxes or
|
||||
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
|
||||
register so they read as affective and clearly *softer* than the hard clinical
|
||||
reticles.
|
||||
|
||||
## Where the math lives
|
||||
|
||||
Consistent with the existing split (alteration math in Python, label-selection in
|
||||
the client):
|
||||
|
||||
- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the
|
||||
`RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain *
|
||||
min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under
|
||||
`"affect"`. Pure, unit-tested.
|
||||
- The simulator client picks *which* words to show by comparing each affect
|
||||
entry's `min_level` to `plan.affect.strength`, and renders them at
|
||||
`plan.affect.intensity` opacity.
|
||||
|
||||
`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already
|
||||
makes `overlay.level > 0`.
|
||||
|
||||
## Data
|
||||
|
||||
New optional per-clip `affect` list in the manifest, parallel to `annotations`:
|
||||
|
||||
```json
|
||||
"affect": [
|
||||
{"key": "feel.awe", "at": [0.5, 0.4], "min_level": 1},
|
||||
{"key": "feel.serenity", "at": [0.2, 0.62], "min_level": 2}
|
||||
],
|
||||
"strings": {"en": {"feel.awe": "awe", "feel.serenity": "serenity"}}
|
||||
```
|
||||
|
||||
`at` is a normalized `[x, y]` anchor point (not a box — feelings are scene-level).
|
||||
The words live in the existing `strings.en` map. `Clip` gains an `affect` field
|
||||
(defaulting to `[]`) threaded through `_clip_from_dict` and `to_dict`.
|
||||
|
||||
Starter palettes (words are art-direction, easily tuned):
|
||||
|
||||
| scale | palette (min_level 1 → 4) |
|
||||
|--------|-----------------------------------------------|
|
||||
| forest | awe · serenity · reverence · stillness |
|
||||
| cosmos | wonder · vastness · insignificance · longing |
|
||||
| abyss | unease · fascination · isolation · dread |
|
||||
|
||||
All three scales carry the data; forest is fully realized (it has the real Right
|
||||
variants).
|
||||
|
||||
## Testing
|
||||
|
||||
- Python unit tests: affect `strength = min(left, right)` across knob combos;
|
||||
intensity scaling and `overlay_gain`; `0` when either knob is 0; presence in the
|
||||
serialized plan.
|
||||
- By-eye in the live simulator (forest, Left & Right both up) — the established
|
||||
judge-by-eye loop for this surface.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No drift-toward-unsettling arc (rejected in favour of a stable palette).
|
||||
- No per-object emotion tags; no animated drift (words are statically placed for
|
||||
v1; gentle motion can come later if wanted).
|
||||
+30
-1
@@ -85,6 +85,18 @@ class AnalyticalOverlay:
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectOverlay:
|
||||
"""Affect channel (Left x Right): emotion-words surfaced only when BOTH the
|
||||
analytical (Left) and dreamlike (Right) knobs are up. `strength` is
|
||||
`min(left, right)` (0..4) — a runtime affect track picks which feeling words
|
||||
appear by it; `intensity` 0..1 is their opacity. The dream leaking into the
|
||||
machine's reading (affect-channel design, session 0013)."""
|
||||
|
||||
strength: int
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Restyle:
|
||||
"""Right axis (§4.1): selects a pre-baked, flow-stabilized restyle variant.
|
||||
@@ -99,11 +111,14 @@ class RenderPlan:
|
||||
|
||||
grade: ColorGrade
|
||||
overlay: AnalyticalOverlay
|
||||
affect: AffectOverlay
|
||||
restyle: Restyle
|
||||
|
||||
@property
|
||||
def is_identity(self) -> bool:
|
||||
"""True when the plan leaves the neutral base un-altered."""
|
||||
"""True when the plan leaves the neutral base un-altered. Affect needs no
|
||||
clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0`
|
||||
already makes a plan with active affect non-identity."""
|
||||
return (
|
||||
self.grade.is_identity
|
||||
and self.overlay.level == 0
|
||||
@@ -115,6 +130,15 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _affect_strength(left: int, right: int) -> int:
|
||||
"""Affect surfaces only when BOTH knobs are up — gated by the smaller one."""
|
||||
return min(left, right)
|
||||
|
||||
|
||||
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _right_variant(right: int, cal: Calibration) -> int:
|
||||
return cal.right_variant_map[right]
|
||||
|
||||
@@ -133,6 +157,10 @@ def plan_alteration(
|
||||
level=coord.left,
|
||||
intensity=_overlay_intensity(coord.left, calibration),
|
||||
),
|
||||
affect=AffectOverlay(
|
||||
strength=_affect_strength(coord.left, coord.right),
|
||||
intensity=_affect_intensity(coord.left, coord.right, calibration),
|
||||
),
|
||||
restyle=Restyle(variant=_right_variant(coord.right, calibration)),
|
||||
)
|
||||
|
||||
@@ -142,6 +170,7 @@ def render_plan_to_dict(plan: RenderPlan) -> dict:
|
||||
return {
|
||||
"grade": {"tone": plan.grade.tone},
|
||||
"overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity},
|
||||
"affect": {"strength": plan.affect.strength, "intensity": plan.affect.intensity},
|
||||
"restyle": {"variant": plan.restyle.variant},
|
||||
"is_identity": plan.is_identity,
|
||||
}
|
||||
|
||||
@@ -120,6 +120,15 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
)
|
||||
return ring_move_to_dict(move, app.state.ring)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_cache(request, call_next):
|
||||
# Dev preview server: never let a browser serve a stale app.js/style.css.
|
||||
# The whole point is by-eye iteration, so a plain refresh must always get
|
||||
# the latest assets (the heuristic cache otherwise hides JS/CSS edits).
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
|
||||
if MEDIA_DIR.exists():
|
||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||
if STATIC_DIR.exists():
|
||||
|
||||
@@ -26,6 +26,7 @@ class Clip:
|
||||
source: str
|
||||
right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0")
|
||||
annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...]
|
||||
affect: list # [{"key", "at":[x,y], "min_level"}, ...] (Left x Right)
|
||||
strings: dict # {"en": {key: text}}
|
||||
|
||||
def variant_file(self, strength: int) -> str:
|
||||
@@ -46,6 +47,7 @@ class Clip:
|
||||
"source": self.source,
|
||||
"right_variants": variants,
|
||||
"annotations": self.annotations,
|
||||
"affect": self.affect,
|
||||
"strings": self.strings,
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ def _clip_from_dict(d: dict[str, Any]) -> Clip:
|
||||
source=d.get("source", ""),
|
||||
right_variants=d.get("right_variants", {}),
|
||||
annotations=d.get("annotations", []),
|
||||
affect=d.get("affect", []),
|
||||
strings=d.get("strings", {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,10 +11,20 @@
|
||||
{"key": "detected.galaxy", "box": [0.34, 0.30, 0.30, 0.34], "min_level": 1},
|
||||
{"key": "measure.redshift", "box": [0.36, 0.66, 0.18, 0.08], "min_level": 4}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.wonder", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.vastness", "at": [0.20, 0.70], "min_level": 2},
|
||||
{"key": "feel.insignificance", "at": [0.64, 0.60], "min_level": 3},
|
||||
{"key": "feel.longing", "at": [0.42, 0.84], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.galaxy": "spiral galaxy",
|
||||
"measure.redshift": "z ≈ 0.04"
|
||||
"measure.redshift": "z ≈ 0.04",
|
||||
"feel.wonder": "wonder",
|
||||
"feel.vastness": "vastness",
|
||||
"feel.insignificance": "insignificance",
|
||||
"feel.longing": "longing"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,12 +46,22 @@
|
||||
{"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3},
|
||||
{"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.awe", "at": [0.48, 0.44], "min_level": 1},
|
||||
{"key": "feel.serenity", "at": [0.18, 0.66], "min_level": 2},
|
||||
{"key": "feel.reverence", "at": [0.66, 0.56], "min_level": 3},
|
||||
{"key": "feel.stillness", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.water": "flowing water",
|
||||
"detected.rock_face": "granite face",
|
||||
"detected.conifer": "conifer stand",
|
||||
"measure.flow_rate": "~2.1 m³/s"
|
||||
"measure.flow_rate": "~2.1 m³/s",
|
||||
"feel.awe": "awe",
|
||||
"feel.serenity": "serenity",
|
||||
"feel.reverence": "reverence",
|
||||
"feel.stillness": "stillness"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -56,10 +76,20 @@
|
||||
{"key": "detected.organism", "box": [0.40, 0.38, 0.22, 0.26], "min_level": 1},
|
||||
{"key": "measure.depth", "box": [0.06, 0.06, 0.16, 0.08], "min_level": 2}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.unease", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.fascination", "at": [0.22, 0.68], "min_level": 2},
|
||||
{"key": "feel.isolation", "at": [0.66, 0.58], "min_level": 3},
|
||||
{"key": "feel.dread", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.organism": "siphonophore",
|
||||
"measure.depth": "−2,140 m"
|
||||
"measure.depth": "−2,140 m",
|
||||
"feel.unease": "unease",
|
||||
"feel.fascination": "fascination",
|
||||
"feel.isolation": "isolation",
|
||||
"feel.dread": "dread"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-10
@@ -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); }
|
||||
|
||||
@@ -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 & affect words. Turn those knobs up first or these do nothing.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import (
|
||||
DEFAULT_CALIBRATION,
|
||||
AffectOverlay,
|
||||
AnalyticalOverlay,
|
||||
Calibration,
|
||||
ColorGrade,
|
||||
@@ -48,6 +49,45 @@ def test_left_and_right_stack_not_cancel():
|
||||
assert plan.restyle.variant == 4
|
||||
|
||||
|
||||
def test_affect_needs_both_knobs_up():
|
||||
# affect-channel design (session 0013): emotion-words surface only when BOTH
|
||||
# the Left and Right knobs are up — either at 0 means no affect at all.
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0
|
||||
|
||||
|
||||
def test_affect_strength_is_the_smaller_knob():
|
||||
# strength = min(left, right) — the smaller knob gates the channel.
|
||||
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2
|
||||
assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4
|
||||
assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1
|
||||
|
||||
|
||||
def test_affect_intensity_scales_with_strength_over_full_scale():
|
||||
for lo in range(5):
|
||||
plan = plan_alteration(_coord(left=lo, right=4))
|
||||
assert plan.affect.intensity == pytest.approx(lo / 4)
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_intensity_honors_overlay_gain():
|
||||
# affect rides the same overlay_gain as the analytical HUD it belongs to.
|
||||
cal = Calibration(overlay_gain=0.5)
|
||||
assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5)
|
||||
clamp = Calibration(overlay_gain=10.0)
|
||||
assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_active_implies_non_identity():
|
||||
# min(left,right) > 0 implies left > 0, so an active affect plan is never identity.
|
||||
plan = plan_alteration(_coord(left=2, right=3))
|
||||
assert plan.affect.strength == 2
|
||||
assert not plan.is_identity
|
||||
|
||||
|
||||
def test_light_pole_grades_warm_positive_tone():
|
||||
plan = plan_alteration(_coord(light=4))
|
||||
assert plan.grade.tone == 1.0
|
||||
@@ -117,6 +157,7 @@ def test_render_plan_to_dict_round_trips_the_numbers():
|
||||
assert d == {
|
||||
"grade": {"tone": -1.0},
|
||||
"overlay": {"level": 4, "intensity": 1.0},
|
||||
"affect": {"strength": 2, "intensity": 0.5},
|
||||
"restyle": {"variant": 2},
|
||||
"is_identity": False,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user