Pabloler21 Claude Opus 4.8 commited on
Commit
fe45fde
·
1 Parent(s): 121e41a

docs: intro/menu polish + audio plans (this session)

Browse files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

docs/superpowers/plans/2026-06-14-frame-blend-audible-music.md ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bigger Blended Frame + Audible Seamless Dread Music
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`).
4
+
5
+ **Goal:** (1) Enlarge the cinematic frame and feather **all** edges so the image no longer looks small and no rectangular cut shows. (2) Fix the menu music: make it audible on any speaker (it was near-inaudible pure sub-bass) and kill the loop seam (a fade-dip + non-integer LFO made an audible cut every loop), keeping the deep-dread direction.
6
+
7
+ **Architecture:** `styles.css` for the frame (size + mask); `sting.py` `menu_loop_wav_bytes()` rewrite + a small playback-volume bump in `app.py`'s `_HEAD_JS`. No model/state/logic.
8
+
9
+ **Tech Stack:** CSS `mask-image` radial fade + `min()`/`vh` sizing; numpy audio synth (integer-cycle components for a gapless loop).
10
+
11
+ **Verification note:** Visual by eye, audio **by ear** — both by the user (`.venv-tts`); headless/audio unverifiable here (CLAUDE.md). Task 3 is the checklist.
12
+
13
+ **Locked (brainstorming):** music = *pure dread drone, made audible + seamless* (no melody, no wind); frame = *bigger + all edges feathered*.
14
+
15
+ ---
16
+
17
+ ### Task 1: Bigger frame, all edges feathered
18
+
19
+ **Files:** Modify `styles.css` (§25 `#intro-frame`, `#intro-image`; §23 `#menu-frame`)
20
+
21
+ Enlarge the frame and start the mask fade earlier / reach transparent further in, so every edge dissolves into the blurred backdrop (same scene), and the sharp area is larger.
22
+
23
+ - [ ] **Step 1: Enlarge the intro frame**
24
+
25
+ In §25 `#intro-frame`, change `width: min(1080px, 92vw);` to `width: min(1280px, 95vw);` and `max-height: 74vh;` to `max-height: 82vh;`.
26
+
27
+ - [ ] **Step 2: Stronger, all-edge mask on the intro image**
28
+
29
+ In §25 `#intro-image`, replace both mask lines with:
30
+
31
+ ```css
32
+ -webkit-mask-image: radial-gradient(ellipse 76% 78% at 50% 47%, #000 30%, transparent 86%);
33
+ mask-image: radial-gradient(ellipse 76% 78% at 50% 47%, #000 30%, transparent 86%);
34
+ ```
35
+
36
+ - [ ] **Step 3: Match the menu frame (size + mask)**
37
+
38
+ In §23 `#menu-frame`, change `width: min(1080px, 92vw);` to `width: min(1280px, 95vw);` and `max-height: 74vh;` to `max-height: 82vh;`, and replace both mask lines with:
39
+
40
+ ```css
41
+ -webkit-mask-image: radial-gradient(ellipse 76% 78% at 50% 47%, #000 30%, transparent 86%);
42
+ mask-image: radial-gradient(ellipse 76% 78% at 50% 47%, #000 30%, transparent 86%);
43
+ ```
44
+
45
+ - [ ] **Step 4: Sanity — app suite green**
46
+
47
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
48
+ Expected: PASS
49
+
50
+ - [ ] **Step 5: Commit**
51
+
52
+ ```bash
53
+ git add styles.css
54
+ git commit -m "style(frames): larger frame, feather all edges into the backdrop"
55
+ ```
56
+
57
+ ---
58
+
59
+ ### Task 2: Audible, seamless dread drone
60
+
61
+ **Files:**
62
+ - Modify: `sting.py` (`menu_loop_wav_bytes()`)
63
+ - Modify: `app.py` (`_HEAD_JS` playback volume, 2 spots)
64
+ - Test: `tests/test_sting.py`
65
+
66
+ The bed was pure sub-bass (inaudible on laptop speakers) and faded to silence at the loop ends (an audible dip). Add a low-mid hollow-fifth body that any speaker reproduces, raise the level, and make every component integer-cycle over the 16 s loop so **no fade is needed** — the waveform is continuous at the seam.
67
+
68
+ - [ ] **Step 1: Rewrite `menu_loop_wav_bytes()`**
69
+
70
+ In `sting.py`, replace the whole `menu_loop_wav_bytes()` function with:
71
+
72
+ ```python
73
+ def menu_loop_wav_bytes() -> bytes:
74
+ """Deep dread menu bed, made audible: a dark hollow-fifth body in the
75
+ low-mids (heard on any speaker) over a felt sub, with a slow dissonant
76
+ swell and a distant toll. Seamless loop — every sustained component is
77
+ integer-cycle over the 16 s loop, so no fade-dip is needed at the seam."""
78
+ sr = 22050
79
+ dur = 16.0
80
+ t = np.linspace(0, dur, int(sr * dur), endpoint=False)
81
+
82
+ # slow breathing, exactly 2 cycles over the loop (continuous at the seam)
83
+ breath = 0.8 + 0.2 * np.sin(2 * np.pi * (2.0 / dur) * t)
84
+
85
+ # 1. sub (felt): 41 + 27.5 Hz (41*16, 27.5*16 are integers)
86
+ sub = (np.sin(2 * np.pi * 41.0 * t)
87
+ + 0.6 * np.sin(2 * np.pi * 27.5 * t)) * 0.16 * breath
88
+
89
+ # 2. audible body: a hollow fifth in the low-mids (65.5/98/131 Hz + harmonics)
90
+ def tone(f):
91
+ return (np.sin(2 * np.pi * f * t)
92
+ + 0.4 * np.sin(2 * np.pi * 2 * f * t)
93
+ + 0.18 * np.sin(2 * np.pi * 3 * f * t))
94
+ body = (tone(65.5) + 0.8 * tone(98.0) + 0.5 * tone(131.0)) * 0.07 * breath
95
+
96
+ # 3. slow dissonant swell: two close tones beating (minor-second tension)
97
+ swell_env = np.sin(np.pi * np.clip(t / dur, 0, 1)) ** 1.5 # 0 at both ends
98
+ swell = (np.sin(2 * np.pi * 110.0 * t)
99
+ + np.sin(2 * np.pi * 116.5 * t)) * 0.05 * swell_env
100
+
101
+ # 4. distant toll ~every 5.3 s: struck tone, long decay, far and quiet
102
+ toll = np.zeros_like(t)
103
+ for c in (1.0, 6.3, 11.6):
104
+ env = np.where(t >= c, np.exp(-1.8 * (t - c)), 0.0)
105
+ strike = (np.sin(2 * np.pi * 98.0 * t)
106
+ + 0.5 * np.sin(2 * np.pi * 147.0 * t)
107
+ + 0.25 * np.sin(2 * np.pi * 196.0 * t))
108
+ toll += strike * env
109
+ echo = np.zeros_like(t) # room tail
110
+ for delay, gain in ((0.28, 0.30), (0.56, 0.15)):
111
+ ds = int(delay * sr)
112
+ echo[ds:] += toll[:-ds] * gain
113
+ toll = (toll + echo) * 0.14
114
+
115
+ mix = sub + body + swell + toll
116
+ mix = np.tanh(1.5 * mix)
117
+ mix = mix / np.max(np.abs(mix)) * 0.5 # audible; no end-fade -> seamless
118
+ return _wav(mix, sr)
119
+ ```
120
+
121
+ - [ ] **Step 2: Bump the playback volume so the bed is present**
122
+
123
+ In `app.py` `_HEAD_JS`, there are two spots with `0.15`. In `startMenuMusic` change `menuAudio.volume = st.muted ? 0 : 0.15;` to `menuAudio.volume = st.muted ? 0 : 0.2;`. In the mute-button handler change `if (menuAudio) { menuAudio.muted = st.muted; menuAudio.volume = st.muted ? 0 : 0.15; }` to use `0.2` as well.
124
+
125
+ - [ ] **Step 3: Run the sting tests**
126
+
127
+ Run: `.venv/Scripts/python -m pytest tests/test_sting.py -q`
128
+ Expected: PASS
129
+
130
+ - [ ] **Step 4: Write a preview to listen (user)**
131
+
132
+ Run: `.venv/Scripts/python sting.py` → writes `menu_loop_preview.wav`. The user listens: audible body, distant toll, and — playing it on loop — **no dip/click at the seam**.
133
+
134
+ - [ ] **Step 5: Commit**
135
+
136
+ ```bash
137
+ git add sting.py app.py
138
+ git commit -m "feat(audio): audible seamless dread drone + louder menu bed"
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Task 3: Live verification (user — eye + ear)
144
+
145
+ **Files:** none (manual).
146
+
147
+ - [ ] **Step 1: Launch** — `.venv-tts/Scripts/python app.py` with Ollama running.
148
+
149
+ - [ ] **Step 2: Confirm (eye)** — the frame is noticeably **larger**; **all** edges dissolve into the blurred forest (no rectangle, no visible cut) on menu and intro cards.
150
+
151
+ - [ ] **Step 3: Confirm (ear)** — the menu music is clearly **audible** (dark low-mid body + distant toll), slow and heavy, and the loop has **no dip/click** at the seam.
152
+
153
+ - [ ] **Step 4: Tune if needed** — frame `max-height` / mask `%`, or music levels (`* 0.5`, body `0.07`), toll spacing, or JS `0.2`. Commit any tweak.
154
+
155
+ ---
156
+
157
+ ## Self-Review
158
+
159
+ - **Spec coverage:** image looked small + edges cut → bigger frame + all-edge feather (Task 1) ✅; music inaudible → low-mid hollow-fifth body + level up + JS volume up (Task 2) ✅; loop seam → integer-cycle components + no end-fade, swell/toll zero at the seam (Task 2) ✅; direction kept (pure dread, no wind/melody) ✅.
160
+ - **Placeholder scan:** none; full function + concrete values.
161
+ - **Seam reasoning:** sustained tones (41, 27.5, 65.5/131/196, 98/294, 110, 116.5 Hz) all have `f * 16` integer → continuous value & slope at the loop point; `breath` = 2 cycles over `dur`; `swell_env` and every `toll` strike are ~0 at both ends; `tanh` is memoryless so it preserves periodicity. Hence no fade is required and none is applied.
162
+ - **Name consistency:** `menu_loop_wav_bytes()` keeps its signature + `_wav` helper; the two `0.15`→`0.2` edits are the only `menuAudio.volume` assignments; mask/size edits target the verified `#intro-frame`/`#intro-image`/`#menu-frame` rules.
docs/superpowers/plans/2026-06-14-intro-cinematic-letterbox.md ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Intro Cinematic Letterbox — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Reframe the opening intro cards as a centered cinematic window with black letterbox bands, a blurred "ghost of the card" backdrop filling those bands, and a repositioned subtitle/choice layout — so the images stop bleeding full-screen, the card-1 traveler is visible, and the whole sequence reads as a polished cutscene.
6
+
7
+ **Architecture:** Presentation-only change across three coupled surfaces that must land together: (1) the `gr.HTML` markup string in `app.py` gains two wrapper divs (`#intro-bg` ambient backdrop + `#intro-frame` centered window); (2) the `<head>` JS `showIntroCard` sets the same image on the backdrop and applies a per-card focal `background-position`; (3) `styles.css` §25 is rewritten for the letterbox frame, blurred backdrop, softer image gradient, and frame-anchored subtitle/choice. No model, state, or pacing logic changes. Decisions are locked from brainstorming: **blurred ambient backdrop** + **~16:10 wide cinematic frame**.
8
+
9
+ **Tech Stack:** Gradio 6.x `gr.HTML` + `gr.Column`; vanilla `<head>` JS injected via `launch(head=...)`; pure CSS (`aspect-ratio`, `filter: blur`, `object`/`background` cover); pytest for the one guard test.
10
+
11
+ **Verification note:** Per CLAUDE.md, CSS/JS rendering and audio are verified **live by the user** (`.venv-tts` + Ollama) — headless screenshots are unreliable here. Only Task 1 has an automated test (a structural guard); Tasks 2–4 are confirmed in the Task 5 live playthrough.
12
+
13
+ ---
14
+
15
+ ### Task 1: Per-card focal positions + card data
16
+
17
+ **Files:**
18
+ - Modify: `app.py` (near `_INTRO_IMAGES`, ~line 214; and the cards-JSON build, ~line 446-448)
19
+ - Test: `tests/test_app.py`
20
+
21
+ Each source image has a different subject placement, and a 16:10 cover-crop of a 4:3 / 1:1 source will clip the wrong part unless we steer it. Card 0 (*threshold*) has the hooded traveler at the bottom; card 3 (*meeting*) is a 1:1 face whose eyes sit in the upper-middle. A per-card `background-position` keeps each subject in frame.
22
+
23
+ - [ ] **Step 1: Write the failing test**
24
+
25
+ In `tests/test_app.py`, add:
26
+
27
+ ```python
28
+ def test_intro_pos_aligns_with_cards():
29
+ import app
30
+ # one focal position per card, all non-empty strings
31
+ assert len(app._INTRO_POS) == len(app.INTRO_CARDS)
32
+ assert all(isinstance(p, str) and p for p in app._INTRO_POS)
33
+ # the card payload carries img, text, and pos for every card
34
+ import json
35
+ cards = json.loads(app._INTRO_CARDS_JSON)
36
+ assert len(cards) == len(app.INTRO_CARDS)
37
+ assert all({"img", "text", "pos"} <= set(c) for c in cards)
38
+ ```
39
+
40
+ - [ ] **Step 2: Run test to verify it fails**
41
+
42
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_intro_pos_aligns_with_cards -v`
43
+ Expected: FAIL with `AttributeError: module 'app' has no attribute '_INTRO_POS'`
44
+
45
+ - [ ] **Step 3: Add `_INTRO_POS` next to `_INTRO_IMAGES`**
46
+
47
+ In `app.py`, immediately after the `_INTRO_IMAGES = [ ... ]` list (ends ~line 219), add:
48
+
49
+ ```python
50
+ # Per-card focal point for the 16:10 cover-crop (CSS background-position).
51
+ # Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting.
52
+ _INTRO_POS = ["center 80%", "center", "center", "center 32%"]
53
+ ```
54
+
55
+ - [ ] **Step 4: Thread `pos` into the cards JSON**
56
+
57
+ In `app.py`, change the cards-JSON build (~line 446-448) from:
58
+
59
+ ```python
60
+ _INTRO_CARDS_JSON = json.dumps(
61
+ [{"img": _INTRO_IMAGES[i], "text": INTRO_CARDS[i]} for i in range(len(INTRO_CARDS))]
62
+ )
63
+ ```
64
+
65
+ to:
66
+
67
+ ```python
68
+ _INTRO_CARDS_JSON = json.dumps(
69
+ [{"img": _INTRO_IMAGES[i], "text": INTRO_CARDS[i], "pos": _INTRO_POS[i]}
70
+ for i in range(len(INTRO_CARDS))]
71
+ )
72
+ ```
73
+
74
+ - [ ] **Step 5: Run test to verify it passes**
75
+
76
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_intro_pos_aligns_with_cards -v`
77
+ Expected: PASS
78
+
79
+ - [ ] **Step 6: Commit**
80
+
81
+ ```bash
82
+ git add app.py tests/test_app.py
83
+ git commit -m "feat(intro): per-card focal positions for cinematic crop"
84
+ ```
85
+
86
+ ---
87
+
88
+ ### Task 2: Restructure the intro markup (backdrop + frame)
89
+
90
+ **Files:**
91
+ - Modify: `app.py` (the `intro_view` `gr.HTML`, ~line 937-942)
92
+
93
+ The current markup is `#intro-stage > #intro-image + #intro-panel`. We wrap the image in a centered `#intro-frame` and add a sibling `#intro-bg` for the blurred backdrop. The subtitle panel moves *inside* the frame so it anchors to the frame's bottom edge (not the viewport).
94
+
95
+ - [ ] **Step 1: Replace the intro HTML string**
96
+
97
+ In `app.py`, change:
98
+
99
+ ```python
100
+ gr.HTML(
101
+ '<div id="intro-stage">'
102
+ ' <div id="intro-image"></div>'
103
+ ' <div id="intro-panel"><p id="intro-text"></p></div>'
104
+ '</div>'
105
+ )
106
+ ```
107
+
108
+ to:
109
+
110
+ ```python
111
+ gr.HTML(
112
+ '<div id="intro-stage">'
113
+ ' <div id="intro-bg"></div>'
114
+ ' <div id="intro-frame">'
115
+ ' <div id="intro-image"></div>'
116
+ ' <div id="intro-panel"><p id="intro-text"></p></div>'
117
+ ' </div>'
118
+ '</div>'
119
+ )
120
+ ```
121
+
122
+ The `#intro-choice` and `#intro-skip` Gradio buttons (below the `gr.HTML`) are **unchanged** — they stay absolute-positioned within `#intro-stage` via CSS.
123
+
124
+ - [ ] **Step 2: Verify the app still imports**
125
+
126
+ Run: `.venv/Scripts/python -c "import app; print('intro-bg' in app.__dict__ or 'ok')"`
127
+ Expected: prints `ok` (no import error; the string change can't break import, this just confirms the module loads)
128
+
129
+ - [ ] **Step 3: Run the full suite to confirm nothing regressed**
130
+
131
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
132
+ Expected: PASS (all existing app tests green)
133
+
134
+ - [ ] **Step 4: Commit**
135
+
136
+ ```bash
137
+ git add app.py
138
+ git commit -m "feat(intro): wrap card in centered frame + ambient backdrop div"
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Task 3: JS — drive the backdrop image + per-card focal position
144
+
145
+ **Files:**
146
+ - Modify: `app.py` (the `showIntroCard` function inside `_HEAD_JS`, ~line 342-350)
147
+
148
+ `showIntroCard` currently sets only `#intro-image`'s background. It must now also set `#intro-bg`'s background to the same URL (so the blurred backdrop matches the card) and apply the card's focal `pos` to `#intro-image`.
149
+
150
+ - [ ] **Step 1: Replace `showIntroCard`**
151
+
152
+ In `app.py`, inside the `_HEAD_JS` string, change:
153
+
154
+ ```javascript
155
+ function showIntroCard(cardIndex, isLast) {
156
+ var card = INTRO[cardIndex];
157
+ var img = document.getElementById('intro-image');
158
+ var txt = document.getElementById('intro-text');
159
+ var choice = document.getElementById('intro-choice');
160
+ if (choice) choice.classList.remove('intro-choice-show');
161
+ if (img) img.style.backgroundImage = card.img ? "url('" + card.img + "')" : '';
162
+ if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); });
163
+ }
164
+ ```
165
+
166
+ to:
167
+
168
+ ```javascript
169
+ function showIntroCard(cardIndex, isLast) {
170
+ var card = INTRO[cardIndex];
171
+ var img = document.getElementById('intro-image');
172
+ var bg = document.getElementById('intro-bg');
173
+ var txt = document.getElementById('intro-text');
174
+ var choice = document.getElementById('intro-choice');
175
+ if (choice) choice.classList.remove('intro-choice-show');
176
+ var url = card.img ? "url('" + card.img + "')" : '';
177
+ if (img) {
178
+ img.style.backgroundImage = url;
179
+ img.style.backgroundPosition = card.pos || 'center';
180
+ }
181
+ if (bg) bg.style.backgroundImage = url;
182
+ if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); });
183
+ }
184
+ ```
185
+
186
+ - [ ] **Step 2: Confirm the shipped JS contains the new lines**
187
+
188
+ Run: `.venv/Scripts/python -c "import app; assert 'intro-bg' in app._HEAD_JS and 'backgroundPosition' in app._HEAD_JS; print('js ok')"`
189
+ Expected: prints `js ok`
190
+
191
+ - [ ] **Step 3: Commit**
192
+
193
+ ```bash
194
+ git add app.py
195
+ git commit -m "feat(intro): JS sets ambient backdrop + per-card focal position"
196
+ ```
197
+
198
+ ---
199
+
200
+ ### Task 4: CSS — rewrite §25 for the cinematic letterbox
201
+
202
+ **Files:**
203
+ - Modify: `styles.css` (§25, the block from `#intro-view` through `#intro-choice.intro-choice-show`)
204
+
205
+ Rewrite the **layout** portion of §25. Keep the existing `.intro-tablet`, `.intro-tablet:hover`, `#intro-skip`, and `#intro-skip:hover` rules **as-is** (they style the buttons, not the layout).
206
+
207
+ - [ ] **Step 1: Replace the §25 layout block**
208
+
209
+ In `styles.css`, replace everything from `#intro-view {` down to the end of `#intro-choice.intro-choice-show { ... }` with:
210
+
211
+ ```css
212
+ /* ── 25. Opening intro sequence (cinematic letterbox) ── */
213
+ #intro-view {
214
+ position: relative;
215
+ min-height: 100vh;
216
+ display: flex;
217
+ flex-direction: column;
218
+ background: #000;
219
+ }
220
+ #intro-view.hide, #intro-view.hidden { display: none !important; }
221
+
222
+ #intro-stage {
223
+ position: relative;
224
+ flex: 1;
225
+ min-height: 100vh;
226
+ cursor: pointer; /* click anywhere to advance */
227
+ overflow: hidden;
228
+ display: flex;
229
+ align-items: center; /* center the frame -> letterbox bands */
230
+ justify-content: center;
231
+ background: #000;
232
+ }
233
+
234
+ /* blurred ambient backdrop: a ghost of the same card, fills the bands */
235
+ #intro-bg {
236
+ position: absolute;
237
+ inset: -6%; /* overscan so blur edges never show a gap */
238
+ background-position: center;
239
+ background-size: cover;
240
+ background-repeat: no-repeat;
241
+ filter: grayscale(1) blur(42px) brightness(0.32);
242
+ transform: scale(1.08);
243
+ z-index: 0;
244
+ }
245
+ #intro-bg::after { /* darken so the framed card pops */
246
+ content: '';
247
+ position: absolute;
248
+ inset: 0;
249
+ background: radial-gradient(ellipse at center,
250
+ rgba(0,0,0,0.20) 0%, rgba(0,0,0,0.80) 100%);
251
+ }
252
+
253
+ /* centered cinematic window (~16:10) */
254
+ #intro-frame {
255
+ position: relative;
256
+ z-index: 1;
257
+ width: min(1080px, 92vw);
258
+ aspect-ratio: 16 / 10;
259
+ max-height: 74vh;
260
+ overflow: hidden;
261
+ border-radius: 3px;
262
+ box-shadow: 0 0 60px 12px rgba(0,0,0,0.7),
263
+ 0 0 0 1px rgba(120,110,140,0.12);
264
+ }
265
+
266
+ #intro-image {
267
+ position: absolute;
268
+ inset: 0;
269
+ background-position: center; /* JS overrides with the card focal point */
270
+ background-size: cover;
271
+ background-repeat: no-repeat;
272
+ /* fallback when a card image is missing/empty: */
273
+ background-color: #04040a;
274
+ background-image: radial-gradient(ellipse at 50% 35%, #16131c 0%, #04040a 80%);
275
+ filter: grayscale(1) brightness(0.9);
276
+ }
277
+ #intro-image::after { /* soft bottom darkening so the subtitle reads */
278
+ content: '';
279
+ position: absolute;
280
+ inset: 0;
281
+ background: linear-gradient(transparent 55%, rgba(4,4,10,0.85) 96%);
282
+ }
283
+
284
+ /* subtitle panel: anchored to the bottom of the frame */
285
+ #intro-panel {
286
+ position: absolute;
287
+ left: 50%;
288
+ bottom: 16px;
289
+ transform: translateX(-50%);
290
+ width: min(680px, 88%);
291
+ z-index: 2;
292
+ border: 10px solid transparent;
293
+ border-image: var(--stone-frame) 30;
294
+ background: var(--stone-base);
295
+ padding: 15px 22px;
296
+ }
297
+ #intro-text {
298
+ font-family: Georgia, serif;
299
+ font-size: 1.05rem;
300
+ line-height: 1.6;
301
+ color: #d6d0e0;
302
+ margin: 0;
303
+ min-height: 3.2em; /* reserve height so the panel doesn't grow while typing */
304
+ }
305
+
306
+ /* choice tablets: in the lower letterbox band, below the frame */
307
+ #intro-choice {
308
+ position: absolute;
309
+ left: 50%;
310
+ bottom: 3.5%;
311
+ transform: translateX(-50%);
312
+ z-index: 3;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 8px;
316
+ align-items: center;
317
+ opacity: 0; /* hidden until the last card finishes typing */
318
+ pointer-events: none;
319
+ transition: opacity 0.6s ease;
320
+ }
321
+ #intro-choice.intro-choice-show {
322
+ opacity: 1;
323
+ pointer-events: auto;
324
+ }
325
+ ```
326
+
327
+ - [ ] **Step 2: Confirm the `.intro-tablet` / `#intro-skip` rules survived**
328
+
329
+ Run: `grep -n "intro-tablet\|#intro-skip" styles.css`
330
+ Expected: the `.intro-tablet`, `.intro-tablet:hover`, `#intro-skip`, `#intro-skip:hover` rules still present below the replaced block.
331
+
332
+ - [ ] **Step 3: Commit**
333
+
334
+ ```bash
335
+ git add styles.css
336
+ git commit -m "feat(intro): cinematic letterbox frame + blurred backdrop CSS"
337
+ ```
338
+
339
+ ---
340
+
341
+ ### Task 5: Live verification (user)
342
+
343
+ **Files:** none (manual)
344
+
345
+ Headless verification is unreliable here (CLAUDE.md). The user runs the app and confirms by eye. This task is a checklist for the live playthrough, not an automated step.
346
+
347
+ - [ ] **Step 1: Launch with voice + a real model**
348
+
349
+ Run (user): `.venv-tts/Scripts/python app.py` with Ollama running (`qwen3:8b`).
350
+
351
+ - [ ] **Step 2: Confirm the letterbox layout**
352
+
353
+ - The card sits in a **centered window** with black bands above and below (not full-bleed).
354
+ - The bands show a **blurred, darkened ghost** of the same card (not dead black).
355
+ - **Card 1 (threshold):** the hooded traveler is visible (focal `center 80%` keeps him in frame).
356
+ - **Card 4 (meeting):** the child's face is framed, not overwhelming; eyes + reaching hand visible (`center 32%`).
357
+ - The stone-framed **subtitle panel** sits at the bottom of the frame; the **choice tablets** appear in the lower band, below the frame, not over the child.
358
+ - **skip** still works (top band); click-to-advance / click-to-complete still works.
359
+ - Tester run shows only Threshold + Meeting; Full shows all four.
360
+
361
+ - [ ] **Step 3: Tune focal points if needed**
362
+
363
+ If a subject is still clipped, adjust the matching entry in `_INTRO_POS` (Task 1) — e.g. nudge `"center 32%"` → `"center 28%"`. Re-run. Commit any tweak:
364
+
365
+ ```bash
366
+ git add app.py
367
+ git commit -m "tune(intro): adjust card focal positions after live check"
368
+ ```
369
+
370
+ ---
371
+
372
+ ## Self-Review
373
+
374
+ - **Spec coverage:** images smaller (centered 16:10 frame, Task 4) ✅; black bands top/bottom (letterbox via flex-center, Task 4) ✅; background treatment (blurred ambient backdrop, Tasks 2-4) ✅; better arranged (panel anchored to frame, choices in lower band, Task 4) ✅; card-1 character visible (focal `center 80%` + softer gradient + more visible frame, Tasks 1+4) ✅.
375
+ - **Placeholder scan:** no TBD/TODO; every code step shows full code.
376
+ - **Type/name consistency:** `_INTRO_POS` (Task 1) is read by the cards-JSON (Task 1) and consumed as `card.pos` in JS (Task 3); `#intro-bg` / `#intro-frame` created in markup (Task 2), referenced in JS (Task 3) and CSS (Task 4) with identical ids.
docs/superpowers/plans/2026-06-14-intro-menu-blend-music.md ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Focus-Pull Blend, Button Bands, Menu Legibility & Dread Music
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`).
4
+
5
+ **Goal:** Four polish items from the live review: (1) kill the full-width hover band on `skip` and `close`; (2) make the faint menu lines (`AN ADVENTURE…`, `A LOST CHILD WAITS…`, `How to Play`) legible over the bright fog; (3) blend the sharp central frame into the blurred backdrop (focus-pull, no floating rectangle); (4) replace the windy menu music with a deep, slow, oppressive dread drone.
6
+
7
+ **Architecture:** Presentation + one audio rewrite. `styles.css` for (1)(2)(3); `sting.py` `menu_loop_wav_bytes()` rewrite for (4). No model/state/logic.
8
+
9
+ **Tech Stack:** CSS (`mask-image` radial fade, `width:fit-content` to defeat Gradio's full-width buttons, text-shadow halos); numpy audio synth.
10
+
11
+ **Verification note:** Visuals by eye, audio **by ear** — both by the user (`.venv-tts`); headless/audio can't be verified here (CLAUDE.md). Task 5 is the checklist.
12
+
13
+ **Locked decisions (brainstorming):** frame↔backdrop = *focus-pull, no border*; music = *deep dread drone (no wind)*. Skip/close fix = CSS width (no markup churn).
14
+
15
+ ---
16
+
17
+ ### Task 1: Kill the full-width hover band on `skip` and `close`
18
+
19
+ **Files:** Modify `styles.css` (§25 `#intro-skip`; §23/24 add `#btn-howto-close`)
20
+
21
+ Gradio buttons default to full width, so their hover/pill background spans the screen. Constraining the button to `fit-content` makes the background hug the text. (`How to Play` is already an HTML `.menu-opt` row, so only `skip` and `close` remain real buttons.)
22
+
23
+ - [ ] **Step 1: Constrain the skip pill**
24
+
25
+ In `styles.css` §25 `#intro-skip`, add these two lines inside the rule:
26
+
27
+ ```css
28
+ width: fit-content !important;
29
+ min-width: 0 !important;
30
+ ```
31
+
32
+ (It stays `position:absolute; top:18px; right:22px`, so it hugs its text at the top-right.)
33
+
34
+ - [ ] **Step 2: Constrain the close button**
35
+
36
+ In `styles.css`, near the other `#btn-howto*` rules, add:
37
+
38
+ ```css
39
+ #btn-howto-close {
40
+ display: block !important;
41
+ width: fit-content !important;
42
+ min-width: 0 !important;
43
+ margin: 6px auto 0 !important;
44
+ }
45
+ ```
46
+
47
+ - [ ] **Step 3: Commit**
48
+
49
+ ```bash
50
+ git add styles.css
51
+ git commit -m "style: skip/close hug their text, no full-width hover band"
52
+ ```
53
+
54
+ ---
55
+
56
+ ### Task 2: Menu line legibility (halos + stronger scrim)
57
+
58
+ **Files:** Modify `styles.css` (§23: `#menu-card::before`, `.menu-eyebrow`, `.menu-tag`, `.menu-opt-muted`, `.menu-credit`)
59
+
60
+ These lines wash out because they're light-on-bright-fog. Add dark halos (so they read on any value) and deepen the card scrim.
61
+
62
+ - [ ] **Step 1: Deepen + widen the scrim**
63
+
64
+ In `#menu-card::before`, change `inset: -48px -72px;` to `inset: -56px -90px;` and the gradient to:
65
+
66
+ ```css
67
+ background: radial-gradient(ellipse at center,
68
+ rgba(4,4,10,0.82) 0%, rgba(4,4,10,0.0) 70%);
69
+ ```
70
+
71
+ - [ ] **Step 2: Halo the eyebrow and tagline**
72
+
73
+ - `.menu-eyebrow`: change its `text-shadow` to `text-shadow: 0 0 12px rgba(0,0,0,0.95), 0 1px 4px #000;`
74
+ - `.menu-tag`: change its `text-shadow` to `text-shadow: 0 0 14px rgba(0,0,0,0.9), 0 1px 6px #000;`
75
+
76
+ - [ ] **Step 3: Brighten + halo "How to Play" and the credit**
77
+
78
+ - `.menu-opt-muted`: change `color: #7a7290;` to `color: #b8b0cc;` and add `text-shadow: 0 0 10px #000, 0 1px 3px #000;`
79
+ - `.menu-opt-muted:hover`: change `color: #b0a8c8;` to `color: #e0d9ee;`
80
+ - `.menu-credit`: change `color: #5a5070;` to `color: #8c84a4;` and add `text-shadow: 0 1px 6px #000;`
81
+
82
+ - [ ] **Step 4: Commit**
83
+
84
+ ```bash
85
+ git add styles.css
86
+ git commit -m "style(menu): dark halos + deeper scrim so faint lines read"
87
+ ```
88
+
89
+ ---
90
+
91
+ ### Task 3: Focus-pull blend (dissolve the frame into the backdrop)
92
+
93
+ **Files:** Modify `styles.css` (§25 `#intro-image`, `#intro-frame`, remove `#intro-frame::after`; §23 `#menu-frame`, remove `#menu-frame::after`)
94
+
95
+ The sharp frame reads as a rectangle on a separate dark blur. Masking the sharp image's edges to transparent reveals the **same** blurred scene behind it, so the sharp center dissolves into its own blur — one continuous forest, no border, no seam. Remove the card border/shadow and the previous fog `::after` (the mask replaces it).
96
+
97
+ - [ ] **Step 1: Mask the intro image and strip the frame chrome**
98
+
99
+ In §25 `#intro-image`, add:
100
+
101
+ ```css
102
+ -webkit-mask-image: radial-gradient(ellipse 72% 74% at 50% 46%, #000 50%, transparent 88%);
103
+ mask-image: radial-gradient(ellipse 72% 74% at 50% 46%, #000 50%, transparent 88%);
104
+ ```
105
+
106
+ In §25 `#intro-frame`, change `border-radius: 3px;` to `border-radius: 0;` and `box-shadow: 0 0 60px 12px rgba(0,0,0,0.7), 0 0 0 1px rgba(120,110,140,0.12);` to `box-shadow: none;`.
107
+
108
+ Delete the entire `#intro-frame::after { ... }` rule (added last round).
109
+
110
+ - [ ] **Step 2: Mask the menu frame and strip its chrome**
111
+
112
+ In §23 `#menu-frame`, add:
113
+
114
+ ```css
115
+ -webkit-mask-image: radial-gradient(ellipse 72% 74% at 50% 46%, #000 50%, transparent 88%);
116
+ mask-image: radial-gradient(ellipse 72% 74% at 50% 46%, #000 50%, transparent 88%);
117
+ ```
118
+
119
+ In §23 `#menu-frame`, change `border-radius: 3px;` to `border-radius: 0;` and its `box-shadow: ...;` to `box-shadow: none;`.
120
+
121
+ Delete the entire `#menu-frame::after { ... }` rule (added last round).
122
+
123
+ - [ ] **Step 3: Sanity — app suite green**
124
+
125
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
126
+ Expected: PASS
127
+
128
+ - [ ] **Step 4: Commit**
129
+
130
+ ```bash
131
+ git add styles.css
132
+ git commit -m "style(frames): focus-pull mask blends sharp center into backdrop"
133
+ ```
134
+
135
+ ---
136
+
137
+ ### Task 4: Deep dread menu music (no wind)
138
+
139
+ **Files:** Modify `sting.py` (`menu_loop_wav_bytes()`)
140
+ - Test: `tests/test_sting.py`
141
+
142
+ Replace the windy bed with a heavy sub drone, a slow dissonant swell, and a distant low toll every few seconds — slow and oppressive, no noise texture.
143
+
144
+ - [ ] **Step 1: Confirm the existing validity test covers the function**
145
+
146
+ Run: `.venv/Scripts/python -m pytest tests/test_sting.py -q`
147
+ Expected: PASS (there is already a WAV-validity test for `menu_loop_wav_bytes`; if not, add one mirroring the other sting tests asserting it returns non-empty bytes starting with `b"RIFF"`).
148
+
149
+ - [ ] **Step 2: Rewrite `menu_loop_wav_bytes()`**
150
+
151
+ In `sting.py`, replace the whole `menu_loop_wav_bytes()` function with:
152
+
153
+ ```python
154
+ def menu_loop_wav_bytes() -> bytes:
155
+ """Deep dread menu bed: a heavy sub drone with a slow dissonant swell and a
156
+ distant low toll every few seconds. No wind/noise — slow and oppressive,
157
+ built to loop seamlessly."""
158
+ sr = 22050
159
+ dur = 16.0
160
+ t = np.linspace(0, dur, int(sr * dur), endpoint=False)
161
+
162
+ # 1. heavy sub drone: 41 Hz + 27.5 Hz sub + soft octave, slow breathing
163
+ breath = 0.82 + 0.18 * np.sin(2 * np.pi * 0.05 * t) # ~20 s breath
164
+ drone = (np.sin(2 * np.pi * 41.0 * t)
165
+ + 0.6 * np.sin(2 * np.pi * 27.5 * t)
166
+ + 0.3 * np.sin(2 * np.pi * 82.0 * t)) * 0.18 * breath
167
+
168
+ # 2. slow dissonant swell: two close low tones beating (minor-second dread)
169
+ swell_env = np.sin(np.pi * np.clip(t / dur, 0, 1)) ** 1.5
170
+ swell = (np.sin(2 * np.pi * 110.0 * t)
171
+ + np.sin(2 * np.pi * 116.5 * t)) * 0.05 * swell_env
172
+
173
+ # 3. distant low toll every ~5.3 s: struck tone, long decay, far and quiet
174
+ toll = np.zeros_like(t)
175
+ for c in (1.0, 6.3, 11.6):
176
+ env = np.where(t >= c, np.exp(-1.8 * (t - c)), 0.0)
177
+ strike = (np.sin(2 * np.pi * 98.0 * t)
178
+ + 0.5 * np.sin(2 * np.pi * 146.8 * t)
179
+ + 0.25 * np.sin(2 * np.pi * 196.0 * t))
180
+ toll += strike * env
181
+ echo = np.zeros_like(t) # room tail
182
+ for delay, gain in ((0.28, 0.30), (0.56, 0.15)):
183
+ ds = int(delay * sr)
184
+ echo[ds:] += toll[:-ds] * gain
185
+ toll = (toll + echo) * 0.12
186
+
187
+ mix = drone + swell + toll
188
+ fade = int(0.9 * sr) # seamless loop
189
+ mix[:fade] *= np.linspace(0, 1, fade)
190
+ mix[-fade:] *= np.linspace(1, 0, fade)
191
+ mix = np.tanh(1.8 * mix)
192
+ mix = mix / np.max(np.abs(mix)) * 0.22
193
+ return _wav(mix, sr)
194
+ ```
195
+
196
+ - [ ] **Step 3: Run the sting tests**
197
+
198
+ Run: `.venv/Scripts/python -m pytest tests/test_sting.py -q`
199
+ Expected: PASS
200
+
201
+ - [ ] **Step 4: (Optional) write a preview to listen**
202
+
203
+ Run: `.venv/Scripts/python sting.py` → writes `menu_loop_preview.wav`. The user listens (this file is git-ignored / not committed).
204
+
205
+ - [ ] **Step 5: Commit**
206
+
207
+ ```bash
208
+ git add sting.py
209
+ git commit -m "feat(audio): deep dread menu drone (sub + dissonant swell + distant toll)"
210
+ ```
211
+
212
+ ---
213
+
214
+ ### Task 5: Live verification (user — eye + ear)
215
+
216
+ **Files:** none (manual).
217
+
218
+ - [ ] **Step 1: Launch** — `.venv-tts/Scripts/python app.py` with Ollama running.
219
+
220
+ - [ ] **Step 2: Confirm (eye)**
221
+
222
+ - `skip` and `close` hover/background hug the text — **no screen-wide band**.
223
+ - Menu lines `AN ADVENTURE…`, the tagline, `How to Play`, and `A LOST CHILD WAITS…` all read clearly over the fog.
224
+ - Intro cards + menu: the sharp center **dissolves into the blurred forest** — no floating rectangle, no hard border.
225
+
226
+ - [ ] **Step 3: Confirm (ear)**
227
+
228
+ - Menu music is a **slow, heavy, oppressive drone** with a distant toll — **no windy hiss**. Tune in `sting.py` if needed (toll spacing `(1.0, 6.3, 11.6)`, levels, `dur`).
229
+
230
+ - [ ] **Step 4: Commit any tweak**
231
+
232
+ ```bash
233
+ git add styles.css sting.py
234
+ git commit -m "tune: live polish (blend / music)"
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Self-Review
240
+
241
+ - **Spec coverage:** full-width band on skip+close (Task 1, `fit-content`) ✅; faint menu lines (Task 2, halos + scrim) ✅; frame↔backdrop disconnect (Task 3, focus-pull mask, no border) ✅; windy music → dread drone (Task 4) ✅.
242
+ - **Placeholder scan:** none; concrete values / full function body given.
243
+ - **Name consistency:** edits target verified selectors — `#intro-skip` (§25), `#btn-howto-close` (new, matches `elem_id="btn-howto-close"`), `.menu-opt-muted` / `.menu-credit` / `#menu-card::before` / `.menu-eyebrow` / `.menu-tag` (§23), `#intro-image` / `#intro-frame` / `#menu-frame` and the two `::after` rules added last round (removed here). `menu_loop_wav_bytes()` keeps its signature and `_wav` helper.
244
+ - **Mask behavior:** masking `#intro-image` reveals `#intro-bg` (z0, behind the frame); masking `#menu-frame` reveals `.menu-bg` (z0) — both the same scene blurred, so the fade is seamless. The dialogue panel/title sit above and are unaffected (mask is on the image/frame only).
docs/superpowers/plans/2026-06-14-intro-menu-game-aesthetic.md ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Aesthetic Pass — Menu Legibility, Intro Framing, Game Grayscale
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`).
4
+
5
+ **Goal:** A cohesion + legibility pass: (1) menu text reads clearly over the bright fog; (2) the intro skip button is visible; (3) frame edges melt into fog instead of a hard rectangular crop; (4) the intro dialogue box becomes translucent with the subject focal raised, so it stops covering the child; (5) the game-view sheds its purple/magenta accents for a cold grayscale palette that matches the menu/intro.
6
+
7
+ **Architecture:** Pure presentation. Mostly `styles.css`; one `app.py` change (raise intro focal points in `_INTRO_POS`). No model/state/pacing/logic changes.
8
+
9
+ **Tech Stack:** Gradio 6.x; CSS (radial-gradient fog masks, translucent panels, `backdrop-filter`); the existing `_INTRO_POS` array (already guarded by a test).
10
+
11
+ **Verification note:** Visuals verified **live by the user** (`.venv-tts` + Ollama); headless unreliable (CLAUDE.md). Task 6 is the by-eye checklist.
12
+
13
+ **Locked decisions (brainstorming):** dialogue = *translucent box + raised focal* (keep the overlapping look, mitigate coverage); game = *desaturate to gray (tone-match)*. Intro options + stone border stay. Deadline is tomorrow (15 Jun) → scope is deliberately the lighter tone-match, not a full reskin.
14
+
15
+ ---
16
+
17
+ ### Task 1: Menu text legibility (brighten + local scrim)
18
+
19
+ **Files:** Modify `styles.css` (§23: `.menu-eyebrow`, `.menu-tag`, `.menu-opt`, new `#menu-card::before`)
20
+
21
+ The menu ink is purple-gray over a bright fog, so it washes out. Brighten the text and drop a soft radial scrim behind the card so light text separates from the fog.
22
+
23
+ - [ ] **Step 1: Add a scrim behind the card**
24
+
25
+ In `styles.css` §23, immediately after the `#menu-card { ... }` rule, add:
26
+
27
+ ```css
28
+ #menu-card::before {
29
+ content: '';
30
+ position: absolute;
31
+ inset: -48px -72px;
32
+ z-index: -1;
33
+ pointer-events: none;
34
+ background: radial-gradient(ellipse at center,
35
+ rgba(4,4,10,0.74) 0%, rgba(4,4,10,0.0) 72%);
36
+ }
37
+ ```
38
+
39
+ - [ ] **Step 2: Brighten the eyebrow, tagline, and option rows**
40
+
41
+ In §23, change these three colors:
42
+
43
+ - `.menu-eyebrow`'s `color: #7a6a92;` → `color: #c6bcda;` and add `text-shadow: 0 1px 6px #000;`
44
+ - `.menu-tag`'s `color: #a098b8;` → `color: #dad3e8;` and add `text-shadow: 0 1px 8px rgba(0,0,0,0.85);`
45
+ - `.menu-opt`'s `color: #c4bede;` → `color: #ded8ec;` and add `text-shadow: 0 1px 6px #000;`
46
+
47
+ - [ ] **Step 3: Commit**
48
+
49
+ ```bash
50
+ git add styles.css
51
+ git commit -m "style(menu): brighten ink + scrim behind card for legibility"
52
+ ```
53
+
54
+ ---
55
+
56
+ ### Task 2: Visible skip button
57
+
58
+ **Files:** Modify `styles.css` (§25: `#intro-skip`, `#intro-skip:hover`)
59
+
60
+ `#intro-skip` is near-invisible dark gray on a dark band. Give it a subtle pill (translucent bg + hairline border + brighter text).
61
+
62
+ - [ ] **Step 1: Restyle `#intro-skip`**
63
+
64
+ In §25, replace the `#intro-skip { ... }` rule (keep `position/top/right/z-index/font-family/letter-spacing/font-size`) so the visual props become:
65
+
66
+ ```css
67
+ #intro-skip {
68
+ position: absolute;
69
+ top: 18px;
70
+ right: 22px;
71
+ z-index: 4;
72
+ font-family: Georgia, serif !important;
73
+ letter-spacing: 0.14em !important;
74
+ font-size: 0.82rem !important;
75
+ color: #cdc7da !important;
76
+ background: rgba(8,6,14,0.55) !important;
77
+ border: 1px solid rgba(150,142,170,0.35) !important;
78
+ box-shadow: none !important;
79
+ border-radius: 14px !important;
80
+ padding: 5px 16px !important;
81
+ text-shadow: 0 1px 4px #000 !important;
82
+ }
83
+ ```
84
+
85
+ And replace `#intro-skip:hover { ... }` with:
86
+
87
+ ```css
88
+ #intro-skip:hover {
89
+ color: #f2eefa !important;
90
+ background: rgba(30,24,42,0.82) !important;
91
+ border-color: rgba(190,178,210,0.6) !important;
92
+ }
93
+ ```
94
+
95
+ - [ ] **Step 2: Commit**
96
+
97
+ ```bash
98
+ git add styles.css
99
+ git commit -m "style(intro): visible skip pill"
100
+ ```
101
+
102
+ ---
103
+
104
+ ### Task 3: Frame edges melt into fog (no hard crop)
105
+
106
+ **Files:** Modify `styles.css` (§23 `#menu-frame`, §25 `#intro-frame`)
107
+
108
+ The sharp rectangular frame edge reads as a cut. A radial fog overlay inside each frame fades the edges into the dark blurred backdrop, so the scene dissolves into mist instead of being clipped.
109
+
110
+ - [ ] **Step 1: Add a fog overlay to the intro frame**
111
+
112
+ In §25, immediately after the `#intro-frame { ... }` rule, add:
113
+
114
+ ```css
115
+ #intro-frame::after {
116
+ content: '';
117
+ position: absolute;
118
+ inset: 0;
119
+ z-index: 1; /* above the image, below the panel (z2) */
120
+ pointer-events: none;
121
+ background: radial-gradient(ellipse 80% 78% at 50% 44%,
122
+ transparent 50%, rgba(10,10,16,0.5) 78%, rgba(8,8,14,0.94) 100%);
123
+ }
124
+ ```
125
+
126
+ - [ ] **Step 2: Add the same fog to the menu frame**
127
+
128
+ In §23, immediately after the `#menu-frame { ... }` rule, add:
129
+
130
+ ```css
131
+ #menu-frame::after {
132
+ content: '';
133
+ position: absolute;
134
+ inset: 0;
135
+ z-index: 1; /* above the forest, below the card (z5) */
136
+ pointer-events: none;
137
+ background: radial-gradient(ellipse 80% 78% at 50% 44%,
138
+ transparent 50%, rgba(10,10,16,0.5) 78%, rgba(8,8,14,0.94) 100%);
139
+ }
140
+ ```
141
+
142
+ - [ ] **Step 3: Commit**
143
+
144
+ ```bash
145
+ git add styles.css
146
+ git commit -m "style(frames): fog-feather edges so images melt into mist"
147
+ ```
148
+
149
+ ---
150
+
151
+ ### Task 4: Translucent intro dialogue + raised focal points
152
+
153
+ **Files:**
154
+ - Modify: `styles.css` (§25 `#intro-panel`, `#intro-image::after`)
155
+ - Modify: `app.py` (`_INTRO_POS`, ~line 220)
156
+
157
+ Make the panel translucent (image shows through) and raise each subject so the box sits over the lower, emptier part of the frame instead of the child's face.
158
+
159
+ - [ ] **Step 1: Make the panel translucent with a blur backing**
160
+
161
+ In §25, in `#intro-panel`, change `background: var(--stone-base);` to:
162
+
163
+ ```css
164
+ background: rgba(16,14,24,0.62);
165
+ backdrop-filter: blur(3px);
166
+ -webkit-backdrop-filter: blur(3px);
167
+ ```
168
+
169
+ and soften its size: change `padding: 15px 22px;` to `padding: 13px 20px;` and `bottom: 16px;` to `bottom: 14px;`.
170
+
171
+ - [ ] **Step 2: Lighten the image's bottom gradient (the panel now provides contrast)**
172
+
173
+ In §25, in `#intro-image::after`, change the gradient to end softer:
174
+
175
+ ```css
176
+ background: linear-gradient(transparent 60%, rgba(4,4,10,0.55) 98%);
177
+ ```
178
+
179
+ - [ ] **Step 3: Raise the focal points so subjects sit higher**
180
+
181
+ In `app.py`, change `_INTRO_POS` to:
182
+
183
+ ```python
184
+ # Per-card focal point for the 16:10 cover-crop (CSS background-position).
185
+ # Raised so each subject sits high and the dialogue box covers emptier area.
186
+ # Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting.
187
+ _INTRO_POS = ["center 72%", "center 38%", "center 38%", "center 20%"]
188
+ ```
189
+
190
+ - [ ] **Step 4: Guard test still passes**
191
+
192
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_intro_pos_aligns_with_cards -v`
193
+ Expected: PASS
194
+
195
+ - [ ] **Step 5: Commit**
196
+
197
+ ```bash
198
+ git add styles.css app.py
199
+ git commit -m "style(intro): translucent dialogue + raised focal so art stays visible"
200
+ ```
201
+
202
+ ---
203
+
204
+ ### Task 5: Game-view to cold grayscale (drop the purple)
205
+
206
+ **Files:** Modify `styles.css` (§11 bond: `.bond-tier`, `.bond-fill`, `.bond-beat`, `@keyframes heartbeat`; § button `:active`)
207
+
208
+ The bond bar and a couple of hovers are the only purple-tinted elements (the treasure already uses the gray stone frame). Recolor them to bone/cold-gray so the game matches the menu/intro.
209
+
210
+ - [ ] **Step 1: Recolor the bond tier label**
211
+
212
+ In `styles.css`, change `.bond-tier`'s `color: #9a6faa;` to `color: #bcb4c8;` and its `text-shadow: 0 0 12px rgba(120,60,160,0.4);` to `text-shadow: 0 0 12px rgba(150,145,170,0.35);`.
213
+
214
+ - [ ] **Step 2: Recolor the bond fill**
215
+
216
+ In `.bond-fill`, change:
217
+ - `background: linear-gradient(to right, #3a2152, #9a6faa);` → `background: linear-gradient(to right, #2b2832, #bcb4c8);`
218
+ - `box-shadow: 0 0 10px rgba(120,60,160,0.5);` → `box-shadow: 0 0 10px rgba(180,174,196,0.35);`
219
+
220
+ - [ ] **Step 3: Recolor the bond beat**
221
+
222
+ In `.bond-beat`, change:
223
+ - `background: #b07fc0;` → `background: #cdc6d8;`
224
+ - `box-shadow: 0 0 8px rgba(150,80,190,0.7);` → `box-shadow: 0 0 8px rgba(190,184,206,0.6);`
225
+
226
+ - [ ] **Step 4: Recolor the heartbeat keyframes glow**
227
+
228
+ Replace the `@keyframes heartbeat { ... }` block with:
229
+
230
+ ```css
231
+ @keyframes heartbeat {
232
+ 0%, 100% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(180,174,196,0.40); }
233
+ 8% { transform: scaleY(1.5); box-shadow: 0 0 14px rgba(205,198,216,0.85); }
234
+ 18% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(180,174,196,0.40); }
235
+ 30% { transform: scaleY(1.25); box-shadow: 0 0 9px rgba(195,188,208,0.62); }
236
+ 45% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(180,174,196,0.35); }
237
+ }
238
+ ```
239
+
240
+ - [ ] **Step 5: Neutralize the button :active accent**
241
+
242
+ In `.gradio-container button:active`, change `color: #efe2fb !important;` → `color: #ece9f2 !important;` and `border-color: #8a5cb0 !important;` → `border-color: #6f6a80 !important;`.
243
+
244
+ - [ ] **Step 6: Commit**
245
+
246
+ ```bash
247
+ git add styles.css
248
+ git commit -m "style(game): cold grayscale bond bar + neutral accents (match intro)"
249
+ ```
250
+
251
+ ---
252
+
253
+ ### Task 6: Live verification (user)
254
+
255
+ **Files:** none (manual).
256
+
257
+ - [ ] **Step 1: Launch** — `.venv-tts/Scripts/python app.py` with Ollama running.
258
+
259
+ - [ ] **Step 2: Confirm**
260
+
261
+ - **Menu:** eyebrow / tagline / mode rows read clearly over the fog (scrim + brighter ink).
262
+ - **Skip:** visible as a small pill, top-right.
263
+ - **Frames:** edges fade into fog — no hard rectangular crop on menu or intro cards.
264
+ - **Intro cards:** the dialogue box is translucent (art shows through); subjects sit high so the box no longer buries the child — including the last card with options.
265
+ - **Game:** the Bond bar + accents are cold gray/bone, not purple; menu → intro → game feel like one art direction.
266
+
267
+ - [ ] **Step 3: Tune live** — adjust focal `%`, panel opacity, fog strength, or bond grays as needed; commit any tweak.
268
+
269
+ ---
270
+
271
+ ## Self-Review
272
+
273
+ - **Spec coverage:** menu colors (Task 1) ✅; skip visibility (Task 2) ✅; cropped-edge fog (Task 3) ✅; dialogue covering art → translucent + raised focal (Task 4) ✅; game purple → grayscale (Task 5) ✅. Full reskin deliberately out of scope (deadline) ✅.
274
+ - **Placeholder scan:** none; every step shows concrete values.
275
+ - **Name consistency:** `#menu-card::before` / `#menu-frame::after` match the ids built in `_menu_html`; `#intro-frame::after` / `#intro-panel` / `#intro-image::after` match the intro markup; `_INTRO_POS` length unchanged (4) so the existing guard test holds; all recolored selectors (`.bond-tier`, `.bond-fill`, `.bond-beat`, `@keyframes heartbeat`, `button:active`) verified present at the cited lines.
docs/superpowers/plans/2026-06-14-intro-menu-polish-2.md ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Intro + Menu Polish, Round 2 — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
4
+
5
+ **Goal:** Fix the regressions from the last round: (1) the intro frame is no longer vertically centered (huge dead black band below it); (2) the menu's title/tagline/credit overlap and its mode buttons render full-width with a screen-wide hover band. Rebuild the menu as a single cohesive panel — title + tagline + **selectable option rows** (`Tester Run` / `The Full Stay`) in the same style as the intro options the user approved — using the hidden-button proxy pattern.
6
+
7
+ **Architecture:** Pure presentation. (1) Restore `#intro-stage` height so the frame re-centers. (2) `_menu_html()` becomes a `#menu-card` block (eyebrow, title, tagline, `.menu-opt` rows); the real `Tester Run` / `The Full Stay` buttons stay in the DOM but hidden in a `#menu-proxy` column, and each `.menu-opt` clicks its matching hidden button (same `#idle-trigger` / `.intro-opt` proxy trick already shipped). The credit line pins to the viewport (`position:fixed`) so it stops floating into the title. No model/state/pacing logic; the mode buttons keep their `_show_intro` handlers and `.menu-mode-btn` JS listener, both fired by the programmatic click.
8
+
9
+ **Tech Stack:** Gradio 6.x `gr.HTML` + `gr.Button`; `<head>` JS via `launch(head=...)`; pure CSS; pytest for one structural guard.
10
+
11
+ **Verification note:** Layout/visuals verified **live by the user** (`.venv-tts` + Ollama); headless is unreliable (CLAUDE.md). Task 3 is the by-eye checklist.
12
+
13
+ **Locked:** menu = cohesive panel like the intro; intro options + panel border stay as-is (approved).
14
+
15
+ ---
16
+
17
+ ### Task 1: Re-center the intro frame (kill the bottom black band)
18
+
19
+ **Files:**
20
+ - Modify: `styles.css` (§25 `#intro-stage`)
21
+
22
+ Last round set `#intro-stage { min-height: 0 }`. The `gr.HTML` wrapper isn't a full-height flex child, so the stage collapsed and the centered frame floated to the top, leaving a large black band below. Restoring the stage to fill the viewport re-centers the frame (symmetric letterbox). `#intro-view` is `position:fixed; height:100vh; overflow:hidden`, so a 100vh stage fits exactly with no scroll.
23
+
24
+ - [ ] **Step 1: Restore the stage height**
25
+
26
+ In `styles.css` §25, change `#intro-stage`'s `min-height: 0;` back to `min-height: 100vh;`:
27
+
28
+ ```css
29
+ #intro-stage {
30
+ position: relative;
31
+ flex: 1;
32
+ min-height: 100vh;
33
+ cursor: pointer; /* click anywhere to advance */
34
+ overflow: hidden;
35
+ display: flex;
36
+ align-items: center; /* center the frame -> symmetric letterbox bands */
37
+ justify-content: center;
38
+ background: #000;
39
+ }
40
+ ```
41
+
42
+ - [ ] **Step 2: Sanity — app suite still green**
43
+
44
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
45
+ Expected: PASS
46
+
47
+ - [ ] **Step 3: Commit**
48
+
49
+ ```bash
50
+ git add styles.css
51
+ git commit -m "fix(intro): re-center frame, remove dead bottom band"
52
+ ```
53
+
54
+ ---
55
+
56
+ ### Task 2: Menu cohesive panel with selectable option rows
57
+
58
+ **Files:**
59
+ - Modify: `app.py` (`_menu_html()` ~line 455; the menu button block ~line 925-930; JS `wire()` ~line 401)
60
+ - Modify: `styles.css` (§23: rename `.menu-content` → `#menu-card`, add `.menu-opts`/`.menu-opt`, pin `.menu-credit`)
61
+ - Test: `tests/test_app.py`
62
+
63
+ The title screen becomes one centered card: eyebrow, title, tagline, then the two modes as selectable rows (`›` marker, hover glow) mirroring the intro options. `Tester Run` / `The Full Stay` stay as hidden Gradio buttons (`#menu-proxy`, `display:none`); each `.menu-opt` row clicks its target button, so both the Gradio `_show_intro` handler and the existing `.menu-mode-btn` JS listener (which calls `startIntro`) still fire. The credit pins to the viewport bottom.
64
+
65
+ - [ ] **Step 1: Write the failing test**
66
+
67
+ In `tests/test_app.py`, add:
68
+
69
+ ```python
70
+ def test_menu_card_has_option_rows():
71
+ import app
72
+ html = app._menu_html()
73
+ assert 'id="menu-card"' in html
74
+ assert 'data-target="btn-tester"' in html
75
+ assert 'data-target="btn-full"' in html
76
+ assert 'class="menu-opt"' in html
77
+ # the JS wires the rows
78
+ assert 'menu-opt' in app._HEAD_JS
79
+ ```
80
+
81
+ - [ ] **Step 2: Run test to verify it fails**
82
+
83
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_menu_card_has_option_rows -v`
84
+ Expected: FAIL (`id="menu-card"` not found)
85
+
86
+ - [ ] **Step 3: Rebuild `_menu_html()`**
87
+
88
+ In `app.py`, replace `_menu_html()` with:
89
+
90
+ ```python
91
+ def _menu_html() -> str:
92
+ return (
93
+ f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
94
+ f'<div id="menu-frame" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
95
+ '<div class="menu-scrim"></div>'
96
+ '<div class="menu-grain"></div>'
97
+ '<div class="menu-vig"></div>'
98
+ '<div id="menu-card">'
99
+ ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
100
+ ' <div class="menu-title">Hollow</div>'
101
+ ' <div class="menu-tag">something is here, at the edge of the wood '
102
+ '&mdash; it remembers what you give it.</div>'
103
+ ' <div class="menu-opts">'
104
+ ' <div class="menu-opt" data-target="btn-tester">Tester Run</div>'
105
+ ' <div class="menu-opt" data-target="btn-full">The Full Stay</div>'
106
+ ' </div>'
107
+ '</div>'
108
+ '<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
109
+ )
110
+ ```
111
+
112
+ - [ ] **Step 4: Hide the real mode buttons in a proxy column**
113
+
114
+ In `app.py`, change the menu button block from:
115
+
116
+ ```python
117
+ tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
118
+ elem_classes="menu-btn menu-mode-btn")
119
+ full_btn = gr.Button("The Full Stay", elem_id="btn-full",
120
+ elem_classes="menu-btn menu-mode-btn")
121
+ howto_btn = gr.Button("How to Play", elem_id="btn-howto",
122
+ elem_classes="menu-btn")
123
+ ```
124
+
125
+ to:
126
+
127
+ ```python
128
+ with gr.Column(elem_id="menu-proxy"): # hidden; .menu-opt rows click these
129
+ tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
130
+ elem_classes="menu-mode-btn")
131
+ full_btn = gr.Button("The Full Stay", elem_id="btn-full",
132
+ elem_classes="menu-mode-btn")
133
+ howto_btn = gr.Button("How to Play", elem_id="btn-howto",
134
+ elem_classes="menu-btn")
135
+ ```
136
+
137
+ - [ ] **Step 5: Wire the option rows in the JS `wire()`**
138
+
139
+ In `app.py` `_HEAD_JS`, inside `wire()`, after the existing `.menu-mode-btn` block (the `document.querySelectorAll('.menu-mode-btn')...` loop, ~line 425-432), add:
140
+
141
+ ```javascript
142
+ document.querySelectorAll('.menu-opt').forEach(function (mo) {
143
+ if (mo.dataset.hollowWired) return;
144
+ mo.dataset.hollowWired = '1';
145
+ mo.addEventListener('click', function () {
146
+ var t = document.getElementById(mo.dataset.target);
147
+ if (t) t.click(); // fire the hidden mode button (startIntro + _show_intro)
148
+ });
149
+ });
150
+ ```
151
+
152
+ - [ ] **Step 6: Run test to verify it passes**
153
+
154
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_menu_card_has_option_rows -v`
155
+ Expected: PASS
156
+
157
+ - [ ] **Step 7: CSS — rename `.menu-content`, add rows, pin credit**
158
+
159
+ In `styles.css` §23:
160
+
161
+ (a) Rename the `.menu-content` selector to `#menu-card` and give it a max-width so the tagline wraps tightly:
162
+
163
+ ```css
164
+ #menu-card {
165
+ position: relative;
166
+ z-index: 5;
167
+ text-align: center;
168
+ font-family: Georgia, serif;
169
+ max-width: 560px;
170
+ }
171
+ ```
172
+
173
+ (b) Immediately after the `.menu-tag { ... }` rule, add the option rows:
174
+
175
+ ```css
176
+ .menu-opts {
177
+ margin-top: 34px;
178
+ display: flex;
179
+ flex-direction: column;
180
+ align-items: center; /* rows shrink to content -> no full-width band */
181
+ gap: 6px;
182
+ }
183
+ .menu-opt {
184
+ font-family: Georgia, serif;
185
+ font-size: 1.15rem;
186
+ letter-spacing: 0.14em;
187
+ color: #c4bede;
188
+ padding: 8px 26px;
189
+ cursor: pointer;
190
+ border-radius: 2px;
191
+ transition: color 0.3s, letter-spacing 0.3s, text-shadow 0.3s, background 0.3s;
192
+ }
193
+ .menu-opt::before {
194
+ content: '\203A\00a0\00a0'; /* "› " */
195
+ opacity: 0;
196
+ transition: opacity 0.3s;
197
+ }
198
+ .menu-opt:hover {
199
+ color: #f0ecfa;
200
+ letter-spacing: 0.2em;
201
+ text-shadow: 0 0 16px rgba(180,160,210,0.5);
202
+ background: rgba(255,255,255,0.04);
203
+ }
204
+ .menu-opt:hover::before { opacity: 1; }
205
+
206
+ #menu-proxy { display: none !important; }
207
+ ```
208
+
209
+ (c) Pin the credit to the viewport so it stops floating into the title — in `.menu-credit`, change `position: absolute;` to `position: fixed;`.
210
+
211
+ - [ ] **Step 8: Full app suite**
212
+
213
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
214
+ Expected: PASS
215
+
216
+ - [ ] **Step 9: Commit**
217
+
218
+ ```bash
219
+ git add app.py styles.css tests/test_app.py
220
+ git commit -m "feat(menu): cohesive card with selectable mode rows (proxy), pinned credit"
221
+ ```
222
+
223
+ ---
224
+
225
+ ### Task 3: Live verification (user)
226
+
227
+ **Files:** none (manual).
228
+
229
+ - [ ] **Step 1: Launch**
230
+
231
+ Run (user): `.venv-tts/Scripts/python app.py` with Ollama running.
232
+
233
+ - [ ] **Step 2: Confirm**
234
+
235
+ - **Menu:** eyebrow / `HOLLOW` / tagline stack cleanly inside the framed card — **no overlap**; credit sits in the bottom band, not over the title.
236
+ - **Menu modes:** `Tester Run` / `The Full Stay` are centered selectable rows with a `›` + glow on hover; the hover highlight hugs the text — **no screen-wide band**. Clicking one starts the chosen run (music keeps playing low, intro begins).
237
+ - **Intro:** the framed card is **vertically centered** (symmetric bands) — **no dead black band** below; skip + click-to-advance work; options inside the panel still look right.
238
+ - No page scroll anywhere in menu/intro.
239
+
240
+ - [ ] **Step 3: Tune if needed**
241
+
242
+ Adjust `.menu-opts` `margin-top`, `#menu-card` `max-width`, or frame `max-height` live; commit any tweak:
243
+
244
+ ```bash
245
+ git add styles.css app.py
246
+ git commit -m "tune(menu/intro): live polish"
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Self-Review
252
+
253
+ - **Spec coverage:** intro bottom band (Task 1, restore stage height) ✅; menu text overlap (Task 2, `#menu-card` clean stack + credit `position:fixed`) ✅; menu buttons full-width band (Task 2, `.menu-opt` content-width rows, real buttons hidden) ✅; menu modes as approved-style rows (Task 2) ✅. Intro options/border untouched ✅.
254
+ - **Placeholder scan:** none; full code in every step.
255
+ - **Name consistency:** `#menu-card` (markup Task 2 step 3, CSS step 7); `.menu-opt[data-target="btn-tester"|"btn-full"]` (markup) → JS `wire()` clicks `document.getElementById(mo.dataset.target)` (step 5) → ids match the hidden `tester_btn`/`full_btn` (`elem_id="btn-tester"/"btn-full"`, step 4) whose `_show_intro` handlers and `.menu-mode-btn` listener are unchanged; `#menu-proxy` column id (step 4) matches `#menu-proxy { display:none }` (step 7).
256
+ - **Proxy validity:** `display:none` Gradio buttons fire on JS `.click()` (shipped `#idle-trigger` proves it); the user's real click on `.menu-opt` is a `pointerdown`, so `firstGesture` still unlocks the menu music.
docs/superpowers/plans/2026-06-14-intro-menu-polish.md ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Intro + Menu Polish — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
4
+
5
+ **Goal:** Three presentation fixes from the live review: (1) the menu and intro never scroll (fixed full-viewport); (2) the menu gets the same cinematic letterbox + blurred backdrop as the intro cards; (3) the intro choice replaces the full-width, bottom-glued buttons with selectable rows **inside** the stone dialogue panel (visual-novel style).
6
+
7
+ **Architecture:** Pure presentation. (1) `#menu-view` / `#intro-view` become `position:fixed; inset:0` so they take over the viewport and contribute no page height. (2) `_menu_html()` gains a sharp centered `#menu-frame`; `.menu-bg` becomes the blurred ambient backdrop; §23 CSS reworked. (3) The choice options render as HTML rows (`.intro-opt`) inside `#intro-panel`; the real Gradio buttons stay in the DOM but hidden (`display:none`), and each row clicks its matching hidden button — the exact `#idle-trigger` proxy pattern already used in this codebase. No model/state/pacing logic changes; tone seeds (+12 / −8) stay on the hidden buttons' existing handlers.
8
+
9
+ **Tech Stack:** Gradio 6.x `gr.HTML` + `gr.Button`; `<head>` JS injected via `launch(head=...)`; pure CSS; pytest for two small structural guards.
10
+
11
+ **Verification note:** Per CLAUDE.md, layout/visuals are verified **live by the user** (`.venv-tts` + Ollama) — headless is unreliable. Tasks carry structural guards only; the look is confirmed in Task 4.
12
+
13
+ **Locked decisions (from brainstorming):** choice = *inside the dialogue box*; menu = *letterbox like the intro*; scroll = *fixed, no scroll*.
14
+
15
+ ---
16
+
17
+ ### Task 1: Menu + intro never scroll (fixed full-viewport)
18
+
19
+ **Files:**
20
+ - Modify: `styles.css` (§23 `#menu-view`, ~line 865; §25 `#intro-view` + `#intro-stage`)
21
+
22
+ `#menu-view` and `#intro-view` are `min-height:100vh` inside the Gradio container's vertical padding, so the page is a little taller than the viewport and scrolls. Pinning them `position:fixed; inset:0` removes them from flow (no page height) and locks them to the viewport. Gradio's `.hide` (`display:none !important`) still collapses them when switched away.
23
+
24
+ - [ ] **Step 1: Pin `#menu-view`**
25
+
26
+ In `styles.css` §23, change the `#menu-view` rule from:
27
+
28
+ ```css
29
+ #menu-view {
30
+ position: relative;
31
+ min-height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
+ align-items: center;
35
+ justify-content: center; /* center title+buttons as one vertical group */
36
+ overflow: hidden;
37
+ border: none !important;
38
+ background: #04040a;
39
+ }
40
+ ```
41
+
42
+ to:
43
+
44
+ ```css
45
+ #menu-view {
46
+ position: fixed;
47
+ inset: 0;
48
+ height: 100vh;
49
+ display: flex;
50
+ flex-direction: column;
51
+ align-items: center;
52
+ justify-content: center; /* center title+buttons as one vertical group */
53
+ overflow: hidden;
54
+ border: none !important;
55
+ background: #04040a;
56
+ z-index: 50;
57
+ }
58
+ ```
59
+
60
+ - [ ] **Step 2: Pin `#intro-view` and make the stage fill it**
61
+
62
+ In `styles.css` §25, change `#intro-view` from `position: relative; min-height: 100vh;` to:
63
+
64
+ ```css
65
+ #intro-view {
66
+ position: fixed;
67
+ inset: 0;
68
+ height: 100vh;
69
+ display: flex;
70
+ flex-direction: column;
71
+ background: #000;
72
+ overflow: hidden;
73
+ z-index: 50;
74
+ }
75
+ ```
76
+
77
+ and change `#intro-stage`'s `min-height: 100vh;` to `min-height: 0;` (it already has `flex: 1`, so it fills the fixed parent):
78
+
79
+ ```css
80
+ #intro-stage {
81
+ position: relative;
82
+ flex: 1;
83
+ min-height: 0;
84
+ cursor: pointer; /* click anywhere to advance */
85
+ overflow: hidden;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ background: #000;
90
+ }
91
+ ```
92
+
93
+ - [ ] **Step 3: Confirm the suite still passes (no Python touched, sanity only)**
94
+
95
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
96
+ Expected: PASS
97
+
98
+ - [ ] **Step 4: Commit**
99
+
100
+ ```bash
101
+ git add styles.css
102
+ git commit -m "fix(intro/menu): fixed full-viewport views, no page scroll"
103
+ ```
104
+
105
+ ---
106
+
107
+ ### Task 2: Menu cinematic letterbox
108
+
109
+ **Files:**
110
+ - Modify: `app.py` (`_menu_html()`, ~line 455)
111
+ - Modify: `styles.css` (§23: `.menu-bg`, new `#menu-frame`, `.menu-scrim` z-index, `.menu-tag`)
112
+ - Test: `tests/test_app.py`
113
+
114
+ The menu forest currently bleeds full-screen. Give it the intro treatment: a blurred ambient backdrop (`.menu-bg`) plus a sharp, centered `#menu-frame` (~16:10). Title/buttons keep their existing flex-centering and overlay the frame. Also center the tagline (it's a `max-width` block with no auto margins, so it currently hugs the left).
115
+
116
+ - [ ] **Step 1: Write the failing test**
117
+
118
+ In `tests/test_app.py`, add:
119
+
120
+ ```python
121
+ def test_menu_html_has_letterbox_frame():
122
+ import app
123
+ html = app._menu_html()
124
+ # the sharp centered frame and the (now blurred) backdrop both exist
125
+ assert 'id="menu-frame"' in html
126
+ assert 'class="menu-bg"' in html
127
+ # both reference the forest art
128
+ assert html.count(app._BACKGROUND_URI) >= 2
129
+ ```
130
+
131
+ - [ ] **Step 2: Run test to verify it fails**
132
+
133
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_menu_html_has_letterbox_frame -v`
134
+ Expected: FAIL (`id="menu-frame"` not found)
135
+
136
+ - [ ] **Step 3: Add `#menu-frame` to `_menu_html()`**
137
+
138
+ In `app.py`, change `_menu_html()` so the line after `.menu-bg` adds the frame:
139
+
140
+ ```python
141
+ def _menu_html() -> str:
142
+ return (
143
+ f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
144
+ f'<div id="menu-frame" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
145
+ '<div class="menu-scrim"></div>'
146
+ '<div class="menu-grain"></div>'
147
+ '<div class="menu-vig"></div>'
148
+ '<div class="menu-content">'
149
+ ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
150
+ ' <div class="menu-title">Hollow</div>'
151
+ ' <div class="menu-tag">something is here, at the edge of the wood '
152
+ '&mdash; it remembers what you give it.</div>'
153
+ '</div>'
154
+ '<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
155
+ )
156
+ ```
157
+
158
+ - [ ] **Step 4: Run test to verify it passes**
159
+
160
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_menu_html_has_letterbox_frame -v`
161
+ Expected: PASS
162
+
163
+ - [ ] **Step 5: Rework the §23 CSS — backdrop, frame, z-index, tagline**
164
+
165
+ In `styles.css` §23, replace the `.menu-bg` rule with (blurred ambient backdrop):
166
+
167
+ ```css
168
+ .menu-bg {
169
+ position: fixed;
170
+ inset: -6%; /* overscan so blur edges never reveal a gap */
171
+ background-size: cover;
172
+ background-position: center 38%;
173
+ filter: grayscale(1) blur(46px) brightness(0.34);
174
+ transform: scale(1.08);
175
+ z-index: 0;
176
+ }
177
+ ```
178
+
179
+ Immediately after `.menu-bg`, add the sharp centered frame:
180
+
181
+ ```css
182
+ /* sharp cinematic frame: the forest, contained like the intro cards */
183
+ #menu-frame {
184
+ position: fixed;
185
+ left: 50%;
186
+ top: 50%;
187
+ transform: translate(-50%, -50%);
188
+ width: min(1080px, 92vw);
189
+ aspect-ratio: 16 / 10;
190
+ max-height: 74vh;
191
+ background-size: cover;
192
+ background-position: center 38%;
193
+ background-repeat: no-repeat;
194
+ filter: grayscale(1) brightness(0.92);
195
+ border-radius: 3px;
196
+ box-shadow: 0 0 60px 12px rgba(0,0,0,0.7),
197
+ 0 0 0 1px rgba(120,110,140,0.12);
198
+ z-index: 1;
199
+ }
200
+ ```
201
+
202
+ Change `.menu-scrim`'s `z-index: 1;` to `z-index: 2;` (so the atmosphere layers sit above the frame; `.menu-grain` and `.menu-vig` are already `z-index: 2`). Change `.menu-content`'s `z-index: 3;` to `z-index: 5;` so the title group stays above everything.
203
+
204
+ Finally, center the tagline — change `.menu-tag`'s `margin-top: 14px;` to:
205
+
206
+ ```css
207
+ margin: 14px auto 0;
208
+ ```
209
+
210
+ - [ ] **Step 6: Commit**
211
+
212
+ ```bash
213
+ git add app.py styles.css tests/test_app.py
214
+ git commit -m "feat(menu): cinematic letterbox frame + blurred backdrop, centered tagline"
215
+ ```
216
+
217
+ ---
218
+
219
+ ### Task 3: Choice options inside the dialogue panel
220
+
221
+ **Files:**
222
+ - Modify: `app.py` (intro `gr.HTML` markup ~line 938; intro button column ~line 943-947; JS `showIntroCard` + `revealChoice` + `startIntro` ~line 338-377)
223
+ - Modify: `styles.css` (§25: replace `#intro-choice` block with `#intro-opts` / `.intro-opt`; hide proxy column)
224
+ - Test: `tests/test_app.py`
225
+
226
+ The two choices move **inside** the stone panel as selectable rows. The real Gradio buttons (which carry the +12 / −8 tone-seed handlers) stay in the DOM but hidden; each HTML row clicks its matching hidden button — the same proxy trick as `#idle-trigger`.
227
+
228
+ - [ ] **Step 1: Write the failing test**
229
+
230
+ In `tests/test_app.py`, add:
231
+
232
+ ```python
233
+ def test_intro_opts_proxy_to_hidden_buttons():
234
+ import app
235
+ # the panel hosts the option rows, each targeting a hidden Gradio button id
236
+ src = app._HEAD_JS
237
+ assert 'intro-opts' in src # reveal logic references the opts
238
+ # the intro markup is built inline in the Blocks; check the module source
239
+ import inspect
240
+ mod = inspect.getsource(app)
241
+ assert 'id="intro-opts"' in mod
242
+ assert 'data-target="btn-offer"' in mod
243
+ assert 'data-target="btn-keep"' in mod
244
+ ```
245
+
246
+ - [ ] **Step 2: Run test to verify it fails**
247
+
248
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py::test_intro_opts_proxy_to_hidden_buttons -v`
249
+ Expected: FAIL (`intro-opts` not present)
250
+
251
+ - [ ] **Step 3: Add the option rows inside the panel**
252
+
253
+ In `app.py`, change the intro `gr.HTML` markup from:
254
+
255
+ ```python
256
+ gr.HTML(
257
+ '<div id="intro-stage">'
258
+ ' <div id="intro-bg"></div>'
259
+ ' <div id="intro-frame">'
260
+ ' <div id="intro-image"></div>'
261
+ ' <div id="intro-panel"><p id="intro-text"></p></div>'
262
+ ' </div>'
263
+ '</div>'
264
+ )
265
+ ```
266
+
267
+ to:
268
+
269
+ ```python
270
+ gr.HTML(
271
+ '<div id="intro-stage">'
272
+ ' <div id="intro-bg"></div>'
273
+ ' <div id="intro-frame">'
274
+ ' <div id="intro-image"></div>'
275
+ ' <div id="intro-panel">'
276
+ ' <p id="intro-text"></p>'
277
+ ' <div id="intro-opts">'
278
+ ' <div class="intro-opt" data-target="btn-offer">Offer your hand</div>'
279
+ ' <div class="intro-opt" data-target="btn-keep">Keep your distance</div>'
280
+ ' </div>'
281
+ ' </div>'
282
+ ' </div>'
283
+ '</div>'
284
+ )
285
+ ```
286
+
287
+ - [ ] **Step 4: Hide the real buttons (proxy), keep skip visible**
288
+
289
+ In `app.py`, change the choice column from:
290
+
291
+ ```python
292
+ with gr.Column(elem_id="intro-choice"):
293
+ offer_btn = gr.Button("Offer your hand", elem_id="btn-offer",
294
+ elem_classes="intro-tablet intro-enter-btn")
295
+ keep_btn = gr.Button("Keep your distance", elem_id="btn-keep",
296
+ elem_classes="intro-tablet intro-enter-btn")
297
+ skip_btn = gr.Button("skip ▸", elem_id="intro-skip",
298
+ elem_classes="intro-enter-btn")
299
+ ```
300
+
301
+ to:
302
+
303
+ ```python
304
+ with gr.Column(elem_id="intro-proxy"): # hidden; HTML rows click these
305
+ offer_btn = gr.Button("Offer your hand", elem_id="btn-offer",
306
+ elem_classes="intro-enter-btn")
307
+ keep_btn = gr.Button("Keep your distance", elem_id="btn-keep",
308
+ elem_classes="intro-enter-btn")
309
+ skip_btn = gr.Button("skip ▸", elem_id="intro-skip",
310
+ elem_classes="intro-enter-btn")
311
+ ```
312
+
313
+ (The `offer_btn.click(...)` / `keep_btn.click(...)` wiring further down is unchanged — clicking the hidden button still fires `_enter_game`.)
314
+
315
+ - [ ] **Step 5: Update the JS — reveal opts, hide on each card, wire row clicks**
316
+
317
+ In `app.py` `_HEAD_JS`, change `showIntroCard` so it hides the opts at the start of each card:
318
+
319
+ ```javascript
320
+ function showIntroCard(cardIndex, isLast) {
321
+ var card = INTRO[cardIndex];
322
+ var img = document.getElementById('intro-image');
323
+ var bg = document.getElementById('intro-bg');
324
+ var txt = document.getElementById('intro-text');
325
+ var opts = document.getElementById('intro-opts');
326
+ if (opts) opts.classList.remove('intro-opts-show');
327
+ var url = card.img ? "url('" + card.img + "')" : '';
328
+ if (img) {
329
+ img.style.backgroundImage = url;
330
+ img.style.backgroundPosition = card.pos || 'center';
331
+ }
332
+ if (bg) bg.style.backgroundImage = url;
333
+ if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); });
334
+ }
335
+ ```
336
+
337
+ Change `revealChoice` to reveal the opts:
338
+
339
+ ```javascript
340
+ function revealChoice() {
341
+ var opts = document.getElementById('intro-opts');
342
+ if (opts) opts.classList.add('intro-opts-show');
343
+ }
344
+ ```
345
+
346
+ In `startIntro`, inside the `setTimeout`, after the stage click is wired, also wire the option rows once:
347
+
348
+ ```javascript
349
+ function startIntro(mode) {
350
+ introSeq = INTRO_SEQ[mode] || INTRO_SEQ['tester']; // single source of truth
351
+ introIdx = 0;
352
+ window.scrollTo(0, 0);
353
+ setTimeout(function () {
354
+ showIntroCard(introSeq[0], introSeq.length <= 1);
355
+ var stage = document.getElementById('intro-stage');
356
+ if (stage && !stage.dataset.hollowWired) {
357
+ stage.dataset.hollowWired = '1';
358
+ stage.addEventListener('click', advanceIntro);
359
+ }
360
+ var opts = document.querySelectorAll('#intro-opts .intro-opt');
361
+ opts.forEach(function (el) {
362
+ if (el.dataset.hollowWired) return;
363
+ el.dataset.hollowWired = '1';
364
+ el.addEventListener('click', function (e) {
365
+ e.stopPropagation(); // don't let the stage advance
366
+ var t = document.getElementById(el.dataset.target);
367
+ if (t) t.click(); // fire the hidden Gradio button
368
+ });
369
+ });
370
+ }, 130);
371
+ }
372
+ ```
373
+
374
+ - [ ] **Step 6: Replace the §25 choice CSS**
375
+
376
+ In `styles.css` §25, replace the whole `#intro-choice { ... }` and `#intro-choice.intro-choice-show { ... }` block with the opts styling, and drop the now-unused `.intro-tablet` rules (the proxy buttons are hidden). Replace from `/* choice tablets: ... */` down to the end of the `.intro-tablet:hover { ... }` rule with:
377
+
378
+ ```css
379
+ /* option rows, revealed inside the stone panel on the final card */
380
+ #intro-opts {
381
+ margin-top: 14px;
382
+ padding-top: 12px;
383
+ border-top: 1px solid rgba(120,110,140,0.18);
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 2px;
387
+ max-height: 0;
388
+ overflow: hidden;
389
+ opacity: 0;
390
+ transition: opacity 0.5s ease, max-height 0.5s ease;
391
+ }
392
+ #intro-opts.intro-opts-show {
393
+ max-height: 220px;
394
+ opacity: 1;
395
+ }
396
+ .intro-opt {
397
+ font-family: Georgia, serif;
398
+ font-size: 1.02rem;
399
+ color: #b9b2cc;
400
+ letter-spacing: 0.04em;
401
+ padding: 7px 10px;
402
+ cursor: pointer;
403
+ border-radius: 2px;
404
+ transition: color 0.2s, background 0.2s, padding-left 0.2s;
405
+ }
406
+ .intro-opt::before {
407
+ content: '\203A\00a0\00a0'; /* "› " */
408
+ color: #6a5a86;
409
+ }
410
+ .intro-opt:hover {
411
+ color: #f0ecfa;
412
+ background: rgba(120,90,140,0.12);
413
+ padding-left: 16px;
414
+ }
415
+
416
+ /* the real choice buttons live hidden in the DOM; HTML rows proxy their clicks */
417
+ #intro-proxy { display: none !important; }
418
+ ```
419
+
420
+ - [ ] **Step 7: Run the guard test + full app suite**
421
+
422
+ Run: `.venv/Scripts/python -m pytest tests/test_app.py -q`
423
+ Expected: PASS (including `test_intro_opts_proxy_to_hidden_buttons`)
424
+
425
+ - [ ] **Step 8: Commit**
426
+
427
+ ```bash
428
+ git add app.py styles.css tests/test_app.py
429
+ git commit -m "feat(intro): selectable choice rows inside the dialogue panel"
430
+ ```
431
+
432
+ ---
433
+
434
+ ### Task 4: Live verification (user)
435
+
436
+ **Files:** none (manual). Headless is unreliable here.
437
+
438
+ - [ ] **Step 1: Launch with voice + model**
439
+
440
+ Run (user): `.venv-tts/Scripts/python app.py` with Ollama (`qwen3:8b`) running.
441
+
442
+ - [ ] **Step 2: Confirm**
443
+
444
+ - **Menu:** the forest sits in a centered 16:10 frame with letterbox bands + blurred backdrop (matches the intro). Title/buttons centered over the frame. Tagline centered (not hugging the left). **No scroll.**
445
+ - **Intro:** still letterboxed; **no scroll**; skip + click-to-advance work.
446
+ - **Last card:** the two choices appear as selectable rows **inside** the stone panel (below the narration, under a thin divider), with a `›` marker and hover highlight. Clicking a row enters the game with the right tone seed (Offer → warmer first turn; Keep → cooler). The stage click behind the rows does **not** also advance.
447
+ - Tester shows Threshold + Meeting; Full shows all four.
448
+
449
+ - [ ] **Step 3: Tune if needed**
450
+
451
+ Adjust frame `max-height`, opt spacing, or backdrop `brightness` live; commit any tweak:
452
+
453
+ ```bash
454
+ git add styles.css app.py
455
+ git commit -m "tune(intro/menu): live polish after review"
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Self-Review
461
+
462
+ - **Spec coverage:** no scroll (Task 1, fixed views) ✅; menu image shrunk/letterboxed like intro (Task 2) ✅; choices inside the dialogue box (Task 3, proxy rows) ✅; tagline left-hug fixed (Task 2, `margin:auto`) ✅.
463
+ - **Placeholder scan:** none; every code step shows full code.
464
+ - **Name consistency:** `#menu-frame` created in `_menu_html` (Task 2) + styled in §23 (Task 2); `#intro-opts` / `.intro-opt[data-target]` created in markup (Task 3), revealed/wired in JS (Task 3), styled in §25 (Task 3); `#intro-proxy` column id (Task 3 markup) matches the `#intro-proxy { display:none }` rule (Task 3 CSS); hidden button ids `btn-offer` / `btn-keep` match the `data-target`s and the existing `offer_btn`/`keep_btn` handlers.
465
+ - **Proxy pattern validity:** `display:none` Gradio buttons still fire on JS `.click()` — same mechanism as the shipped `#idle-trigger`.