fuzzyworld

explodorama / commit

Offer: select, then Choose; dialogs fit the screen; mode clips and stills

ac534e578611adb164722d70b15a002e1c826a89 · Pavel Safronov · 2026-10-24 17:34:15 -0700 · parents 32dd53cd7a7d

Claude Code usage: this commit ~41.6K out / ~29.3M cached · cost unknown (no per-model trailer) · session 4 tally 1.4K in / 682.4K out / 259.1M cached · usage by commit and session

Offer: select, then Choose; dialogs fit the screen; mode clips and stills

- The gem's offer takes two taps: a card selects it and its clip plays
  above (Heart and Coin bag show their picture), Choose takes it.
- Dialogs are never taller than the visible screen (100dvh); only their
  body scrolls, so the buttons always show.
- The modes' tag plays the rainbow gem (Developer) or Immortal; under
  reduced motion every clip is a still of its moment.
- Hidden clip canvases really hide; screenshots retaken.

Usage: 1.4K in / 682.4K out / 259.1M cached (session tally)
rtk:   1.9M saved, 38.9% over 4165 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   1 +
 PLAN.md                              |   5 ++-
 README.md                            |  10 +++--
 decisions.md                         |  42 +++++++++++++++++
 docs/screenshots/almanac-charges.png | Bin 280855 -> 282941 bytes
 docs/screenshots/almanac-enemies.png | Bin 196295 -> 195175 bytes
 docs/screenshots/almanac.png         | Bin 400082 -> 410063 bytes
 docs/screenshots/auto-move.png       | Bin 217145 -> 217157 bytes
 docs/screenshots/bomb-picked.png     | Bin 216434 -> 216471 bytes
 docs/screenshots/death.png           | Bin 235149 -> 235128 bytes
 docs/screenshots/dev-offer.png       | Bin 217879 -> 200791 bytes
 docs/screenshots/enemies.png         | Bin 220100 -> 220425 bytes
 docs/screenshots/gem-offer.png       | Bin 214176 -> 214178 bytes
 docs/screenshots/gem.png             | Bin 214183 -> 214188 bytes
 docs/screenshots/info-hud.png        | Bin 183703 -> 175377 bytes
 docs/screenshots/info-mode.png       | Bin 215678 -> 215594 bytes
 docs/screenshots/lingering.png       | Bin 219582 -> 219627 bytes
 docs/screenshots/menu.png            | Bin 142192 -> 142122 bytes
 docs/screenshots/move-picked.png     | Bin 216597 -> 216615 bytes
 docs/screenshots/nuke.png            | Bin 240183 -> 240183 bytes
 src/main.ts                          |   1 +
 src/sim/demo.ts                      |  21 ++++++++-
 src/style.css                        |  85 +++++++++++++++++------------------
 src/ui/clip.ts                       |  13 ++++++
 src/ui/info.ts                       |   2 +-
 src/ui/offer.ts                      |  73 +++++++++++++++++-------------
 26 files changed, 172 insertions(+), 81 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8af9049..cd23d57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -126,6 +126,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Fixed
 
+- Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show.
 - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again.
 - The player is drawn a little higher, so their feet never touch the line between tiles.
 - A top-bar number bumping at the edge of its row (Turn) is no longer cut off.
diff --git a/PLAN.md b/PLAN.md
index a6723e8..3950f96 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -781,10 +781,13 @@ new scenes (*done 2026-10-24*, clip-only, `clipOnly` in sim/demo.ts: `lastEnemy`
 `crate`, `pillar` for walls; lives play the `dieEnemy` scene; turn and par, which only a number
 shows, and the modes have none); 5. the offer's cards,
 the almanac and the "Ouch!" replay (*done 2026-10-24*, the user's choices: one clip above the
-offer's cards, playing the card whose picture — now its own button — was tapped or pointed at;
+offer's cards, playing the card selected — *since 2026-10-24 (user request) a tap on a card
+selects it and Choose takes it*;
 almanac entries with a clip are buttons that open it in their own dialog, Back returning; the
 banner replays the player's own last 4 turns of the attempt, a demo room's worth of the level
 round where they died). Clips can now be several at once, fixed scenes or made each loop.
+*Also done 2026-10-24:* the modes' tag plays `rainbowGem` (Developer) or `immortal`, and under
+reduced motion every clip is a still of its moment (the scene played to its end, held).
 
 ## 13b. Run options (user request, 2026-10-21; built)
 
