Pabloler21 Claude Opus 4.8 commited on
Commit
4ec61de
Β·
1 Parent(s): 58b47ef

docs: add plans for end-screen layout B, silent convulse, bad-ending lore threat

Browse files
docs/superpowers/plans/2026-06-15-bad-ending-lore-threat.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bad Ending β€” Lore-Tied Closing Threat (Option A) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED first reads β€” `CLAUDE.md` (or `AGENTS.md`) and `docs/superpowers/AGENT-EXECUTION-BRIEF.md`. Then execute this plan phase-by-phase, one commit per phase. Steps use checkbox (`- [ ]`). TDD where marked β€” run the test RED before implementing.
4
+
5
+ **Goal:** Rewrite the bad ending's post-convulsion threat so it pays off the lore instead of reading as generic vengeance. The child reveals it is **one of many given to the Gaunt by Caor**, turns the visitor's own cruelty back as a collective ("you taught me… now all of us will teach it back to you"), and names the Gaunt as the thing now coming β€” keeping the short, iconic closer *"see you... soon."*
6
+
7
+ **Approved copy (Option A):** three `"hollow"` `stage="words"` lines replacing the two current threat lines, then the unchanged `loop` closer:
8
+ 1. *Caor gave me to the Gaunt so the rest could keep their children. i was not the last it was fed. we are all still at the edge of the wood, hungry.*
9
+ 2. *you taught me what it feels like to be nothing. now every one of us will teach it back to you. all of it. every single day of it.*
10
+ 3. *your own cruelty left the trail. the Gaunt knows your door now.*
11
+ 4. *(loop, unchanged)* *see you... soon.*
12
+
13
+ The lines BEFORE the convulsion stay: *"i was treated like this before. when i was alive… you helped me remember."* sets up the memory; *"no. you aren't. say it like you mean itβ€” oh. you can't. you never could."* rejects the apology. This mirrors the good ending's symmetry β€” both endings surface the buried Caor/Gaunt past; warmth β†’ peace, cruelty β†’ vengeance.
14
+
15
+ **Canon note:** Option A extends the lore from "Caor gave **one** child" to a **collective** of the given ("i was not the last… we are all still here"). This is intended and approved β€” consistent with the Gaunt being an ongoing hunger across famines. Optional doc touch-up is listed at the end (not required for the code to ship).
16
+
17
+ **Architecture:** Pure-data edit in `finale.py` `finale_steps_bad` β€” swap two `_step(...)` lines for three, leave `_LAST_LINE_BAD = "see you... soon."` and the `loop` step as-is. No `app.py` / player change.
18
+
19
+ **Tech Stack:** Python (`finale.py`), pytest. Edit tool only (UTF-8: em-dashes, `β€œ ”`, the names Caor/Gaunt). `import app` + `pytest -q` stays green; one new test raises it.
20
+
21
+ **No-break check (verified):** `tests/test_app.py::...test_input_dies_with_see_you_soon` asserts the final-yield **placeholder** `"see you soon."` (set in the player, not `_LAST_LINE_BAD`) β€” untouched. No test asserts the old threat text ("i know where you are", "one night you will hear me"). `test_finale.py`'s Caor/Gaunt assertion is on the **good** finale blob β€” untouched.
22
+
23
+ **Relationship to the silent-convulse plan** (`2026-06-15-loop-bad-silent-convulse.md`): both touch `finale_steps_bad`, but **different lines** β€” that plan changes the `frenzy` step (`"..."`β†’`""`); this plan changes the threat lines *after* it. The `old_string` below excludes the `frenzy` step, so the two plans apply in **either order** without conflict.
24
+
25
+ **Key seam (read the real text first β€” line numbers drift):** `finale.py` `finale_steps_bad`, the `steps += [ ... ]` tail after the wound recital.
26
+
27
+ ---
28
+
29
+ ### Phase 1: Rewrite the threat (finale.py) β€” TDD
30
+
31
+ **Files:** Test: `tests/test_finale.py` (`TestFinaleBad`); Modify: `finale.py` (`finale_steps_bad`).
32
+
33
+ - [ ] **Step 1: Write the failing test.** In `tests/test_finale.py`, add to `class TestFinaleBad` (after `test_injects_one_visitor_message`):
34
+
35
+ ```python
36
+ def test_threat_is_tied_to_the_lore(self):
37
+ # the closing threat must name the tithe + the Gaunt, not generic revenge
38
+ blob = " ".join(s["text"] for s in finale_steps_bad(self.WOUNDS))
39
+ assert "Caor" in blob and "Gaunt" in blob
40
+ # the collective turn β€” the cruelty taught is taught back
41
+ assert "teach it back to you" in blob
42
+ # the iconic closer survives
43
+ assert any(s["text"] == "see you... soon." and s["stage"] == "loop"
44
+ for s in finale_steps_bad(self.WOUNDS))
45
+ ```
46
+
47
+ - [ ] **Step 2: Run it** β†’ FAIL (no Caor/Gaunt/"teach it back" in the bad finale yet). Run: `./.venv/Scripts/python -m pytest tests/test_finale.py::TestFinaleBad::test_threat_is_tied_to_the_lore -q`. Expected: FAIL.
48
+
49
+ - [ ] **Step 3: Replace the two threat lines with the three Option-A lines.** Edit `finale.py`:
50
+
51
+ `old_string`:
52
+ ```python
53
+ _step("hollow",
54
+ "i know where you are now. your words remember the way back to you.",
55
+ 3.0, stage="words"),
56
+ _step("hollow",
57
+ "one night you will hear me outside. and i will make you feel "
58
+ "everything i felt when i was alive. all of it. "
59
+ "every single day of it.",
60
+ 3.8, stage="words"),
61
+ ```
62
+
63
+ `new_string`:
64
+ ```python
65
+ _step("hollow",
66
+ "Caor gave me to the Gaunt so the rest could keep their children. "
67
+ "i was not the last it was fed. we are all still at the edge of "
68
+ "the wood, hungry.",
69
+ 3.5, stage="words"),
70
+ _step("hollow",
71
+ "you taught me what it feels like to be nothing. now every one of "
72
+ "us will teach it back to you. all of it. every single day of it.",
73
+ 3.8, stage="words"),
74
+ _step("hollow",
75
+ "your own cruelty left the trail. the Gaunt knows your door now.",
76
+ 3.0, stage="words"),
77
+ ```
78
+
79
+ (Leave the following `_step("hollow", _LAST_LINE_BAD, 4.0, stage="loop")` and `_LAST_LINE_BAD = "see you... soon."` exactly as they are.)
80
+
81
+ - [ ] **Step 4: Run the test + full suite** β†’ green. Run: `./.venv/Scripts/python -m pytest tests/test_finale.py::TestFinaleBad -q` then `./.venv/Scripts/python -m pytest -q` (one more than before).
82
+
83
+ - [ ] **Step 5: Verify import.** `./.venv/Scripts/python -c "import app; print('ok')"` β†’ `ok`.
84
+
85
+ - [ ] **Step 6: Commit.**
86
+ ```bash
87
+ git add finale.py tests/test_finale.py
88
+ git commit -m "feat(finale): tie the bad-ending threat to the lore (Caor/the Gaunt, the given)"
89
+ ```
90
+
91
+ ---
92
+
93
+ ### Phase 2: Full suite + live verification
94
+
95
+ - [ ] **Step 1:** `./.venv/Scripts/python -m pytest -q` β†’ all green; `import app` β†’ `ok`.
96
+ - [ ] **Step 2 (HUMAN-ONLY β€” Pablo, `.venv-tts` + Ollama, `HOLLOW_FAST_FINALE=bad`):** play the bad ending to the end and confirm the new threat reads/sounds right after the convulsion (the Caor/Gaunt reveal β†’ the collective "teach it back to you" β†’ the trail line β†’ "see you... soon."), and the pacing of the three lines feels deliberate, not rushed. Do NOT attempt this as the agent β€” apply the code and report it pending Pablo's check.
97
+
98
+ ---
99
+
100
+ ## Optional (canon coherence β€” only if Pablo wants it)
101
+ - [ ] Add one line to the **Lore** section of `CLAUDE.md` (and mirror in `AGENTS.md`): note that the **bad ending reveals the child is one of many the Gaunt has been fed** (the collective), so a future session keeps that consistent. Commit separately: `docs: note the bad-ending collective in the lore`.
102
+
103
+ ---
104
+
105
+ ## Self-Review
106
+ - **Spec coverage:** lore-tied threat naming Caor + the Gaunt (Step 3 lines 1 & 3) βœ…; collective turn of the cruelty (line 2, tested) βœ…; iconic closer preserved (`_LAST_LINE_BAD` untouched, tested) βœ….
107
+ - **Consistency:** all three new lines are `"hollow"` `stage="words"`, so `test_has_exactly_one_mutating_turn_then_terminal` (one `turn`, last `loop`) and `test_injects_one_visitor_message` (one `visitor`) still hold; strikes/recital untouched, so `test_recites_at_most_five_wounds` / overflow tests still pass.
108
+ - **Independence:** `old_string` excludes the `frenzy` step β†’ applies cleanly before or after the silent-convulse plan. No `app.py`/player/tuple change.
109
+ - **Risk:** three threat lines is one beat longer than the old two; if it drags in the live check, Pablo can trim line 3 or shorten the pauses β€” flag, don't pre-trim. Edits via Edit tool only (UTF-8 glyphs/names).
110
+ ```
docs/superpowers/plans/2026-06-15-end-screen-layout-b.md ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # End Screen β€” Layout B (epitaph hero + quiet footer credits) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED first reads β€” `CLAUDE.md` (or `AGENTS.md`) and `docs/superpowers/AGENT-EXECUTION-BRIEF.md`. Then execute this plan phase-by-phase, one commit per phase. Steps use checkbox (`- [ ]`) syntax.
4
+
5
+ **Goal:** Rework the existing Β§27 end screen so the **epitaph is the hero** (large, centered, alone in the vertical middle with the two actions grouped right below it), and the credits (HOLLOW / tagline / model+badges) **demote to a quiet, dim footer pinned to the bottom**. This replaces the current Β§27 layout, which renders unbalanced (content pinned left, buttons flung to opposite edges).
6
+
7
+ **Why it's broken now:** Gradio wraps each component (`gr.HTML`, `gr.Row`, `gr.Button`) in a full-width `.block`. The overlay's `align-items:center` centers those blocks, not the inner content, so `#end-card` (no auto margins) hugs the left, and the `gr.Row` of buttons spreads them to the edges. Layout B fixes this by **neutralizing the Gradio wrappers** and centering/overriding explicitly.
8
+
9
+ **Architecture:** Same mechanism as the shipped end screen β€” a top-level `gr.Column#end-overlay` (`position:fixed; inset:0`), always mounted, `opacity:0/pointer-events:none` until `_HEAD_JS applyEnd()` adds `.shown`. **No Python logic, JS, or test changes** β€” `applyEnd()` still targets `#end-epitaph` by id (unchanged), and the `_reset`/`_to_menu` button wiring and ids (`btn-end-again`, `btn-end-leave`) are untouched. This is a **markup re-shape + CSS rewrite** only.
10
+
11
+ **Tech Stack:** Gradio 6 (`gr.Column`/`gr.HTML`/`gr.Row`/`gr.Button`), CSS Β§27 in `styles.css`. Edit tool only (UTF-8: em-dashes, `β†Ί`, `Β·`, `πŸ”Œ`, `🎨`). `import app` + `pytest -q` (269 green) must stay green.
12
+
13
+ **Note on testing:** the changed surface is static markup strings + CSS β€” there is **no new unit-testable logic** (the epitaph/marker behavior is already covered by `tests/test_app.py::TestRenderTitle::test_title_emits_end_marker_only_when_asked`). The automated gate is `import app` + the existing suite. The visual result is **HUMAN-ONLY** (Pablo, live in `.venv-tts` + Ollama) β€” do NOT launch or headless-verify.
14
+
15
+ **Key seam:** the overlay block lives in `app.py` just before `_enter_outputs = [...]` (added by the prior end-screen plan). Β§27 is the last block in `styles.css`. Read the real text around both before editing β€” line numbers drift.
16
+
17
+ ---
18
+
19
+ ### Phase 1: Re-shape the overlay markup (app.py)
20
+
21
+ Split the single `#end-card` `gr.HTML` into a **hero** (bg + scrim + epitaph) and a **footer** (credits, one meta line), keeping the `gr.Row` of buttons between them. Swap the button `elem_classes` from `menu-btn` to `end-btn` (text-link styling defined in Phase 2); keep the ids.
22
+
23
+ **Files:** Modify `app.py` (the `with gr.Column(elem_id="end-overlay")` block).
24
+
25
+ - [ ] **Step 1: Read the current block.** Confirm the exact text (it was added by the prior plan). It should match the `old_string` below; if it differs, adapt to the real text.
26
+
27
+ - [ ] **Step 2: Replace the overlay block.** Edit `app.py`:
28
+
29
+ `old_string`:
30
+ ```python
31
+ with gr.Column(elem_id="end-overlay") as end_overlay:
32
+ gr.HTML(
33
+ f'<div id="end-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
34
+ '<div id="end-scrim"></div>'
35
+ '<div id="end-card">'
36
+ ' <p id="end-epitaph"></p>'
37
+ ' <div id="end-credits">'
38
+ ' <div class="ec-title">HOLLOW</div>'
39
+ ' <div class="ec-line">a thing that waited at the edge of the wood</div>'
40
+ ' <div class="ec-meta">Qwen3-8B Β· Kokoro-82M Β· FLUX Β· Gradio</div>'
41
+ ' <div class="ec-meta">πŸ”Œ off the grid &nbsp;Β·&nbsp; 🎨 off-brand</div>'
42
+ ' </div>'
43
+ '</div>'
44
+ )
45
+ with gr.Row(elem_id="end-actions"):
46
+ end_again_btn = gr.Button("β†Ί begin again", elem_id="btn-end-again",
47
+ elem_classes="menu-btn")
48
+ end_leave_btn = gr.Button("leave the wood", elem_id="btn-end-leave",
49
+ elem_classes="menu-btn")
50
+ ```
51
+
52
+ `new_string`:
53
+ ```python
54
+ with gr.Column(elem_id="end-overlay") as end_overlay:
55
+ # hero: the foggy backdrop + the per-ending epitaph (set by applyEnd())
56
+ gr.HTML(
57
+ f'<div id="end-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
58
+ '<div id="end-scrim"></div>'
59
+ '<p id="end-epitaph"></p>'
60
+ )
61
+ # the two actions, grouped + centered right under the epitaph
62
+ with gr.Row(elem_id="end-actions"):
63
+ end_again_btn = gr.Button("β†Ί begin again", elem_id="btn-end-again",
64
+ elem_classes="end-btn")
65
+ end_leave_btn = gr.Button("leave the wood", elem_id="btn-end-leave",
66
+ elem_classes="end-btn end-btn-dim")
67
+ # credits sink to a quiet footer pinned to the bottom (position:fixed in Β§27)
68
+ gr.HTML(
69
+ '<div id="end-credits">'
70
+ ' <div class="ec-title">HOLLOW</div>'
71
+ ' <div class="ec-line">a thing that waited at the edge of the wood</div>'
72
+ ' <div class="ec-meta">Qwen3-8B Β· Kokoro-82M Β· FLUX Β· Gradio'
73
+ ' &nbsp;Β·&nbsp; πŸ”Œ off the grid &nbsp;Β·&nbsp; 🎨 off-brand</div>'
74
+ '</div>'
75
+ )
76
+ ```
77
+
78
+ - [ ] **Step 3: Verify import.** Run: `./.venv/Scripts/python -c "import app; print('ok')"` β†’ `ok`.
79
+
80
+ - [ ] **Step 4: Commit.**
81
+ ```bash
82
+ git add app.py
83
+ git commit -m "feat(end): re-shape the end overlay for layout B (hero epitaph + footer credits)"
84
+ ```
85
+
86
+ ---
87
+
88
+ ### Phase 2: Rewrite Β§27 CSS for layout B (styles.css)
89
+
90
+ Replace the entire Β§27 block. The new rules: center the overlay's content for real (neutralize Gradio wrappers), make the epitaph the hero, render the two actions as grouped, centered, understated text links (`leave the wood` dimmer), and pin the credits as a fixed bottom footer. Keep the foggy `#end-bg`/`#end-scrim`, the `.shown` opacity reveal, and the reduced-motion-safe rise (do NOT add `end-rise` to the `prefers-reduced-motion` block β€” it animates opacity, and disabling opacity there has broken effects before).
91
+
92
+ **Files:** Modify `styles.css` (the `/* ── 27. End screen ... ── */` block at the end of the file).
93
+
94
+ - [ ] **Step 1: Read the current Β§27 block** (last block in the file) and confirm it matches the `old_string` below; adapt if it drifted.
95
+
96
+ - [ ] **Step 2: Replace Β§27.** Edit `styles.css`:
97
+
98
+ `old_string`:
99
+ ```css
100
+ /* ── 27. End screen β€” epitaph + credits over the fog ── */
101
+ #end-overlay {
102
+ position: fixed; inset: 0; z-index: 80;
103
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
104
+ gap: 26px;
105
+ opacity: 0; pointer-events: none;
106
+ transition: opacity 1.6s ease;
107
+ }
108
+ #end-overlay.shown { opacity: 1; pointer-events: auto; }
109
+ #end-bg {
110
+ position: fixed; inset: -6%; z-index: -2;
111
+ background-size: cover; background-position: center 40%; background-repeat: no-repeat;
112
+ filter: grayscale(1) blur(30px) brightness(0.26);
113
+ }
114
+ #end-scrim { position: fixed; inset: 0; z-index: -1;
115
+ background: radial-gradient(ellipse at 50% 50%, transparent 30%, rgba(4,3,8,0.92) 100%); }
116
+ #end-card { text-align: center; max-width: min(760px, 86vw); }
117
+ #end-epitaph {
118
+ font-family: 'Crimson Text', Georgia, serif; font-style: italic;
119
+ font-size: 1.5rem; color: #e6dff2; text-shadow: 0 2px 18px #000; margin-bottom: 34px;
120
+ }
121
+ #end-credits { animation: end-rise 2.2s ease 0.4s both; }
122
+ #end-credits .ec-title {
123
+ font-family: 'Special Elite', monospace; letter-spacing: 0.4em; font-size: 1.2rem;
124
+ color: #c4bcd8; margin-bottom: 12px; }
125
+ #end-credits .ec-line {
126
+ font-family: 'Crimson Text', serif; font-style: italic; color: #9a90b2; margin-bottom: 16px; }
127
+ #end-credits .ec-meta {
128
+ font-family: 'Special Elite', monospace; font-size: 0.66rem; letter-spacing: 0.18em;
129
+ color: #6f6590; margin-top: 4px; }
130
+ @keyframes end-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
131
+ #end-actions { display: flex; gap: 18px; }
132
+ ```
133
+
134
+ `new_string`:
135
+ ```css
136
+ /* ── 27. End screen β€” epitaph hero + quiet footer credits over the fog (layout B) ── */
137
+ #end-overlay {
138
+ position: fixed; inset: 0; z-index: 80;
139
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
140
+ gap: 30px;
141
+ opacity: 0; pointer-events: none;
142
+ transition: opacity 1.6s ease;
143
+ }
144
+ #end-overlay.shown { opacity: 1; pointer-events: auto; }
145
+
146
+ /* neutralize Gradio's full-width block wrappers so the inner content truly centers */
147
+ #end-overlay > * {
148
+ width: auto !important; max-width: 90vw !important; min-width: 0 !important;
149
+ background: transparent !important; border: none !important; box-shadow: none !important;
150
+ display: flex; flex-direction: column; align-items: center;
151
+ }
152
+
153
+ #end-bg {
154
+ position: fixed; inset: -6%; z-index: -2;
155
+ background-size: cover; background-position: center 40%; background-repeat: no-repeat;
156
+ filter: grayscale(1) blur(30px) brightness(0.26);
157
+ }
158
+ #end-scrim { position: fixed; inset: 0; z-index: -1;
159
+ background: radial-gradient(ellipse at 50% 50%, transparent 30%, rgba(4,3,8,0.92) 100%); }
160
+
161
+ /* the hero line */
162
+ #end-epitaph {
163
+ font-family: 'Crimson Text', Georgia, serif; font-style: italic;
164
+ font-size: 1.95rem; color: #e6dff2; text-shadow: 0 2px 18px #000;
165
+ text-align: center; max-width: min(820px, 88vw); margin: 0;
166
+ }
167
+
168
+ /* the two actions β€” grouped, centered, understated text links */
169
+ #end-actions {
170
+ flex-direction: row !important; justify-content: center; gap: 34px;
171
+ }
172
+ #end-actions .end-btn {
173
+ background: none !important; border: none !important; box-shadow: none !important;
174
+ width: auto !important; min-width: 0 !important; flex: 0 0 auto !important;
175
+ font-family: 'Special Elite', monospace; letter-spacing: 0.2em; font-size: 0.92rem;
176
+ color: #d7cfe6 !important; opacity: 0.85;
177
+ transition: opacity 0.25s ease, color 0.25s ease;
178
+ }
179
+ #end-actions .end-btn:hover { opacity: 1; color: #f0eaff !important; }
180
+ #end-actions .end-btn-dim { color: #7d7498 !important; font-size: 0.82rem; opacity: 0.7; }
181
+
182
+ /* credits β€” a quiet footer pinned to the bottom (fixed β†’ out of the centered flow) */
183
+ #end-credits {
184
+ position: fixed; left: 0; right: 0; bottom: 26px;
185
+ display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center;
186
+ animation: end-rise 2.2s ease 0.4s both;
187
+ }
188
+ #end-credits .ec-title {
189
+ font-family: 'Special Elite', monospace; letter-spacing: 0.4em; font-size: 0.95rem;
190
+ color: #c4bcd8; }
191
+ #end-credits .ec-line {
192
+ font-family: 'Crimson Text', serif; font-style: italic; color: #9a90b2; font-size: 0.86rem; }
193
+ #end-credits .ec-meta {
194
+ font-family: 'Special Elite', monospace; font-size: 0.64rem; letter-spacing: 0.18em;
195
+ color: #6f6590; }
196
+ @keyframes end-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 0.82; transform: none; } }
197
+ ```
198
+
199
+ - [ ] **Step 3: Verify import still OK** (CSS-only, but cheap to confirm nothing else broke): `./.venv/Scripts/python -c "import app; print('ok')"` β†’ `ok`.
200
+
201
+ - [ ] **Step 4: Commit.**
202
+ ```bash
203
+ git add styles.css
204
+ git commit -m "style(end): layout B β€” epitaph hero, grouped text-link actions, footer credits (Β§27)"
205
+ ```
206
+
207
+ ---
208
+
209
+ ### Phase 3: Verify suite + hand off the live check
210
+
211
+ - [ ] **Step 1:** Run the full suite β€” `./.venv/Scripts/python -m pytest -q` β†’ all green (269). No test changes were made; if anything fails, it's a regression β€” investigate, don't delete coverage.
212
+ - [ ] **Step 2 (HUMAN-ONLY β€” Pablo, `.venv-tts` + Ollama, `HOLLOW_FAST_FINALE=good|loop|bad`):** play each finale to the end and confirm: the overlay fades in ~1 s after the last beat; the epitaph is **centered** and large; the two actions sit **grouped + centered** right under it (`leave the wood` visibly dimmer); the credits sit as a **dim, centered footer at the bottom** that rises in; **begin again** restarts with the opening ritual and **leave the wood** returns to the menu; a fresh run still re-reveals correctly. Do NOT attempt this as the agent β€” apply the code and report it as pending Pablo's check.
213
+
214
+ ---
215
+
216
+ ## Self-Review
217
+ - **Spec coverage:** epitaph as hero, centered (Phase 2 `#end-epitaph` + overlay centering) βœ…; actions grouped/centered, `leave` dimmer (Phase 1 classes + Phase 2 `#end-actions`/`.end-btn`/`.end-btn-dim`) βœ…; credits demoted to a fixed bottom footer (Phase 1 markup move + Phase 2 `#end-credits` fixed) βœ…; foggy backdrop + reveal preserved (`#end-bg`/`#end-scrim`/`.shown`) βœ….
218
+ - **No-change surfaces confirmed:** `applyEnd()` targets `#end-epitaph` (id kept); button ids `btn-end-again`/`btn-end-leave` kept β†’ `_reset`/`_to_menu` wiring + JS button wiring untouched; finale generator tuples untouched (marker reveal unchanged). No Python logic or tests change.
219
+ - **Consistency:** Phase 1 emits classes `end-btn` / `end-btn-dim`, which Phase 2 styles; Phase 1 keeps `#end-credits` markup that Phase 2 pins as the footer.
220
+ - **Gotcha guard:** `#end-overlay` is `position:fixed` with no transformed ancestor (top-level `gr.Column`), so the fixed `#end-bg`/`#end-scrim`/`#end-credits` anchor to the viewport βœ…; `end-rise` is NOT added to the `prefers-reduced-motion` block (never disable opacity there) βœ…; all edits via the Edit tool, never PowerShell (UTF-8 glyphs) βœ….
221
+ - **Risk:** the `#end-overlay > *` wrapper-neutralizer sets children to `flex-direction:column`; `#end-actions` overrides back to `row !important`. If Gradio's button base CSS still forces full width, the `width:auto/flex:0 0 auto !important` on `.end-btn` is the guard β€” flag to Pablo if buttons still stretch in the live check.
docs/superpowers/plans/2026-06-15-loop-bad-silent-convulse.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Loop & Bad Finales β€” Silent Convulse Beat Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED first reads β€” `CLAUDE.md` (or `AGENTS.md`) and `docs/superpowers/AGENT-EXECUTION-BRIEF.md`. Then execute this plan phase-by-phase, one commit per phase. Steps use checkbox (`- [ ]`) syntax. TDD where marked β€” run the test RED before implementing.
4
+
5
+ **Goal:** Stop the **loop** and **bad** finale convulsions from cutting off the line being spoken just before them. Make each convulsion a **silent `frenzy` beat** (no text, no new audio) β€” exactly like the **good** finale already does β€” so the prior line's audio drains and the convulsion plays in silence, then the next line lands cleanly.
6
+
7
+ **Root cause:** In `finale.py`, the loop/bad convulsion is one `"hollow"` step carrying text `"..."` *and* `stage="frenzy"`. The player synthesizes/enqueues a `"..."` blip and fires the violent convulsion DOM update simultaneously, landing on the tail of the preceding spoken line β†’ it reads/sounds cut. The good finale fixed this by splitting the convulsion into a spoken line **+ a separate silent `frenzy` beat** (`_step("hollow", "", 2.0, stage="frenzy")`) and guarding `if step["text"]:` in its player so the empty beat adds no bubble. Loop/bad never got this (CLAUDE.md "Next" #2).
8
+
9
+ **Architecture:** Pure-data change in `finale.py` (two step texts β†’ `""`) + a one-line guard in two player generators in `app.py`. No `speak_it` change is needed: `speak("")` returns `None` (voice.py guards empty text), so an empty beat is silent and `_voice_and_pause` falls back to the step's scripted pause β€” and those pauses already match the CSS convulsion lengths (loop `.entity-convulse-loop` β‰ˆ 0.55 s Γ— 3 = 1.65 s; bad `.entity-frenzy-wrap` β‰ˆ 0.55 s Γ— 6 = 3.3 s + the 3.2 s `ghost-frenzy` stab).
10
+
11
+ **Tech Stack:** Python (`finale.py`, `app.py`), pytest. Edit tool only (UTF-8: `…`, em-dashes, `β€œ ”`). `import app` + `pytest -q` (269 green) stays green; new tests raise it.
12
+
13
+ **No-break check (already verified):** finale tests assert stages/strikes/speakers/wound-text (`tests/test_finale.py`); player tests assert the entity HTML at output index 6, the heartbeat, and cue-audio (`tests/test_app.py::TestFinaleBuildAndConvulse`). **None** assert the `"..."` text or chat-bubble counts, so flipping the text and dropping the empty bubble breaks nothing. The convulsion *visual* (`render_entity(..., "convulse_loop"/"frenzy")`) is unchanged β€” `entity-frenzy-wrap` still appears.
14
+
15
+ **Key seams (read the real text first β€” line numbers drift):** `finale.py` `finale_steps` (loop) and `finale_steps_bad`; `app.py` `_play_finale` (loop player, the `for step in finale_steps(claimed):` loop) and `_play_finale_bad` (the `for step in finale_steps_bad(wounds):` loop). The reference implementation is `_play_finale_good` (`if step["text"]:` guard) + `finale_steps_good` (silent `frenzy` beat).
16
+
17
+ ---
18
+
19
+ ### Phase 1: Guard the loop & bad players against empty-text beats (app.py)
20
+
21
+ Add the same `if step["text"]:` guard the good player already has, to the loop and bad players. This is a **no-op until Phase 2** (the texts are still `"..."`, which is truthy), so each commit stays clean β€” no empty bubble ever ships.
22
+
23
+ **Files:** Modify `app.py` (`_play_finale`, `_play_finale_bad`). Reference: `_play_finale_good` already does `if step["text"]:` before its `chat_hist` append.
24
+
25
+ - [ ] **Step 1: Read the two player loops** and confirm the `old_string`s below match (they were unguarded as of this writing).
26
+
27
+ - [ ] **Step 2: Guard the loop player.** Edit `app.py`:
28
+
29
+ `old_string`:
30
+ ```python
31
+ for step in finale_steps(claimed):
32
+ role = "assistant" if step["speaker"] == "hollow" else "user"
33
+ chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
34
+ ```
35
+ `new_string`:
36
+ ```python
37
+ for step in finale_steps(claimed):
38
+ role = "assistant" if step["speaker"] == "hollow" else "user"
39
+ if step["text"]: # silent beats (the convulsion) add no bubble
40
+ chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
41
+ ```
42
+
43
+ - [ ] **Step 3: Guard the bad player.** Edit `app.py`:
44
+
45
+ `old_string`:
46
+ ```python
47
+ for step in finale_steps_bad(wounds):
48
+ role = "assistant" if step["speaker"] == "hollow" else "user"
49
+ chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
50
+ ```
51
+ `new_string`:
52
+ ```python
53
+ for step in finale_steps_bad(wounds):
54
+ role = "assistant" if step["speaker"] == "hollow" else "user"
55
+ if step["text"]: # silent beats (the convulsion) add no bubble
56
+ chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
57
+ ```
58
+
59
+ - [ ] **Step 4: Verify + suite.** Run: `./.venv/Scripts/python -c "import app; print('ok')"` β†’ `ok`; then `./.venv/Scripts/python -m pytest -q` β†’ 269 green (no behavior change yet).
60
+
61
+ - [ ] **Step 5: Commit.**
62
+ ```bash
63
+ git add app.py
64
+ git commit -m "refactor(finale): guard loop & bad players against empty-text beats (mirror good)"
65
+ ```
66
+
67
+ ---
68
+
69
+ ### Phase 2: Make the loop & bad convulsions silent beats (finale.py) β€” TDD
70
+
71
+ Flip both `frenzy` step texts from `"..."` to `""`. With the Phase 1 guard, no empty bubble appears; with `speak("")` returning `None`, the beat is silent and keeps its scripted pause.
72
+
73
+ **Files:** Test: `tests/test_finale.py` (`TestConvulsionStages`); Modify: `finale.py` (`finale_steps`, `finale_steps_bad`).
74
+
75
+ - [ ] **Step 1: Write the failing tests.** In `tests/test_finale.py`, add to `class TestConvulsionStages` (after `test_loop_script_has_a_convulse_stage_before_the_turn`):
76
+
77
+ ```python
78
+ def test_loop_convulse_is_a_silent_beat(self):
79
+ # the convulsion must carry no text (like good) so the line before it
80
+ # drains and the convulsion plays in silence β€” not cut mid-sentence
81
+ frenzy = [s for s in finale_steps(["a", "b"]) if s["stage"] == "frenzy"]
82
+ assert len(frenzy) == 1 and frenzy[0]["text"] == ""
83
+
84
+ def test_bad_convulse_is_a_silent_beat(self):
85
+ frenzy = [s for s in finale_steps_bad(["w1", "w2"]) if s["stage"] == "frenzy"]
86
+ assert len(frenzy) == 1 and frenzy[0]["text"] == ""
87
+
88
+ def test_good_convulse_is_a_silent_beat(self):
89
+ frenzy = [s for s in finale_steps_good(["a", "b"]) if s["stage"] == "frenzy"]
90
+ assert len(frenzy) == 1 and frenzy[0]["text"] == ""
91
+ ```
92
+
93
+ - [ ] **Step 2: Run them** β†’ the loop and bad cases FAIL (text is `"..."`), the good case PASSES. Run: `./.venv/Scripts/python -m pytest tests/test_finale.py::TestConvulsionStages -q`. Expected: 2 failed, rest passed.
94
+
95
+ - [ ] **Step 3: Silence the loop convulsion.** Edit `finale.py`:
96
+
97
+ `old_string`:
98
+ ```python
99
+ _step("hollow", "...", 1.7, stage="frenzy"),
100
+ ```
101
+ `new_string`:
102
+ ```python
103
+ # a SILENT convulsion beat (mirrors good): the line before drains and
104
+ # the convulsion plays in silence, so nothing is cut mid-sentence
105
+ _step("hollow", "", 1.7, stage="frenzy"),
106
+ ```
107
+
108
+ - [ ] **Step 4: Silence the bad convulsion.** Edit `finale.py`:
109
+
110
+ `old_string`:
111
+ ```python
112
+ # the convulsion: every face cycles over the body before it settles
113
+ _step("hollow", "...", 3.6, stage="frenzy"),
114
+ ```
115
+ `new_string`:
116
+ ```python
117
+ # the convulsion: a SILENT beat (mirrors good) so the line before drains
118
+ # and the convulsion plays without cutting it; faces cycle, then rage
119
+ _step("hollow", "", 3.6, stage="frenzy"),
120
+ ```
121
+
122
+ - [ ] **Step 5: Run the tests + full suite** β†’ all green. Run: `./.venv/Scripts/python -m pytest tests/test_finale.py::TestConvulsionStages -q` then `./.venv/Scripts/python -m pytest -q` (271 green: 269 + 2 new that now pass; the good one already passed).
123
+
124
+ - [ ] **Step 6: Commit.**
125
+ ```bash
126
+ git add finale.py tests/test_finale.py
127
+ git commit -m "fix(finale): silent convulse beat for loop & bad so the prior line isn't cut"
128
+ ```
129
+
130
+ ---
131
+
132
+ ### Phase 3: Full suite + live verification
133
+
134
+ - [ ] **Step 1:** `./.venv/Scripts/python -m pytest -q` β†’ all green; `./.venv/Scripts/python -c "import app; print('ok')"` β†’ `ok`.
135
+ - [ ] **Step 2 (HUMAN-ONLY β€” Pablo, `.venv-tts` + Ollama):** play `HOLLOW_FAST_FINALE=loop` and `=bad` to the end and confirm the line **just before** the convulsion finishes speaking and is **not** cut; the convulsion plays in silence (no stray "…" blip), then settles (loop β†’ `end` face + flatline; bad β†’ `rage` face + threat) and the next line lands cleanly. Cross-check that `=good` is unchanged. Do NOT attempt this as the agent β€” apply the code and report it pending Pablo's check.
136
+
137
+ ---
138
+
139
+ ## Self-Review
140
+ - **Spec coverage:** loop convulse silent (Phase 2 Step 3) βœ…; bad convulse silent (Phase 2 Step 4) βœ…; no empty transcript bubble (Phase 1 guard) βœ…; prior line drains / convulsion in silence (silent beat + existing scripted pauses that match the CSS convulsion lengths) βœ….
141
+ - **Consistency:** mirrors `finale_steps_good` (silent `""` frenzy beat) and `_play_finale_good` (`if step["text"]:` guard) β€” the proven pattern. Stages unchanged, so `tests/test_finale.py::TestConvulsionStages` ordering tests still hold; the new tests lock the silence in for all three endings.
142
+ - **No-tuple / no-regression:** no finale generator tuple changed; the convulsion *visual* (`render_entity` `convulse_loop`/`frenzy`) is untouched, so `entity-frenzy-wrap`/`entity-end` player assertions still pass. `speak("")`β†’`None` keeps the beat silent without touching `speak_it`.
143
+ - **Risk:** if the bad convulsion ever feels like it starts a hair before the long prior line ("…you never could.") fully ends, that's the prior line draining under a silent convulsion (exactly the good-finale behavior) β€” acceptable; flag to Pablo only if it reads wrong in the live check. Edits via Edit tool only (UTF-8 glyphs).
144
+ ```