RELEASE
planning/2.8.0/plans/FEEDBACK_ANIMATION_BUILD_PLAN.md
sha256 424e9aaf4c917b94 · 20144 bytes ·
original held in the private archive
# FEEDBACK ANIMATION — BUILD PLAN (all 33)
**Branch:** `dev` · **HEAD:** `92b7bbe` · **APP_VERSION:** `2.7.0` (`[Unreleased]`)
**Source slate:** `planning/FEEDBACK_ANIMATION_SLATE.md` (all 33 approved).
**Scope:** PLAN ONLY — no source, no commits, no gate. Build lands in the waves below (§6), each a verified push.
---
## 1. What already exists (the substrate — reuse, don't rebuild · Protocol 22)
**The bus** (`js/state.js` L233): `RobcoEvents.on/emit`, synchronous, each listener wrapped in `try/catch` (a bad listener never breaks the emitter or siblings). Subscribers are wired inside named `_wire*EventBusSubscribers()` **boot phases** called from `window.onload` — never at parse time (the U7 boot-order lesson: a static `<script>` can execute before `state.js` defines `RobcoEvents`).
**13 events already emitted** (payloads verified):
`level.up {oldLvl,newLvl}` · `stat.change {key,oldVal,newVal}` (key ∈ `s_*`/`hp`/`xp`/`rad`/`caps`/skills) · `hp.critical` · `faction.threshold {key,name,direction:'vilified'|'idolized',curNet,prevNet}` · `collectible.acquired {name}` · `craft.completed {name,qty}` · `craft.scrapped {name,qty}` · `trade.bought {name,price}` · `trade.sold {name,price}` · `sleep.completed` · `data.write {kind}` · `runtime.state {from,to}`.
**The echo precedent the owner cited** — the CHASSIS **mini core** (`_coreShells()` returns the full BUS-22 core **and** the always-present casing-top `#chassisCoreMini`; `_coreOneShot(cls,ms)` add-class→reflow→remove on **both** shells from one call; gated by `_coreShouldAnimate()`). The mini core lives in `.casing-top` (`index.html` L250–325) — the machine header that is **always on screen on every subsystem** (outside the tab/panel system, top of the casing, above the thumb zone). This is exactly the "viewable off its own page" mechanism to generalize.
**The animation gate** (`_coreShouldAnimate()`, ui-core L1954): false when reduced-motion OR immersion `< balanced` OR `document.hidden` OR runtime ∈ STANDBY/SHUTDOWN/OFF. Reuse it (Protocol 22).
**Active-subsystem signal:** `document.body.dataset.subsystem` (set in `switchTab()`, ui-core L4075) — the key to echo suppression (§2).
**Audio channels (synth, ready):** `playLevelUpJingle` · `playQuestCompleteSound` · `playQuestFailSound` · `playFactionThresholdSound` · `playGeigerClick` · `playLimbCrippleSound`/`playHeadCrippleSound`/`playLimbRestoreSound` · `playWakeTone` · `playChipClick` · `playBoardThunk`. Visuals sync to the **existing** call sites — no new audio.
---
## 2. THE GLOBAL FEEDBACK ECHO (the key design problem)
**Problem:** a themed animation plays on its home panel (level card on OPERATOR, reputation stamp on the faction console, survey ping on the map), but the triggering event often fires while the user is on a **different** subsystem — above all the AI/`autoImportState` changes that land while the user is on UPLINK/chat. Off-page, the animation is unseen.
**Solution: the STATUS ANNUNCIATOR** — a lightweight, always-present, in-view, reduced-motion-safe, non-blocking Pip-Boy readout in the **`.casing-top` header** (beside the mini core) that surfaces an event **regardless of the active subsystem**, while the **full themed animation still plays on its home panel** for when the user is there. It generalizes the mini-core dual-paint pattern.
### Form & placement
- A compact readout inside `.casing-top`: **`[glyph] STENCIL LABEL`** in a themed tone. Always top-of-screen, **never `position:fixed` over the bottom thumb zone** (owner constraint). At 360px it is a single short line; the casing-top header already flex-wraps, so it drops to its own row without overflow (verified pattern — Suite 129/160 casing rules).
- **Fallout-native, transform/opacity only:** a plain `@keyframes annunciator-in` (slide+fade in from the mini-core side) → hold ~2000ms → `annunciator-out`. Glyphs ride existing iconography, no game literal: ☢ rads · ▲ level · ⚑ faction · ✚ heal · ▼ damage · ◈ item · ★ collectible (label from `collectibleLabel`) · ✓/✗ quest · ⦿ location · ⚖ karma · ✦ effect · ⚠ limb/seized. Tone = green (gain/local) · amber (rad/hazard-warn/remote) · red (critical/fail) — the shipped amber-on-phosphor rules.
### The suppression rule (the viewability core — one elegant rule)
`_echoPush(evt)` fires **iff the event's HOME subsystem ≠ the currently-active subsystem** (`document.body.dataset.subsystem`). If the user is already on the home panel, the home animation is visible → **no echo** (no redundancy). If it fired off-page (AI, or the user is elsewhere) → **echo fires**. This single runtime rule subsumes the owner's (a)/(b) split (§3): **(a) events are never wired for an echo at all** (they can *only* be user-on-panel — `adjItemQty`/`toggleEquipItem`/`adjustFaction ±5`/user `cycleQuestStatus`/user `markLocationVisited`/`addPerk`/craft/scrap have no off-page path); **(b) events are wired, and self-suppress** on the rare occasion the user happens to be viewing the home panel when the AI change lands.
### De-dupe & queue (never floods)
- One annunciation visible at a time via a FIFO `_echoQueue` (cap ~6, drop-oldest). `_echoAdvance()` shows the next for ~2000ms then advances — a burst plays in sequence, oldest-first, nothing missed, layer never floods.
- **Identical consecutive events collapse** to `×N` (e.g. an AI turn that adds three items → `◈ +3 ITEMS ×3`; three rad ticks up → one `☢ RADS: ADVANCED`).
### Gate & accessibility
- **Motion/glow** rides the shipped `_coreShouldAnimate()` (Protocol 22). Because the entrance/exit are plain `@keyframes`, reduced-motion/immersion-minimal **auto-degrade the slide to an instant show→hold→hide** — the label still appears and is readable (a correct final frame), it just snaps. **Essential-feedback stance:** the readout still shows at every immersion tier (the standby-coordinator `'minimal'`-tier precedent); only the decorative flourish is quieted. Fully suppressed only under `document.hidden` / runtime STANDBY/SHUTDOWN/OFF (no viewer). *(Open Q1: confirm immersion-minimal keeps the readout.)*
- **a11y bonus:** the annunciator is a `role="status" aria-live="polite"` region, so screen readers announce each event — the echo doubles as an SR notification (the mini core is `aria-hidden`; the annunciator is deliberately not).
### Mechanism (mirrors the mini core; zero state write)
New small module in `ui-core.js` beside the core echo: `_echoPush({tone,glyph,label,homeSubsystem,dedupeKey})` · `_echoAdvance()` · `_echoQueue`/`_echoTimer` (transient module vars). A new **`_wireFeedbackEchoSubscribers()`** boot phase (U7 pattern) subscribes to the **(b)** events and maps each to a themed annunciation. Purely presentational — transient DOM classes + module vars only, **no `state.*`/`saveState()`/`robco_v8`**.
*(Open Q2: tap-to-jump — tapping the annunciator could `selectSubsystem(home)` to jump to the reacting panel. Nice-to-have; if adopted it becomes a real `<button>` ≥28px, still not `position:fixed`. Recommend deferring to a later polish.)*
---
## 3. Per-event routing (all 33: home-panel vs +echo)
**Echo fires** for the owner's **(b)** set — events with an off-page (AI/auto/systemic) path: *level-up · stat/HP/XP/RAD · faction threshold · item added · collectible · quest status · location discovered · limb · status effects · karma*. Everything else is **home-panel only** ((a) — inherently user-on-panel). Home subsystem drives the suppression check.
| # | Event | Home subsystem / panel | Signal | Routing |
|---|---|---|---|---|
| 1 | HP critical | OPERATOR · BUS-01 | `hp.critical` (exists) | **home + echo** (☢/⚠ red) |
| 2 | HP damage | OPERATOR · BUS-01 | `stat.change` hp↓ | **home + echo** (▼ red) |
| 3 | HP healed | OPERATOR · BUS-01 | `stat.change` hp↑ | **home + echo** (✚ green) |
| 4 | Rad tier up | OPERATOR · BUS-03 RAD | **new `rad.tier`** | **home + echo** (☢ amber) |
| 5 | Rads flushed | OPERATOR · BUS-03 RAD | **new `rad.tier`** dir=down | **home + echo** (☢ green) |
| 6 | Limb crippled | OPERATOR · BUS-03 plate | **new `limb.state`** | **home + echo** (⚠ red) |
| 7 | Limb mended | OPERATOR · BUS-03 plate | **new `limb.state`** ok | **home + echo** (✚ green) |
| 8 | Over-encumbered | OPERATIONS · BUS-10 bridge | **new `weight.seized`** | **home + echo** (⚠ amber) |
| 9 | LEVEL UP | OPERATOR (corner card) | `level.up` (exists) | **home + echo** (▲ green) — flagship |
| 10 | XP gained | OPERATOR · XP bar | `stat.change` xp↑ | **home + echo** (▲ green) |
| 11 | SPECIAL changed | OPERATOR · BUS-02 gauge | `stat.change` `s_*` | **home + echo** (⚙ green) |
| 12 | Skill book / mag | OPERATOR · shelf/rack | existing toggles | **home only** (a) |
| 13 | Perk socketed | OPERATOR · BUS-06 | `addPerk` hook | **home only** (a) |
| 14 | Faction threshold | OPERATOR · BUS-08 | `faction.threshold` (exists) | **home + echo** (⚑ red/green) |
| 15 | Fame/infamy ±5 | OPERATOR · BUS-08 meter | `adjustFaction` (user) | **home only** (a) |
| 16 | Karma tier | OPERATOR · BUS-09 | **new `karma.tier`** | **home + echo** (⚖) |
| 17 | Caps change | OPERATIONS · caps tile | `trade.*` / `stat.change` caps | **home + echo** (◉ — AI caps path only; trade path self-suppresses on-panel) |
| 18 | Item added | OPERATIONS · BUS-11 | **new `item.added`** | **home + echo** (◈ green) |
| 19 | Equip toggled | OPERATIONS · row | **new `item.equipped`** (user) | **home only** (a) |
| 20 | Craft completed | OPERATIONS · BUS-12 | `craft.completed` (exists, user) | **home only** (a) |
| 21 | Item scrapped | OPERATIONS · VISE | `craft.scrapped` (exists, user) | **home only** (a) |
| 22 | Collectible acquired | OPERATIONS · BUS-15 | `collectible.acquired` (exists) | **home + echo** (★) — AI path needs the emit too (§4 note) |
| 23 | Quest completed | DATABANK · BUS-17 | **new `quest.status`** →complete | **home + echo** (✓ green) |
| 24 | Quest failed | DATABANK · BUS-17 | **new `quest.status`** →failed | **home + echo** (✗ red) |
| 25 | Quest filed | DATABANK · BUS-17 | `addQuest` hook (user) | **home only** (a) |
| 26 | Location discovered | DATABANK · BUS-16 map | **new `location.visited`** | **home (deferred, §5) + echo** (⦿) |
| 27 | Arrival (current changed) | DATABANK · BUS-16 map | `onLocationChange` path | **home + echo** (⦿ amber) — *not in owner's explicit list; recommend echo (Open Q3)* |
| 28 | Status effect applied | OPERATOR · BUS-07 | **new `effect.applied`** | **home + echo** (✦) |
| 29 | Effect expiring | OPERATOR · BUS-07 lamp | **new `effect.expiring`** | **home + echo** (✦ amber) |
| 30 | Sleep completed | OPERATOR · chrono | `sleep.completed` (exists) | **home only** (a, owner-listed) — *borderline (Open Q3)* |
| 31 | Save / cloud | SETTINGS · SAVE ARCHIVE | `data.write` (exists) | **home only** — the existing mini-core `data.write` pulse already serves as the global tell |
| 32 | Fault recorded | CHASSIS · BUS-24 | `_recordError` hook | **home only** — the always-present casing FAULT lamp already IS the echo (polish) |
| 33 | Qty stepped | OPERATIONS · row | `adjItemQty` (user) | **home only** (a) |
---
## 4. New additive `emit()` sites (10 — one line each, U7/U8 precedent + a Protocol 13 regression test each)
Each is **one additive line at the existing setter** — never a forked handler, never polling. All emit `{...payload}`; the `_wireFeedbackEchoSubscribers()` + panel-fx subscribers consume them.
| Event | Host function (file) | Emit condition & payload | Notes |
|---|---|---|---|
| `location.visited` | `recordLocationVisit()` (`js/state.js` L1594) | on a genuinely-new (dedup'd) add → `{loc}` | **The single choke** — `markLocationVisited` (user), `onLocationChange` (arrival), and the AI autoImport path all route through it, so ONE emit covers every path. |
| `quest.status` | `cycleQuestStatus()` (`js/ui-render.js`) **and** the AI quest status-diff in `autoImportState()` (`js/api.js`) | on old→new status change → `{name, status, prevStatus}` | Two sites (user cycle + AI diff), same event. Protocol 14 (AI-contract) — cover the autoImport diff in a round-trip test. |
| `rad.tier` | `updateMath()` (`js/ui-core.js`) | compute tier from `state.rads`, compare a module cache `_lastRadTier`, emit on change → `{tier, direction}` | The `hp.critical` in-`updateMath` precedent. Tiers: NONE/MINOR/ADVANCED/SEVERE (reuse `_bioScanCompute`'s existing 200/400/600 breakpoints — no new literals). |
| `limb.state` | `toggleLimb()` (`js/ui-core.js`) **and** the AI limb-set path in `autoImportState()` | on OK↔CRIPPLED change → `{limb, state}` | Two paths (user toggle + AI). |
| `item.added` | `addItem()` (`js/ui-render.js`), the LOOT apply path, and the AI inventory-merge in `autoImportState()` | on a new/incremented item → `{name, qty, source}` | `source` lets the echo skip the on-panel LOOT/manual case if desired (the subsystem check already handles it). |
| `karma.tier` | `updateKarmaUI()` (`js/ui-core.js` L5489) | compare cached tier from the karma label → `{tier}` | Reuses the existing karma-label thresholds — no new literals. |
| `effect.applied` | `addStatusEffect()` (`js/ui-render.js` ~L703) **and** the AI status-set in `autoImportState()` | on a newly-pushed effect → `{name, type}` | |
| `effect.expiring` | the status tick-down (`js/api.js` L874, `eff.ticks = Math.max(0, eff.ticks - elapsed)`) | when a ticks crosses into `>0 && ≤2` → `{name, ticks}` | The one systemic (time-advance) path. |
| `item.equipped` | `toggleEquipItem()` (`js/ui-render.js`) | on equip/unequip → `{slot, name}` | (a) home-only, but the emit is the render hook the IN-SERVICE STAMP (#19) rides. |
| `weight.seized` | `updateMath()` (`js/ui-core.js`) | when the `weight-over` class flips on (compare cached) → `{seized:true}` | Same in-`updateMath` threshold pattern; can be AI-triggered (AI adds heavy items) → echo. |
**Note on collectible (#22):** `collectible.acquired` already emits from `toggleCollectible()` (user). For the AI-marked-collectible path to echo, confirm/add the same emit on the autoImport collectible-add path (the U7 faction/level precedent) — folded into WAVE 1 with #22.
---
## 5. SURVEY PING timing (#26) — must be seen
**Requirement:** the home-panel SURVEY PING fires **after** the user marks a location surveyed **and is back viewing the world grid** — never while still in the zoomed node sector-sheet (`_mapActiveZone` set), and never off-screen.
**Design (zero state write):**
- `location.visited` sets a transient module var `_pendingSurveyPing = loc` (not the animation itself).
- The **echo** fires immediately (viewability — the annunciator shows `⦿ SURVEYED: <loc>` regardless of where the user is).
- The **home-panel ping** is *deferred*: `renderWorldMap()` consumes `_pendingSurveyPing` **only when it paints the WORLD GRID view** (`_mapActiveZone === null`) and that node is present — then plays the ping once and clears the var. So: marking from the zoomed sheet → ping waits until the user taps `< WORLD GRID`; AI discovery while off-map → ping waits until the user next opens the map. Either way it is actually seen. Pure transient var + a render-time consume; no `state.*`.
---
## 6. Wave / slice order (three verified pushes)
Each wave is one full pre-commit gate + push (Protocol 19). New emits land **with** the wave that first needs them, each with its regression test (Protocol 13) at both-runner parity (Protocol 15).
### WAVE 1 — the echo mechanism + the 8 Tier-S flagships *(the foundation)*
- **Build the STATUS ANNUNCIATOR** (§2): casing-top readout, suppression rule, queue/de-dupe, gate reuse, `aria-live`, `_wireFeedbackEchoSubscribers()`.
- **New emits (4):** `rad.tier`, `limb.state`, `quest.status`, `location.visited` (+ the collectible AI-path emit for #22).
- **8 Tier-S home-panel animations:** #9 VAULT-BOY LEVEL CARD · #14 REPUTATION STAMP · #23 CASE-CLOSED STAMP (+ its paired #24 FILAMENT DIE — same `quest.status` emit) · #26 SURVEY PING (+ deferred timing §5) · #4 GEIGER SPIKE · #6 X-RAY FLASH · #22 EXHIBIT LIGHT-UP · #1 FLATLINE WARNING.
- Every one of these is a **(b)** event → each also drives an annunciation, so the echo is exercised across every subsystem on day one.
### WAVE 2 — Tier-A *(mostly existing signals)*
- **New emit (1):** `item.added`.
- **Animations (9):** #2 CRT FLINCH · #10 CHUNK FILL · #11 SERVO RECALIBRATE · #12 INK STAMP · #17 CAPS ODOMETER SPIN · #18 MANIFEST PUNCH · #20 WELD SPARKS + TAG · #30 CLOCK SPIN-DOZE · #31 HOLOTAPE COMMIT.
### WAVE 3 — Tier-B / C *(polish + the remaining new emits)*
- **New emits (5):** `karma.tier`, `item.equipped`, `effect.applied`, `effect.expiring`, `weight.seized`.
- **Animations (15):** #3 STIM FLUSH · #5 RADAWAY DRAIN · #7 SPLINT WRAP · #8 BRIDGE CLANG · #13 CARD SEAT · #15 NEEDLE KICK · #16 SCALES TIP · #19 IN-SERVICE STAMP · #21 PART DROP · #25 DIRECTIVE FILED · #27 TRIANGULATE · #28 TUNGSTEN WARM-UP · #29 GUTTERING LAMP · #32 FAULT (polish) · #33 QTY DIGIT FLIP.
*(Tally: W1 = 8 flagships (+#24 bundled) · W2 = 9 · W3 = 15 → all 33. New emits: W1 = 4, W2 = 1, W3 = 5 → all 10.)*
---
## 7. Constraints baked in (every wave)
- **RobcoEvents bus only** (Protocol 22) — no forked handlers, no polling; new signals are one-line additive emits at existing setters (U7/U8).
- **Plain reduced-motion-safe `@keyframes`** — add-class → reflow → remove (the SWEEP / `_coreOneShot` pattern); the global `prefers-reduced-motion` block auto-neutralizes each to a correct final frame. Gate via the shipped `_coreShouldAnimate()` (or a shared alias) — no bespoke carve-out.
- **Zero campaign-state write** — transient DOM classes + module vars only; no `state.*`/`saveState()`/`robco_v8`. (Gate-guarded like the runtime, Suite 146-style negative grep.)
- **Game-agnostic** (Protocol 38) — glyphs/copy ride existing data (`collectibleLabel`, registry names, `GAME_DEFS`); no game literals.
- **Mobile-safe** — effects play inside their home panel or the top casing-top header; transform/opacity only; **no `position:fixed` popover over the thumb zone**; verified 360/412 zero-overflow (Protocol 10/17).
- **Audio** — syncs to the existing synth call sites (§1); no new channels.
- **Tests** — each new emit + each animation contract gets a Protocol 13 regression test in **both** runners at parity (Protocol 15), counts synced (Protocol 2a); cache bump (Protocol 1) per wave.
---
## 8. Open decisions (owner)
1. **Immersion-minimal + the echo readout.** Keep the annunciator *readout* visible at immersion=minimal (recommended — it's essential feedback, only the flourish quiets, matching the standby-coordinator `'minimal'` tier), or fully quiet the echo at minimal?
2. **Tap-to-jump.** Should tapping an annunciation jump to the reacting panel (`selectSubsystem(home)`)? Adds interactivity (becomes a real ≥28px button). Recommend **defer** to a later polish; ship WAVE 1 as a non-interactive `aria-live` readout.
3. **#27 Arrival & #30 Sleep echo.** Owner's explicit (b) list didn't name "arrival" (#27) or put sleep (#30) in (a). Recommend: **#27 gets an echo** (cross-page AI arrival is common), **#30 stays home-only** per your list (the sleep animation is deliberate + the mini-core already stirs). Confirm?
4. **SPECIAL (#11) echo noise.** SPECIAL edits are usually user-on-panel (self-suppressed), but the AI can change SPECIAL. Keep #11 wired for echo (recommended — the subsystem-suppression + de-dupe handle any noise), or make it home-only?
5. **Annunciator visible-stack depth.** One annunciation at a time (recommended — cleanest, queue handles bursts), or a shallow 2–3 stack?
6. **Echo home for save/fault (#31/#32).** Confirm we rely on the **existing** always-present tells (mini-core `data.write` pulse for saves; casing FAULT lamp for faults) rather than adding annunciations for them — avoids double-signaling.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).