diff --git a/README.md b/README.md
index bfff82b..97942a4 100644
--- a/README.md
+++ b/README.md
@@ -129,7 +129,8 @@ made on the spot like the demo's scenes and new every loop: a bomb counting down
 lingering flame, a crate breaking, flame stopping at a pillar, the last enemy dying and the exit
 opening, losing a life, the exit, the gem, coins, each power-up and charge; for the actions (Move, Bomb,
 Wait) and the power-ups you work yourself (Run, Kick, Throw, Teleport, Diagonal, the Detonator),
-a finger shows how. Enemies have only their picture.
+a finger shows how. Enemies have only their picture. With reduced motion turned on, each clip is
+a still picture of its moment instead.
 
 **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level
 and seed, and has the **Almanac** — every power-up, charge and enemy type, with its
@@ -250,9 +251,10 @@ uncovered:** flame on it destroys it and lets out 3 enemies, just like the exit
 those enemies can't be hurt by the flame that let them out).
 
 **Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with
-what it does; it can't be closed without choosing, and choosing takes no turn. A clip above the
-cards plays the first one; tap a card's picture (or point at it with a mouse) to play that one,
-and tap the card itself to choose it. The 5 are drawn
+what it does; it can't be closed without choosing, and choosing takes no turn. Choosing takes two
+taps: **tap a card** to select it (it turns green, and a clip above the cards shows it in action),
+then **Choose** to take it. The first card is selected to begin with. Heart and Coin bag, which
+have nothing to show on the board, show their picture there instead. The 5 are drawn
 at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often
 than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The
 offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there
diff --git a/decisions.md b/decisions.md
index 7bc400d..20843d5 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1486,3 +1486,45 @@ open their clip, and a replay of the player's own last turns on the banner.
   9 × 11 board fitted and centred between the bars. The enemy shot taps a Blob (1, 7) now; the
   auto-move and long-press shots, which tap too, show what their comments say: the way to (3, 3)
   in blue with Go, and a bomb armed right of the player.
+
+### 2026-10-24 — Clips: the modes, and stills under reduced motion
+
+The last of the clips plan. **The modes' tag** now plays a clip: the rainbow gem beside the
+player, taken (Developer mode; a new clip-only scene, `rainbowGem`), or the player unharmed in a
+blast or by an enemy (`immortal`); a run with both shows Developer's. **Under reduced motion** a
+clip is a still, as the user chose ("whatever is clearer"): the scene played to its end at once
+and held — the moment just after it, flame still on the board, the player's pose — with no finger
+and no next loop. Checked in a browser with reduced motion emulated: the Blast dialog shows its
+still, the flame on the board; both mode clips play; no errors.
+
+### 2026-10-24 — The gem's offer: select, then Choose
+
+User report: in the (rainbow) gem's offer a tap on a card's description chose it at once; it should
+be two steps — tap to select and see it in action, then confirm. Now a tap anywhere on a card
+selects it (ringed green, `aria-pressed`), the clip above the cards plays it, and a **Choose Kick**
+button (it names the selection) takes it; the first card starts selected. Every gem's offer works
+this way, not only the rainbow one, so it is the same everywhere. The separate picture button and
+the mouse-hover preview of the step before are gone: the selection is the preview. Checked in a
+browser with touch: a tap on Kick keeps the dialog open and changes the button to "Choose Kick";
+Choose takes it (Kick appears in the top bar).
+
+### 2026-10-24 — Dialogs fit the visible screen
+
+User report (a screenshot from a phone): the gem's offer ran off the bottom of the screen, its
+Choose button cut in half. The dialog's body had a height guessed from the screen's (`100vh` less
+150 or 260 px for the rest); on a phone `100vh` includes the part behind the browser's address
+bar, and the offer's two-line title, clip and Choose button took more than the guess. Now the
+panel is a column never taller than what the browser shows (`100dvh`, less room for the close
+button above it; `100vh` where `dvh` isn't known) and only its body scrolls, so the title and
+buttons always show, whatever is in it. Checked at 390 × 640, 390 × 844 and 1280 × 600: the
+rainbow gem's offer and the almanac fit, the Choose button 30 px above the panel's bottom edge.
+
+### 2026-10-24 — Heart and Coin bag show their picture in the offer
+
+User report: selecting Heart or Coin bag in the gem's offer left the clip's place empty. They have
+no clip (nothing changes on the board: a life, coins), so their picture now shows there, big, on a
+pale box the clip's size and shape; any other selection brings the clip back. On the way: a
+hidden clip canvas didn't hide (`canvas.clip` sets `display: block`, which beats `hidden`), so
+`canvas.clip[hidden]` is `display: none` now — the "Ouch!" banner's canvas, hidden when there is no
+replay, relied on it too. Checked in a browser: Heart and Coin bag show their pictures, Kick its
+clip again.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index eddefd4..e3f0657 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index ed0017a..3f47be9 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index de2e477..810ccae 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index c6f0331..a033ca7 100644
Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index a9fefb5..0a79c17 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index 07505eb..3e07817 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index d40573e..39ca629 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 6e4d19b..ab0b126 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png
index 130af7f..9f05d0b 100644
Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 6b318ff..37727c7 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index bc3fe13..f46d8f6 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index 9c1cf21..350d19f 100644
Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 77624f6..38164a7 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index 024c09c..c2b7886 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png
index 867f7b1..f8790a3 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index 5ed23e1..c4880b2 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/src/main.ts b/src/main.ts
index 1a648bf..57e1417 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -786,6 +786,7 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield,
   freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy,
   exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue',
+  rainbowGem: 'gem_green', immortal: 'star',
   dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
 }
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index f1ab70b..63c356c 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -40,7 +40,7 @@ export type SceneKind =
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
   | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy'
   | 'exit' | 'doorBlast'
