Spaces:
Running on Zero
Running on Zero
Commit ·
fe45fde
1
Parent(s): 121e41a
docs: intro/menu polish + audio plans (this session)
Browse filesCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/superpowers/plans/2026-06-14-frame-blend-audible-music.md +162 -0
- docs/superpowers/plans/2026-06-14-intro-cinematic-letterbox.md +376 -0
- docs/superpowers/plans/2026-06-14-intro-menu-blend-music.md +244 -0
- docs/superpowers/plans/2026-06-14-intro-menu-game-aesthetic.md +275 -0
- docs/superpowers/plans/2026-06-14-intro-menu-polish-2.md +256 -0
- docs/superpowers/plans/2026-06-14-intro-menu-polish.md +465 -0
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 |
+
'— 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 · 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 |
+
'— it remembers what you give it.</div>'
|
| 153 |
+
'</div>'
|
| 154 |
+
'<div class="menu-credit">A LOST CHILD WAITS · 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`.
|