Spaces:
Sleeping
Sleeping
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 Β· π¨ 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 |
+
' Β· π off the grid Β· π¨ 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 |
+
```
|