-  | 'lastEnemy' | 'crate' | 'pillar'
+  | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
 /**
@@ -396,6 +396,25 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     clipOnly: true,
     shows: (t) => t.events.some((e) => e.type === 'blockDestroyed' && t.next.gems.some((g) => at(g.pos, e.pos))),
   },
+  // The run's modes, for their tag's dialog.
+  rainbowGem: {
+    caption: 'Developer mode: a rainbow gem beside you, offering every power-up and charge.',
+    enemies: ['blob'],
+    prepare: (s, rng) => {
+      s.options = { ...s.options, dev: true }
+      const near = tilesAt(s, 1)
+      if (near.length) s.gems.push({ pos: near[rng.int(near.length)]!, color: 'rainbow', extra: true })
+    },
+    clipOnly: true,
+    shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'),
+  },
+  immortal: {
+    caption: 'Immortal: nothing can hurt you.',
+    enemies: ['seeker', 'blob'],
+    prepare: (s) => (s.options = { ...s.options, immortal: true }),
+    clipOnly: true,
+    shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)),
+  },
   pillar: {
     caption: 'Flame stops at a pillar.',
     enemies: ['blob'],
diff --git a/src/style.css b/src/style.css
index d405422..d68733e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -509,10 +509,18 @@ img {
 }
 
 /* The RPG expansion's parchment panel, stretched by its edges (9-slice). */
+/* A column: picture, title, body, buttons. It is never taller than the screen — what the browser
+   shows of it, without the address bar (dvh; vh where dvh isn't known), with room for the close
+   button sticking out above — and only the body scrolls, so the buttons always show (user report, 2026-10-24: a long offer's Choose button ran
+   off the bottom of a phone; the body's height was a guess from 100vh). */
 #dialog .panel {
   position: relative;
-  display: grid;
-  justify-items: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-sizing: border-box;
+  max-height: calc(100vh - 64px);
+  max-height: calc(100dvh - 64px);
   gap: 10px;
   padding: 14px 18px 18px;
   border: 12px solid transparent;
@@ -549,7 +557,9 @@ img {
 }
 
 #dialog-body {
-  max-height: calc(100vh - 260px);
+  flex: 0 1 auto;
+  align-self: stretch;
+  min-height: 0;
   overflow-y: auto;
   /* Scrolls by touch, wheel or trackpad, without a scroll bar across the parchment. */
   scrollbar-width: none;
@@ -573,10 +583,6 @@ img {
 }
 
 /* A dialog answered from its body (the gem's choice) has no buttons below: more room for it. */
-#dialog .panel.required #dialog-body {
-  max-height: calc(100vh - 150px);
-}
-
 #dialog-buttons:empty {
   display: none;
 }
@@ -667,8 +673,8 @@ button.almanac-entry.has-clip:active {
   background: rgb(53 53 65 / 0.06);
 }
 
