Pabloler21 Claude Fable 5 commited on
Commit
9ce912d
·
1 Parent(s): 9557331

docs: front-door menu spec + plan (Direction C, modes, how-to-play, menu music)

Browse files

Plan runs on its own branch feat/front-door-menu for isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/superpowers/plans/2026-06-14-front-door-menu.md ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Front Door — Menu + Modes + How-to-Play + Menu Music — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan phase-by-phase. Steps use checkbox (`- [ ]`). **Do Phase 0 FIRST: create a dedicated branch `feat/front-door-menu` off `feat/voice-awareness` and do all work there** — if this overhaul goes wrong, `feat/voice-awareness` stays intact. Run the suite with `.venv/Scripts/python -m pytest -q`. Visuals are verified by screenshotting the RUNNING app (headless Chrome per CLAUDE.md), never the static harness. Audio is verified by ear (ask the user). Design doc: `docs/superpowers/specs/2026-06-14-front-door-menu-design.md`.
4
+ >
5
+ > **SUPERSEDES** `docs/superpowers/plans/2026-06-14-opening-chime-ritual.md` — do NOT execute that plan. With a menu, the chime→greeting ritual fires on **mode-select**, not on the first page gesture (the first gesture now lands on the menu). Phase 5 here owns the full audio flow.
6
+
7
+ **Goal:** A Fear & Hunger / Termina-**inspired** opening menu (approved Direction C) over the visitor's AI-painted foggy-wood backdrop, with two real modes — **Tester Run** (fast, judge-friendly arc) and **The Full Stay** (a genuinely longer experience) — a **How to Play** panel, a subtle eerie **menu melody** that starts on the first menu gesture, and the **chime → greeting** ritual as the "you're entering the wood" cue on mode-select. Makes the demo one click and lifts the whole thing from "Gradio app" to "a game".
8
+
9
+ **Architecture:** Two top-level Gradio views — `menu_view` (visible at start) and `game_view` (the existing game, `visible=False`). Mode-select flips visibility and initializes the game state with the chosen `mode`. `mode ∈ {"tester","full"}` drives a `PACING` config in `memory.py` that `should_recall`/`decide_ending` read (only the numbers change). The `<head>` controller starts the menu loop on first menu gesture and, on a mode button click, stops it and runs the chime→greeting ritual.
10
+
11
+ **Tech Stack:** Gradio 6.x (`gr.Column(visible=...)` view switch, buttons), CSS (menu styling + background `data:` URI), numpy audio (`sting.py` menu loop), the existing `<head>` audio-queue controller, pytest. No new deps.
12
+
13
+ **Critical caveat (CLAUDE.md):** `visible=False` can UNMOUNT a component. Only the menu (no persistent audio) and the game view toggle visibility; the game's `voice_panel`/`#idle-trigger` mount when `game_view` is shown — which is exactly when game audio is first needed. Safe. Do NOT put anything that must run while hidden behind `visible=False`.
14
+
15
+ ---
16
+
17
+ ## PHASE 0 — Isolation branch (do this before anything else)
18
+
19
+ **Why:** this overhaul restructures the whole layout, the audio flow, and the pacing engine. Keep it off the verified `feat/voice-awareness` branch so a bad run loses nothing.
20
+
21
+ - [ ] **0a.** Confirm a clean tree on `feat/voice-awareness`: `git status` (the spec + plan are already committed there).
22
+ - [ ] **0b.** Create and switch to the work branch:
23
+ ```bash
24
+ git switch -c feat/front-door-menu
25
+ ```
26
+ (If the orchestrator prefers an isolated worktree, use superpowers:using-git-worktrees instead — same effect.)
27
+ - [ ] **0c.** Every phase below commits on `feat/front-door-menu`. The merge back happens in FINAL, only after the user approves the live playthrough.
28
+
29
+ ---
30
+
31
+ ## PHASE 1 — Two views + screen switch (no styling, no audio, no mode differences yet)
32
+
33
+ **Files:** `app.py`, `tests/test_app.py`.
34
+
35
+ ### Step 1 — Wrap the game in `game_view`, add `menu_view`
36
+
37
+ - [ ] **1a.** In `app.py`, inside `with gr.Blocks(...) as demo:` and after `state = gr.State(_init_state)`, the current UI is a header `gr.Row` (title/restart/voice) + a body `gr.Row` (3 columns) + the event wiring. Wrap ALL of that existing UI in a hidden column:
38
+ ```python
39
+ with gr.Column(visible=False, elem_id="game-view") as game_view:
40
+ # ... ALL the existing header row + body row + event wiring stays here,
41
+ # unchanged, just indented one level deeper ...
42
+ ```
43
+ (Indent the existing `with gr.Row():` blocks and the `.click`/`.submit`/`idle_trigger` wiring into this column. The `demo.launch(...)` call at the bottom stays at module level, unchanged.)
44
+
45
+ - [ ] **1b.** ADD a menu column BEFORE `game_view` (so it renders first):
46
+ ```python
47
+ with gr.Column(visible=True, elem_id="menu-view") as menu_view:
48
+ menu_art = gr.HTML(_menu_html()) # Phase 2 fills this in; "" for now
49
+ tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
50
+ elem_classes="menu-btn menu-mode-btn")
51
+ full_btn = gr.Button("The Full Stay", elem_id="btn-full",
52
+ elem_classes="menu-btn menu-mode-btn")
53
+ howto_btn = gr.Button("How to Play", elem_id="btn-howto",
54
+ elem_classes="menu-btn")
55
+ ```
56
+ For Phase 1 add a placeholder `_menu_html` near the other render helpers:
57
+ ```python
58
+ def _menu_html() -> str:
59
+ return '<div class="menu-art-placeholder">HOLLOW — menu</div>'
60
+ ```
61
+
62
+ ### Step 2 — Enter-game handler
63
+
64
+ - [ ] **2a.** Add `_enter_game(mode)` near `_pristine_state` / `_init_state`:
65
+ ```python
66
+ def _enter_game(mode: str):
67
+ """Leave the menu, reveal the game, start a fresh run in the chosen mode.
68
+ Order matches the outputs list below."""
69
+ state = _init_state() # honors HOLLOW_FAST_FINALE for dev
70
+ state["mode"] = mode # "tester" | "full"
71
+ return (
72
+ gr.update(visible=False), # menu_view
73
+ gr.update(visible=True), # game_view
74
+ state,
75
+ [{"role": "assistant", "content": OPENING_LINE}], # chatbot
76
+ _render_bond(state["affinity"], get_tier(state["affinity"])),
77
+ render_treasure(state["treasure"], claimed=set(state["claimed"]),
78
+ wounds=state.get("wounds", [])),
79
+ render_entity(state["affinity"]),
80
+ )
81
+ ```
82
+ Add `"mode": "tester",` to the three state factories (`_pristine_state`, both `_init_state` branches) so reads are safe.
83
+
84
+ - [ ] **2b.** Wire both mode buttons (How to Play wired in Phase 4):
85
+ ```python
86
+ _enter_outputs = [menu_view, game_view, state, chatbot, bond_panel,
87
+ treasure_panel, entity_panel]
88
+ tester_btn.click(lambda: _enter_game("tester"), None, _enter_outputs,
89
+ show_progress="hidden")
90
+ full_btn.click(lambda: _enter_game("full"), None, _enter_outputs,
91
+ show_progress="hidden")
92
+ ```
93
+ (These outputs reference `chatbot`, `bond_panel`, etc., which are defined inside `game_view`. Define `_enter_outputs` and the `.click` wiring AFTER those components exist — i.e., at the end of the Blocks body, alongside the other event wiring.)
94
+
95
+ ### Step 3 — Tests + verify
96
+
97
+ - [ ] **3a.** In `tests/test_app.py` add:
98
+ ```python
99
+ class TestEnterGame:
100
+ def test_enter_sets_mode_and_reveals_game(self):
101
+ outs = app._enter_game("full")
102
+ assert outs[0]["visible"] is False # menu hidden
103
+ assert outs[1]["visible"] is True # game shown
104
+ assert outs[2]["mode"] == "full" # mode stored on state
105
+ assert outs[3][0]["content"] == app.OPENING_LINE
106
+ ```
107
+ - [ ] **3b. Run** `.venv/Scripts/python -m pytest -q` → green.
108
+ - [ ] **3c. Live screenshot:** launch, confirm the menu (placeholder) shows first and the game is hidden; click Tester → menu hides, game appears with the opening line. Kill the port owner.
109
+ - [ ] **3d. Commit:** `feat: front-door — menu view + game view with screen switch`.
110
+
111
+ ---
112
+
113
+ ## PHASE 2 — The background asset + Direction C styling
114
+
115
+ **Files:** `assets/background.webp` (new), `app.py` (`_menu_html`, `_BACKGROUND_URI`), `styles.css`, `tests/test_app.py`.
116
+
117
+ ### Step 1 — Process the visitor's image into a committed asset
118
+
119
+ - [ ] **1a.** Convert `assets/backroud.png` → `assets/background.webp` (grayscale + darkened to match the portraits, resized for a wide menu). Run once:
120
+ ```python
121
+ from PIL import Image, ImageEnhance
122
+ img = Image.open("assets/backroud.png").convert("L")
123
+ img = ImageEnhance.Brightness(img).enhance(0.82)
124
+ img = ImageEnhance.Contrast(img).enhance(1.06)
125
+ img = img.convert("RGB").resize((1280, 960), Image.LANCZOS) # keep ~its aspect
126
+ img.save("assets/background.webp", "WEBP", quality=82, method=6)
127
+ ```
128
+ Commit `assets/background.webp`. (HF accepts images; only committed audio binaries are rejected.) Leave `backroud.png` or delete it — your call; the app uses the `.webp`.
129
+
130
+ - [ ] **1b.** In `app.py`, near the top (after imports), load it as a base64 data URI the way portraits are embedded:
131
+ ```python
132
+ with open("assets/background.webp", "rb") as _bg:
133
+ _BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode()
134
+ ```
135
+
136
+ ### Step 2 — `_menu_html` (the C composition, visual only)
137
+
138
+ - [ ] **2a.** Replace the placeholder `_menu_html` with the real composition. The interactive buttons are separate Gradio components (Phase 1); this HTML draws the backdrop + title block + decorative text. The background lives on `#menu-view` via CSS (Step 3) so it covers the whole column; this HTML provides the centered title/eyebrow/tag + the credit + the "How to Play" corner is the Gradio `#btn-howto` styled in CSS.
139
+ ```python
140
+ def _menu_html() -> str:
141
+ return (
142
+ '<div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
143
+ '<div class="menu-title">Hollow</div>'
144
+ '<div class="menu-tag">something is here, at the edge of the wood '
145
+ '&mdash; it remembers what you give it.</div>'
146
+ '<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
147
+ )
148
+ ```
149
+
150
+ ### Step 3 — Menu CSS (§23) — Direction C
151
+
152
+ - [ ] **3a.** In `styles.css`, add a `§23 — opening menu` block. Use `_BACKGROUND_URI` by writing the CSS so the background is set inline on the column from Python is NOT possible (CSS file is static); instead set the background via a CSS custom property injected in `_HEAD_JS` OR set it inline on the `menu_art` HTML wrapper. Simplest: put the background on a full-bleed div INSIDE `_menu_html` with an inline `style="background-image:url(...)"` using the URI. Revise `_menu_html` to wrap its content:
153
+ ```python
154
+ def _menu_html() -> str:
155
+ return (
156
+ f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
157
+ '<div class="menu-scrim"></div>'
158
+ '<div class="menu-grain"></div>'
159
+ '<div class="menu-vig"></div>'
160
+ '<div class="menu-content">'
161
+ ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
162
+ ' <div class="menu-title">Hollow</div>'
163
+ ' <div class="menu-tag">something is here, at the edge of the wood '
164
+ '&mdash; it remembers what you give it.</div>'
165
+ '</div>'
166
+ '<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
167
+ )
168
+ ```
169
+ Then in `styles.css §23` style: `#menu-view{position:relative;min-height:560px;...}`, `.menu-bg{position:absolute;inset:0;background-size:cover;background-position:center 38%;}`, `.menu-scrim{...radial dark...}`, `.menu-grain`/`.menu-vig` (reuse the entity grain/vig feTurbulence + inset shadow), `.menu-content{position:relative;z-index:3;text-align:center;padding-top:64px;font-family:Georgia,serif;}`, `.menu-title{font-variant:small-caps;letter-spacing:.16em;font-weight:700;font-size:72px;color:#ece6f4;text-shadow:0 0 30px #000,0 4px 12px #000;}`, `.menu-eyebrow`/`.menu-tag`/`.menu-credit` per the mockup. Style the buttons: `.menu-btn{position:relative;z-index:4;background:transparent!important;border:0!important;box-shadow:none!important;font-family:Georgia,serif;letter-spacing:.16em;color:#c4bede;...}` with a hover that brightens + widens letter-spacing and a `▸` marker; center them under the title. Position `#btn-howto` absolutely top-right as the corner item.
170
+ - The reference look is the approved mockup `\.superpowers/brainstorm/3158-1781454014/content/c-final-bg.html` — match its scrim/title/spacing.
171
+
172
+ - [ ] **3b. Live screenshot REQUIRED (CSS lies in the harness).** Launch, screenshot the menu, Read the PNG. Confirm: background visible + dark enough, title legible over it, the three buttons read as menu items (Tester/Full centered, How to Play in the corner), grain/vignette present. Iterate on the CSS until it matches the mockup. Kill the port owner.
173
+
174
+ - [ ] **3c.** Test: `assert "menu-title" in app._menu_html()` and `assert app._BACKGROUND_URI.startswith("data:image/webp")`. Run suite → green.
175
+
176
+ - [ ] **3d. Commit:** `feat: Fear & Hunger-inspired opening menu (Direction C) with painted backdrop`.
177
+
178
+ ---
179
+
180
+ ## PHASE 3 — Mode mechanics (Tester vs Full pacing)
181
+
182
+ **Files:** `memory.py`, `tests/test_memory.py`, `tools/pacing_sim.py`.
183
+
184
+ ### Step 1 — `PACING` config + mode-aware gates
185
+
186
+ - [ ] **1a. Failing tests** (append to `tests/test_memory.py`):
187
+ ```python
188
+ def test_full_mode_recall_waits_for_two_memories():
189
+ from memory import should_recall
190
+ one = {"affinity": 30, "treasure": ["a"], "claimed": [], "turn": 1,
191
+ "last_recall_turn": None, "mode": "full"}
192
+ assert should_recall(one)[0] is False # full needs >=2 before first recall
193
+ two = dict(one, treasure=["a", "bb"])
194
+ assert should_recall(two)[0] is True
195
+
196
+ def test_full_mode_ending_gate_is_higher():
197
+ from memory import decide_ending
198
+ mid = {"affinity": 55, "claimed": ["a", "b"], "turn": 8, "tone": 25,
199
+ "wounds": [], "ended": False, "mode": "full"}
200
+ assert decide_ending(mid) is None # 55 < full gate 68
201
+ hi = dict(mid, affinity=70, claimed=["a", "b", "c"])
202
+ assert decide_ending(hi) == "good"
203
+
204
+ def test_tester_mode_unchanged():
205
+ from memory import should_recall, decide_ending
206
+ s = {"affinity": 24, "treasure": ["x"], "claimed": [], "turn": 1,
207
+ "last_recall_turn": None, "mode": "tester"}
208
+ assert should_recall(s)[0] is True # tester: first memory -> recall
209
+ e = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25,
210
+ "wounds": [], "ended": False, "mode": "tester"}
211
+ assert decide_ending(e) == "good"
212
+ ```
213
+
214
+ - [ ] **1b. Run, verify fail.**
215
+
216
+ - [ ] **1c. Implement.** In `memory.py`, add the config and make the two functions mode-aware:
217
+ ```python
218
+ PACING = {
219
+ "tester": {"recall_min_memories": 1, "recall_cooldown": 2,
220
+ "end_affinity": 50, "end_claimed": 2, "bad_min_turn": 4},
221
+ "full": {"recall_min_memories": 2, "recall_cooldown": 3,
222
+ "end_affinity": 68, "end_claimed": 3, "bad_min_turn": 6},
223
+ }
224
+
225
+ def _cfg(state: dict) -> dict:
226
+ return PACING.get(state.get("mode", "tester"), PACING["tester"])
227
+ ```
228
+ `should_recall`:
229
+ ```python
230
+ def should_recall(state: dict) -> tuple[bool, str | None]:
231
+ cfg = _cfg(state)
232
+ unclaimed = [m for m in state["treasure"] if m not in state["claimed"]]
233
+ if len(unclaimed) < cfg["recall_min_memories"]:
234
+ return False, None
235
+ richest = max(unclaimed, key=len)
236
+ last = state.get("last_recall_turn")
237
+ if last is None:
238
+ return True, richest
239
+ if state["turn"] - last >= cfg["recall_cooldown"]:
240
+ return True, richest
241
+ return False, None
242
+ ```
243
+ `decide_ending` (replace the two numeric gates with `cfg`):
244
+ ```python
245
+ cfg = _cfg(state)
246
+ tone = state.get("tone", 0)
247
+ if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
248
+ return "bad"
249
+ if state["affinity"] >= cfg["end_affinity"] and len(state["claimed"]) >= cfg["end_claimed"]:
250
+ return "good" if tone >= 20 else "loop"
251
+ return None
252
+ ```
253
+
254
+ - [ ] **1d.** Update existing `decide_ending`/`should_recall` tests that pass states without `mode` — they default to "tester" via `_cfg`, so they should still pass; fix any that hard-coded the old 50/4 expecting a different mode.
255
+
256
+ - [ ] **1e.** `tools/pacing_sim.py`: add a `--mode` (or loop both) so it sets `state["mode"]`. Run it for both modes; record the measured arc lengths in the commit message. Confirm Tester ~5-7, Full ~8-12.
257
+
258
+ - [ ] **1f. Run** the suite → green. **Commit:** `feat: Tester vs Full pacing modes (config-driven gates)`.
259
+
260
+ ---
261
+
262
+ ## PHASE 4 — How to Play overlay
263
+
264
+ **Files:** `app.py`, `styles.css`, `tests/test_app.py`.
265
+
266
+ - [ ] **1a.** In `app.py`, inside `menu_view` (after the buttons), add a hidden overlay group:
267
+ ```python
268
+ with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay:
269
+ gr.HTML(_HOWTO_HTML)
270
+ howto_close = gr.Button("close", elem_id="btn-howto-close",
271
+ elem_classes="menu-btn")
272
+ ```
273
+ Add the copy constant near the other text constants:
274
+ ```python
275
+ _HOWTO_HTML = (
276
+ '<div class="howto-panel">'
277
+ '<h3>How to Play</h3>'
278
+ '<p>You found a child at the edge of the wood. It has no memories of its own '
279
+ '&mdash; so it asks for yours.</p>'
280
+ '<p>Tell it something true: a person, a place, a moment you lived. It keeps each '
281
+ 'one in its treasure.</p>'
282
+ '<p>Later it speaks your memories back &mdash; in the first person, as if it had '
283
+ 'lived them. That is what it wants.</p>'
284
+ '<p>But it remembers how you treat it. Be gentle, or be cruel. '
285
+ '<b>Three endings</b> wait in the fog.</p>'
286
+ '<p class="howto-controls">type and press enter &middot; 🔊 mute &middot; '
287
+ '↺ begin again &middot; turn your sound on</p>'
288
+ '</div>'
289
+ )
290
+ ```
291
+
292
+ - [ ] **1b.** Wire open/close (visibility toggles — no audio, safe):
293
+ ```python
294
+ howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay,
295
+ show_progress="hidden")
296
+ howto_close.click(lambda: gr.update(visible=False), None, howto_overlay,
297
+ show_progress="hidden")
298
+ ```
299
+
300
+ - [ ] **1c.** `styles.css §24`: `#howto-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;background:#04040ae0;}` and `.howto-panel{max-width:560px;border:11px solid transparent;border-image:var(--stone-frame) 30;background:var(--stone-base);padding:26px 30px;font-family:Georgia,serif;color:#c4bede;line-height:1.6;}` (reuse the carved-stone frame vars). Style `h3`, `.howto-controls` (smaller, muted, letter-spaced).
301
+
302
+ - [ ] **1d. Tests:** `assert "Three endings" in app._HOWTO_HTML`; open returns `visible True`, close returns `visible False`. Run suite → green.
303
+ - [ ] **1e. Live screenshot:** How to Play opens a readable stone panel over the menu; close returns. **Commit:** `feat: How to Play overlay`.
304
+
305
+ ---
306
+
307
+ ## PHASE 5 — Menu music + chime→greeting ritual on mode-select
308
+
309
+ **Files:** `sting.py`, `tests/test_sting.py`, `app.py`, `tests/test_app.py`.
310
+
311
+ This phase OWNS the audio flow (superseding the standalone chime-ritual plan): the menu loop starts on the first menu gesture; choosing a mode stops it and plays **chime → greeting** as the entrance, then the game (already revealed by Phase 1) takes over with its per-reply voice.
312
+
313
+ ### Step 1 — Synthesize the menu loop
314
+
315
+ - [ ] **1a. Failing test** (`tests/test_sting.py`): `assert len(menu_loop_wav_bytes()) > 2000`.
316
+ - [ ] **1b.** In `sting.py`, add `menu_loop_wav_bytes()`: a slow, low, detuned music-box / drone motif (a few minor-key notes over a soft low drone, light reverb), 1 dB-quiet, built to **loop seamlessly** (start and end at silence/zero-crossing). Reuse the existing `_wav` writer and the chime/sine helpers already in the file. Keep it short (~6-10 s) so the base64 stays small.
317
+ - [ ] **1c. Run** the sting test → green.
318
+
319
+ ### Step 2 — Embed the loop + chime/greeting constants
320
+
321
+ - [ ] **2a.** In `app.py`, build the audio constants (the chime + greeting were previously the `_greeting_html` element; consolidate as `data:` URI constants like the planned chime-ritual):
322
+ ```python
323
+ from sting import chime_wav_bytes, menu_loop_wav_bytes
324
+ _CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode()
325
+ _GREETING_B64 = speak(OPENING_LINE) or ""
326
+ _MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode()
327
+ _CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}"
328
+ _GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else ""
329
+ _MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}"
330
+ ```
331
+ Remove the now-obsolete `_greeting_html` function and its `#hollow-greeting` element in `voice_panel` (set `voice_panel = gr.HTML(value="", elem_classes="voice-channel")`). Remove the `test_greeting_html_*` tests.
332
+ - [ ] **2b.** Put the menu loop element inside `menu_view`: `gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>')` (no autoplay — the controller starts it on gesture).
333
+
334
+ ### Step 3 — Rework the `<head>` controller audio flow
335
+
336
+ - [ ] **3a.** In `_HEAD_JS`, inject the three URIs via placeholders (build with `.replace`, NOT f-string): `var CHIME_SRC="__CHIME__"; var GREETING_SRC="__GREETING__"; var MENU_SRC="__MENU__";` and after the string: `_HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI)`.
337
+ - [ ] **3b.** Add to the controller (next to the queue helpers):
338
+ ```javascript
339
+ var menuAudio = null;
340
+ function startMenuMusic() {
341
+ if (menuAudio || !MENU_SRC) return;
342
+ menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC);
343
+ menuAudio.loop = true; menuAudio.volume = st.muted ? 0 : 0.5;
344
+ menuAudio.muted = st.muted;
345
+ menuAudio.play().catch(function () {});
346
+ }
347
+ function stopMenuMusic() {
348
+ if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; }
349
+ }
350
+ var _lastRitual = 0;
351
+ function openingRitual() { // chime, then the child greets
352
+ var now = Date.now(); if (now - _lastRitual < 800) return; _lastRitual = now;
353
+ resetQueue();
354
+ if (CHIME_SRC) enqueue(CHIME_SRC);
355
+ if (GREETING_SRC) enqueue(GREETING_SRC);
356
+ }
357
+ ```
358
+ - [ ] **3c.** REPLACE the current first-gesture greeting handler. The first menu gesture now starts the **menu music** (not the greeting):
359
+ ```javascript
360
+ function firstGesture() {
361
+ startMenuMusic();
362
+ window.removeEventListener('pointerdown', firstGesture, true);
363
+ window.removeEventListener('keydown', firstGesture, true);
364
+ }
365
+ window.addEventListener('pointerdown', firstGesture, true);
366
+ window.addEventListener('keydown', firstGesture, true);
367
+ ```
368
+ - [ ] **3d.** Extend `wire()` (the poller that already wires `.voice-btn`/`.restart-btn`) to also wire the mode buttons: when `.menu-mode-btn` elements exist, add a click listener that does `stopMenuMusic(); openingRitual();` (do NOT stopPropagation — the server `_enter_game` must still run). Restart (`.restart-btn`) keeps calling `openingRitual()` (re-greet on "begin again", which restarts the current game in place).
369
+ - [ ] **3e.** Mute: `applyLive()` already covers queued audio; also have the mute toggle set `menuAudio.muted` if `menuAudio` exists.
370
+
371
+ ### Step 4 — Tests + verify
372
+
373
+ - [ ] **4a.** `tests/test_app.py`: `assert "openingRitual" in app._HEAD_JS`, `assert "startMenuMusic" in app._HEAD_JS`, `assert "menu-loop" in app._menu_html() or "menu-loop" in <the menu audio html>`, `assert app._MENU_LOOP_URI.startswith("data:audio/wav")`. Remove obsolete greeting-html tests. Run suite → green.
374
+ - [ ] **4b. Live (by ear, user):** first menu gesture → eerie loop starts (low); click Tester/Full → loop stops, chime → child greets, game shows and streams replies; 🔊 mutes everything (menu + voice); "begin again" re-greets. Kill the port owner.
375
+ - [ ] **4c. Commit:** `feat: menu melody + chime-greeting ritual on mode select`.
376
+
377
+ ---
378
+
379
+ ## FINAL — verification & docs
380
+
381
+ - [ ] **1.** `.venv/Scripts/python -m pytest -q` → all green.
382
+ - [ ] **2.** Full live playthrough (`.venv-tts` + Ollama) with the user: menu (Direction C, painted backdrop, melody) → How to Play reads → pick **Tester** → chime→greeting → fast arc (wow ~turn 2, ending ~5-7); restart, pick **Full** → longer arc (~8-12). Mute/idle/streaming all intact. Confirm all three finales still fire with sound.
383
+ - [ ] **3.** Update `CLAUDE.md` + `AGENTS.md`: new front-door menu (Direction C + `assets/background.webp`), `mode` in state + `PACING` config (tester/full), How to Play overlay, menu loop + ritual-on-mode-select; note this superseded the standalone chime-ritual plan; `_greeting_html` removed. Update file structure + state shape + tests count. Keep ≤200 lines.
384
+ - [ ] **4.** **Merge back, with the user's go-ahead** (REQUIRED SUB-SKILL: superpowers:finishing-a-development-branch): once all phases pass and the user approves the live playthrough, merge `feat/front-door-menu` → `feat/voice-awareness` (fast-forward or `--no-ff`, the finishing skill decides). If the user is unhappy with the result, the branch is simply abandoned and `feat/voice-awareness` is untouched. Do NOT merge to `main` here — `main` and the deploy wait until the whole branch (incl. the prior streaming/pacing work) has had a live playthrough.
385
+ - [ ] **5.** Deploy is out of scope — the user runs the mirror-first deploy after the merge, approving the playthrough (and Phase C streaming Space-verification from the prior plan).
386
+
387
+ ---
388
+
389
+ ## Self-review notes
390
+ - **Spec coverage:** menu (P1+P2), modes (P3), how-to-play (P4), menu music + chime ritual (P5) — every spec component has a phase; each phase is independently shippable and verified.
391
+ - **Supersession is explicit:** the chime-ritual plan is NOT executed; this plan owns the ritual and re-points it to mode-select (the only place a greeting makes sense once a menu exists).
392
+ - **Unmount caveat respected:** only menu/game/how-to toggle `visible`; none hold audio that must run while hidden — game audio mounts when `game_view` shows.
393
+ - **Modes are config-only:** `PACING` + `_cfg(state)`; `should_recall`/`decide_ending` read `state["mode"]`; no call-path changes, so the streaming/extraction/finale logic is untouched. Tester reproduces the current arc exactly (default).
394
+ - **`.replace`, not f-string** for `_HEAD_JS` (the JS body has literal `{}`); placeholders filled after the string is defined.
395
+ - **Audio = images-are-fine / synth-the-rest:** background is a committed WebP (allowed); the menu loop is synthesized at runtime (`sting.py`), no committed audio binary.
396
+ - **Visuals screenshot-verified, audio by ear** — per the project's hard-won rules; the approved mockup at `.superpowers/brainstorm/3158-1781454014/content/c-final-bg.html` is the visual reference.
docs/superpowers/specs/2026-06-14-front-door-menu-design.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Front Door — Menu, Modes, How-to-Play, Menu Music — Design
2
+
3
+ **Date:** 2026-06-14 · **Project:** Hollow (Build Small Hackathon)
4
+
5
+ ## Goal
6
+ Give Hollow a real "front door": a Fear & Hunger / Termina-**inspired** (not copied) opening menu over an AI-generated foggy-wood background, with two play modes — **Tester Run** (fast, judge-friendly) and **The Full Stay** (a genuinely longer experience) — a **How to Play** panel, a subtle eerie **menu melody**, and the existing **chime** as the "you're entering" cue when a mode is chosen. This makes the demo trivial to run (pick Tester) and lifts the whole thing from "homemade Gradio" to "a game".
7
+
8
+ ## Approved visual direction
9
+ Direction **C** ("Cold Vista", Termina-style): a wide grayscale painted forest backdrop (path → ruined monument in fog), gothic small-caps **HOLLOW** title centered with the track eyebrow above, a tight vertical menu, "How to Play" in a corner, a quiet credit line, all under a radial **scrim** + top/bottom gradients (text legibility over any image) + grain + vignette. The visitor-supplied image (`assets/backroud.png`) is the backdrop, post-processed to grayscale + darkened to match the portraits.
10
+
11
+ ## Architecture
12
+ - **Two top-level views in the Blocks:** `menu_view` (visible at start) and `game_view` (the existing 3-column game, `visible=False` at start). Selecting a mode flips both visibilities and initializes the game state with the chosen mode.
13
+ - **Audio-unmount caveat (CLAUDE.md):** `visible=False` can unmount a component, so the game's `voice_panel` / `#idle-trigger` must NOT be needed while hidden. They aren't — game audio only matters once `game_view` is shown, at which point they mount and the `<head>` controller's MutationObserver picks audio up. Safe.
14
+ - **Mode is state.** `state["mode"] ∈ {"tester","full"}`. A `PACING` config dict (in `memory.py`) maps each mode to its thresholds; `should_recall` and `decide_ending` read `state.get("mode","tester")` and look the values up. No engine call paths change — only the numbers.
15
+ - **The opening ritual moves to mode-select.** Today chime→greeting fires on the first page gesture. With a menu, the page's first gesture lands on the MENU; so: menu melody starts on the first menu gesture; choosing a mode **stops the melody, runs the chime→greeting ritual, and reveals the game**. The `<head>` controller gains listeners on the mode buttons (same pattern as the restart button).
16
+
17
+ ## Components
18
+ 1. **Menu screen** — `menu_view` rendered as a single `gr.HTML` (the C composition, fully styled, background embedded) PLUS three real `gr.Button`s (Tester / Full / How to Play) positioned over it via CSS, so clicks hit the server. Visual is CSS; the background is the post-processed WebP as a base64 `data:` URI in a Python constant (same approach as the portraits).
19
+ 2. **Mode mechanics** — `PACING` config:
20
+
21
+ | key | tester | full | meaning |
22
+ |-----|--------|------|---------|
23
+ | `recall_min_memories` | 1 | 2 | unclaimed memories needed before the first recall |
24
+ | `recall_cooldown` | 2 | 3 | turns between recalls |
25
+ | `end_affinity` | 50 | 68 | affinity gate for good/loop |
26
+ | `end_claimed` | 2 | 3 | claimed memories for good/loop |
27
+ | `bad_min_turn` | 4 | 6 | min turn for the wound ending |
28
+
29
+ Tester reproduces today's arc (wow ~turn 2, ending ~5-7). Full lands ~turn 8-12. Both branch good/loop at tone 20 and bad at tone ≤ −30 ∧ wounds ≥ 2, unchanged.
30
+ 3. **How to Play overlay** — a dismissible panel over the menu. Copy (in the game's voice):
31
+ > You found a child at the edge of the wood. It has no memories of its own — so it asks for yours.
32
+ > Tell it something true: a person, a place, a moment you lived. It keeps each in its treasure.
33
+ > Later it speaks your memories back — in the first person, as if it had lived them. That is what it wants.
34
+ > But it remembers how you treat it. Be gentle, or be cruel. Three endings wait in the fog.
35
+ > *type and press enter · 🔊 mute · ↺ begin again · turn your sound on*
36
+ 4. **Menu music + chime** — a slow, detuned music-box / drone **loop** synthesized in `sting.py` (`menu_loop_wav_bytes()`), embedded in the menu as a looped `<audio>`; the `<head>` controller starts it on the first menu gesture (autoplay gate), at low volume, and stops it on mode-select. Mode-select then plays the existing chime → greeting ritual and transitions to the game.
37
+
38
+ ## Testing
39
+ - **Unit:** `PACING` config exists and `should_recall`/`decide_ending` honor `mode` (tester vs full gates); menu state init sets `mode`; mode-select returns the right visibility updates. How-to-play open/close. `menu_loop_wav_bytes()` returns real audio.
40
+ - **Live (screenshot):** the menu renders with the background + scrim + title + buttons legible; the switch to game works.
41
+ - **By ear (user):** menu melody on first gesture; chime→greeting on mode-select; game voice/idle/mute unaffected.
42
+ - **Pacing re-measure:** run `tools/pacing_sim.py` for both modes; confirm Tester ~5-7 and Full ~8-12.
43
+
44
+ ## Risks / constraints
45
+ - **`visible=False` unmount** — only the menu (no persistent audio) and the game view toggle; game audio mounts on show. Verified pattern.
46
+ - **Autoplay** — menu music can't play before a gesture; it starts on the first menu interaction (consistent with the greeting rule).
47
+ - **Space build** — the background is an **image** (WebP), which HF accepts (only committed audio binaries are rejected); the menu melody is synthesized at runtime like the other stings. No new deps.
48
+ - **Scope** — five phases, each shippable; Phase 3 (modes) is the only engine-touching one and is pure-number config.
49
+
50
+ ## Out of scope (deliberate)
51
+ - Re-painting the in-game scene (the game already has its stone/fog styling); this is the menu/front-door. A light F&H typography pass in-game can follow later if wanted.
52
+ - Save/continue, multiple save slots, settings screens. YAGNI for a hackathon front door.