-/* The gem's choice (ui/offer.ts): the clip of the power-up looked at, kept in view while the cards
-   scroll; each card's picture beside it, a button that plays it there; the card itself chooses. */
+/* The gem's choice (ui/offer.ts): the clip of the selected power-up, kept in view while the cards
+   scroll; a tap on a card selects it (ringed green), and the Choose button takes it. */
 .offer-clip {
   position: sticky;
   top: 0;
@@ -677,39 +683,9 @@ button.almanac-entry.has-clip:active {
   background: #ece3ce;
 }
 
-.offer-row {
-  display: grid;
-  grid-template-columns: 56px 1fr;
-  gap: 8px;
-  align-items: stretch;
-  margin: 0 0 8px;
-}
-
-.offer-preview {
-  display: grid;
-  place-items: center;
-  padding: 0;
-  border: 2px solid var(--ink);
-  border-radius: 12px;
-  background: #fff8e6;
-  box-shadow: 0 3px 0 rgb(53 53 65 / 0.35);
-  cursor: pointer;
-}
-
-.offer-preview img {
-  width: 40px;
-  height: 40px;
-}
-
-/* The one playing above. */
-.offer-row.previewed .offer-preview {
-  border-color: var(--accent-dark);
-  background: #d9f6e9;
-}
-
 .offer-choice {
   width: 100%;
-  margin: 0;
+  margin: 0 0 8px;
   text-align: left;
   padding: 8px 10px;
   border: 2px solid var(--ink);
@@ -721,13 +697,29 @@ button.almanac-entry.has-clip:active {
   cursor: pointer;
 }
 
-#dialog-body .offer-choice strong {
+/* In place of the clip, for a power-up with nothing to show on the board (Heart, Coin bag): its
+   picture, big, in a room-shaped box as wide as a clip. */
+.offer-still {
   display: block;
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 300px;
+  aspect-ratio: 9 / 7;
+  margin: 0 auto;
+  padding: 18%;
+  border-radius: 8px;
+  background: #fff8e6;
+  object-fit: contain;
 }
 
-#dialog-body .offer-choice p {
-  margin: 2px 0 0;
-  font-size: 14px;
+.offer-still[hidden] {
+  display: none;
+}
+
+/* The selected one. */
+.offer-choice[aria-pressed='true'] {
+  border-color: var(--accent-dark);
+  background: #d9f6e9;
 }
 
 .offer-choice:active {
@@ -1349,6 +1341,11 @@ canvas.clip {
   border-radius: 8px;
 }
 
+/* A clip's canvas that isn't wanted (the banner's when nothing is replayed, the offer's for Heart). */
+canvas.clip[hidden] {
+  display: none;
+}
+
 /* A clip with the finger (ui/clip.ts): the canvas in a frame, the finger and its marks over it. */
 .clip-frame {
   position: relative;
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
index 1b747ab..86d0433 100644
--- a/src/ui/clip.ts
+++ b/src/ui/clip.ts
@@ -127,6 +127,7 @@ function tick(c: Clip): void {
     c.next = 0
     c.at = time + LEAD_SECONDS
     c.anim = newAnimation()
+    if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c)
     if (c.finger) {
       // The finger appears beside the player, where the eye should start (as in the demo; user
       // report, 2026-10-24: it came in from the top left corner), and glides from there.
@@ -158,6 +159,18 @@ function tick(c: Clip): void {
   }
 }
 
+/**
+ * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the
+ * scene's moment, just after it — the flame still on the board, the player's pose — held, with
+ * no finger and no next loop.
+ */
+function still(c: Clip): void {
+  for (const action of c.scene!.actions) c.state = step(c.state!, action).state
+  c.next = c.scene!.actions.length
+  c.at = Infinity
+  c.finger?.classList.add('away')
+}
+
 function play(c: Clip, action: Action): void {
   const result = step(c.state!, action)
   animateTurn(c.anim, c.state!, result, action, time)
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 8c460bc..f64baa9 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -135,7 +135,7 @@ export function hudInfo(item: HudItem, s: GameState): Info {
         clip: 'dieEnemy',
       }
     case 'modes':
-      return { icon: 'lever', title: 'Run settings', body: modeLines(s) }
+      return { icon: 'lever', title: 'Run settings', body: modeLines(s), clip: s.options.dev ? 'rainbowGem' : 'immortal' }
     case 'level':
       return {
         icon: 'flag_green_a',
diff --git a/src/ui/offer.ts b/src/ui/offer.ts
index 321fc11..4a71827 100644
--- a/src/ui/offer.ts
+++ b/src/ui/offer.ts
@@ -2,7 +2,7 @@ import { isCharge, type OfferKind } from '../sim/types'
 import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import type { SceneKind } from '../sim/demo'
 import { startClip, stopClip } from './clip'
-import { closeDialog, openDialog } from './dialog'
+import { openDialog } from './dialog'
 import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
@@ -10,64 +10,77 @@ import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
  * laid out like the almanac. One must be picked; the dialog can't be closed any other way.
  * Developer mode's rainbow gem offers the charges too (2026-10-24).
  *
- * One clip above the cards (PLAN.md §13c; user's choice, 2026-10-24) plays the power-up looked at:
- * the first one's at first, then the one whose picture was last tapped (or hovered with a mouse).
- * The picture is its own button, beside the card; choosing is still one tap on the card.
+ * Choosing takes two taps (user request, 2026-10-24: a tap on a card chose it at once): a tap on a
+ * card selects it, and the clip above the cards (PLAN.md §13c) plays it; **Choose** takes the one
+ * selected. The first card is selected at first.
  */
 let chosen: ((kind: OfferKind) => void) | null = null
+let selected: OfferKind | null = null
 
 export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void): void {
   chosen = choose
   openDialog({
     title: 'Choose a power-up',
     body:
-      '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas></div>' +
+      '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas><img class="offer-still" alt="" draggable="false" hidden></div>' +
       offer
         .map((k) => {
           const text = isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]
           return (
-            `<div class="offer-row" data-row="${k}">` +
-            `<button type="button" class="offer-preview" data-preview="${k}" aria-label="Show ${text.name}">` +
-            `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false"></button>` +
-            `<button type="button" class="offer-choice" data-offer="${k}"><strong>${text.name}</strong><p>${text.text}</p></button></div>`
+            `<button type="button" class="almanac-entry offer-choice" data-offer="${k}" aria-pressed="false">` +
+            `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` +
+            `<div><strong>${text.name}</strong><p>${text.text}</p></div></button>`
           )
         })
         .join(''),
-    buttons: [],
+    buttons: [
+      {
+        label: 'Choose',
+        kind: 'primary',
+        onClick: () => {
+          chosen = null
+          if (selected) choose(selected)
+        },
+      },
+    ],
     required: true,
   })
-  preview(offer.find((k) => clipOf(k)) ?? offer[0]!)
+  select(offer[0]!)
+}
+
+function nameOf(k: OfferKind): string {
+  return (isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).name
 }
 
 function clipOf(k: OfferKind): SceneKind | undefined {
   return isCharge(k) ? k : POWER_UP_CLIP[k]
 }
 
-/** The clip above the cards plays `k` (one without a clip, Heart or Coin bag, leaves it blank). */
-function preview(k: OfferKind): void {
+/**
+ * Selects `k`: its card marked, the Choose button naming it, and the clip above the cards playing
+ * it; one without a clip (Heart, Coin bag: nothing to see on the board) shows its picture there,
+ * big, instead (user report, 2026-10-24: the space was left empty).
+ */
+function select(k: OfferKind): void {
+  selected = k
+  for (const card of document.querySelectorAll<HTMLElement>('#dialog-body .offer-choice')) {
+    card.setAttribute('aria-pressed', String(card.dataset.offer === k))
+  }
+  const label = document.querySelector('#dialog-buttons button span')
+  if (label) label.textContent = `Choose ${nameOf(k)}`
   const canvas = document.querySelector<HTMLCanvasElement>('#dialog-body .offer-clip canvas.clip')
-  if (!canvas) return
-  for (const row of document.querySelectorAll('.offer-row')) row.classList.toggle('previewed', (row as HTMLElement).dataset.row === k)
+  const still = document.querySelector<HTMLImageElement>('#dialog-body .offer-still')
+  if (!canvas || !still) return
   const clip = clipOf(k)
+  canvas.hidden = !clip
+  still.hidden = !!clip
   if (clip) return startClip(canvas, clip)
   stopClip(canvas)
-  canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height)
+  still.src = spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])
 }
 
 const dialogBody = document.getElementById('dialog-body')!
 dialogBody.addEventListener('click', (e) => {
-  const look = (e.target as Element).closest<HTMLElement>('[data-preview]')
-  if (look && chosen) return preview(look.dataset.preview as OfferKind)
-  const button = (e.target as Element).closest<HTMLElement>('[data-offer]')
-  if (!button || !chosen) return
-  const choose = chosen
-  chosen = null
-  closeDialog(() => choose(button.dataset.offer as OfferKind))
-})
-
-// With a mouse, pointing at a card plays it too.
-dialogBody.addEventListener('pointerover', (e) => {
-  if (e.pointerType !== 'mouse' || !chosen) return
-  const row = (e.target as Element).closest<HTMLElement>('[data-row]')
-  if (row && !row.classList.contains('previewed')) preview(row.dataset.row as OfferKind)
+  const card = (e.target as Element).closest<HTMLElement>('[data-offer]')
+  if (card && chosen) select(card.dataset.offer as OfferKind)
 })