fuzzyworld

explodorama / commit

Version box in the menu, charge toast, almanac and clip fixes (0.2.0 work)

b89399da0a40ba21367c0e96989f397792d5bc35 · Pavel Safronov · 2026-11-01 09:31:18 -0800 · parents 8edb5cb927ba

Claude Code usage: this commit 64.1K out / 12.6M cached · cost unknown (no per-model trailer) · session 8 tally 398 in / 132.7K out / 22.3M cached · usage by commit and session

Version box in the menu, charge toast, almanac and clip fixes (0.2.0 work)

Menu opens with an Official / Early adopter box and a switch button; both
addresses come from package.json (homepage, nextHomepage). README gets the
next link. A charge picked up again shows a toast (count and total), with
samples on the almanac Tests tab (Interface pieces). Almanac tabs wrap in a
grid, Back keeps the scroll, clips show open gem/charge choices after the
turn comes to rest.

FW-Tokens-In: 398
FW-Tokens-Out: 132723
FW-Tokens-Cached: 22279179
FW-Session: 59d4e4eb-e946-4c26-9ff4-bea067039a2a
FW-Rtk-Saved: 2796445
FW-Rtk-Cmds: 8071

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md            |  12 +++--
 decisions.md         |  38 ++++++++++++++
 package.json         |   1 +
 src/changelog.ts     |  14 +++++
 src/channel.ts       |  11 ++--
 src/main.ts          |  24 +++++++--
 src/style.css        | 141 +++++++++++++++++++++++++++++++++++++++++++++++----
 src/ui/almanac.ts    |  33 +++++++++++-
 src/ui/clip.ts       |  59 ++++++++++++++++++++-
 src/ui/toast.ts      |  40 +++++++++++++++
 src/ui/whatsnew.ts   |  32 +++++++++---
 test/channel.test.ts |   6 ++-
 test/toast.test.ts   |  14 +++++
 vite.config.ts       |   6 +++
 14 files changed, 399 insertions(+), 32 deletions(-)

diff --git a/README.md b/README.md
index f0e770a..29e3547 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
 # EXPLODORAMA!
 
-**[▶ Play now!](https://apps.fuzzyworld.net/explodorama/)** — in the browser, on a phone or a computer.
+**[▶ Play now!](https://apps.fuzzyworld.net/explodorama/)** — in the browser, on a phone or a computer.\
+**[⏩ Try the next version](https://apps.fuzzyworld.net/explodorama-next/)** — what is coming, built from `main`; may have rough edges.
 
 A turn-based, mobile-first, rogue-like take on the 1985 NES bomb-laying classic. Every bomb
 counts down with *your* moves, so nothing happens until you act — and you can see exactly
@@ -24,10 +25,13 @@ tagging moves them into a new release there and bumps `version` in package.json.
 **Two copies:** the released game (`/explodorama/`, built from the latest git tag) and the **next**
 copy (`/explodorama-next/`, built from `main`: tab title "EXPLODORAMA! (next)"). Deploy the
 released one with `npm run deploy` (the latest git tag) and the next one with
-`npm run deploy:next` (needs fuzzyworld-tools with `--as`). A released copy only has the
-**Try it early** link and teaser once it is built from a tag that includes them (0.2.0 or later). They share an
+`npm run deploy:next` (needs fuzzyworld-tools with `--as`). The menu opens with a box saying which
+version this is (**Official** or **Early adopter**) and a button to switch to the other, with both
+addresses (`homepage` and `nextHomepage` in package.json, through vite.config.ts into
+[src/channel.ts](src/channel.ts)). A released copy only has the button, **Try it early** link and
+teaser once it is built from a tag that includes them (0.2.0 or later). They share an
 origin, so every localStorage key is per copy (`storageKey` in [src/channel.ts](src/channel.ts));
-only `explodorama.earlyRuns` is shared. The released menu offers **Try it early** and a
+only `explodorama.earlyRuns` is shared. The released copy also has a
 **Coming next** list; runs finished on the next copy are marked **Early adopter** on its board.
 Developer mode is separate and can be switched on in either.
 
diff --git a/decisions.md b/decisions.md
index e575bc5..393cd2c 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2349,3 +2349,41 @@ with `?dev`. In the demo the gem and charge choices open as real dialogs (new ge
 an almanac clip they are answered unseen. `test/test-scenes.test.ts` asserts each scene's outcome,
 so the same cases are checked without looking. To add a case: a name in `TestKind` and a `TEST_SCENES` entry; the
 demo table and icons follow from the list. Not in the 0.2.0 change list (a developer aid).
+
+### 2026-11-01 — Which version is this: a box in the menu
+
+User request: make it obvious in the menu which version is playing and how to switch. The menu now
+opens with a box: **Official version** / **Early adopter version**, a button to the other and both
+addresses. Addresses come from package.json (`homepage`, new `nextHomepage`) through vite.config.ts
+(`VITE_HOME_URL`, `VITE_NEXT_URL`) into `src/channel.ts`, one source for the app and the README. The
+old "Try it early" / "Back to the released version" links in the menu's bottom line are gone (the
+Changes dialog's teaser keeps its link). The released copy shows the button only once next.json
+answered. Not shown in `npm run dev` (channel `local`). Kenney's credit stays out of the menu
+(since 0.2.0 work, 2026-10-31); CC0 needs none.
+
+### 2026-11-01 — A toast for a charge picked up again
+
+User request: a charge picked up for the second time in a run is simply taken (no dialog, as the
+player knows the idea), but the icons are small, so say what it was. New `ui/toast.ts`: a note with
+the charge's picture and name above the action bar, fading after ~2 s, one at a time. Shown for any
+charge pickup that opened no dialog (not the first of its kind, not a sell-or-swap); not in the
+demo. The toast is the general mechanism for other "you got X" notes later.
+
+Follow-up, same day: the toast says how many were picked up and the total held ("Picked up 3 × Nuke",
+"5 charges in all"; the second line only when more is held than was picked up). It is a popover, so
+it also shows over dialogs. **Rule: each new kind of UI piece like this gets a sample on the
+almanac's Tests tab** ("Interface pieces", tap to show; `TOAST_SAMPLES` in `ui/almanac.ts`), so
+Developer mode can show it without reproducing the situation.
+
+### 2026-11-01 — Clips show the choices; almanac tabs wrap
+
+User report (screenshot): five almanac tabs ran over each other (the Tests tab made five); the tabs are
+now a grid of as many equal columns as fit, none under 104 px. And a clip of "slide over two gems"
+only showed the slide, not the two dialogs. A clip now draws the open choice over itself
+(`showChoice` in `ui/clip.ts`): the gem's cards with the scripted pick lit and "another gem is
+waiting", or a charge's keep/take; held 2 s, then the pick plays. (The real dialog isn't opened in a
+clip: it would cover the clip.) Only test scenes have such choices.
+
+Timing, same day: the choice comes after the turn that led to it has come to rest (a ~1.1 s pause in a
+clip, 1.2 s in the `?dev` demo), shows ~2 s, goes, and the second follows after a pause (the clip's
+own gap after the first goes, then 0.2 s), rather than at once over the slide.
diff --git a/package.json b/package.json
index ba84df3..f1234a5 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
     "url": "https://code.fuzzyworld.net/explodorama.git"
   },
   "homepage": "https://apps.fuzzyworld.net/explodorama/",
+  "nextHomepage": "https://apps.fuzzyworld.net/explodorama-next/",
   "license": "MIT",
   "dependencies": {
     "littlejsengine": "1.28.0"
diff --git a/src/changelog.ts b/src/changelog.ts
index 18fbd4c..fc5ffff 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,20 @@ export const RELEASES: Release[] = [
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
 export const UNRELEASED: Change[] = [
+  {
+    kind: 'added',
+    icon: 'star',
+    title: 'Which version is this?',
+    text: ['The menu now opens with a box saying whether you are playing the official or the early adopter version, with a button to switch to the other.'],
+    date: '2026-11-01',
+  },
+  {
+    kind: 'added',
+    icon: 'bomb',
+    title: 'A note when you pick up a charge',
+    text: ['The first charge of each kind in a run still explains itself. After that, picking one up shows a small note with its picture, name, how many you got and how many you hold, since the icons in the top bar are small.'],
+    date: '2026-11-01',
+  },
   {
     kind: 'changed',
     icon: 'gem_green',
diff --git a/src/channel.ts b/src/channel.ts
index d89050c..4bc53e5 100644
--- a/src/channel.ts
+++ b/src/channel.ts
@@ -23,9 +23,14 @@ export function storageKey(name: string): string {
  */
 export const EARLY_RUNS_KEY = 'explodorama.earlyRuns'
 
-/** Where the other copy lives, relative to the site. */
-export const STABLE_PATH = '/explodorama/'
-export const NEXT_PATH = '/explodorama-next/'
+/**
+ * Where each copy lives: the full addresses come from package.json (`homepage`, `nextHomepage`,
+ * through vite.config.ts), the menu shows them; the paths are for the links and for next.json.
+ */
+export const HOME_URL: string = import.meta.env.VITE_HOME_URL || 'https://apps.fuzzyworld.net/explodorama/'
+export const NEXT_URL: string = import.meta.env.VITE_NEXT_URL || 'https://apps.fuzzyworld.net/explodorama-next/'
+export const STABLE_PATH = new URL(HOME_URL).pathname
+export const NEXT_PATH = new URL(NEXT_URL).pathname
 
 /** Runs finished on the next copy, from either copy. */
 export function earlyRuns(): number {
diff --git a/src/main.ts b/src/main.ts
index 252478b..ae15066 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -34,12 +34,14 @@ import { LAST_LEVEL } from './sim/levelgen'
 import { step } from './sim/turn'
 import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
 import { openAlmanac } from './ui/almanac'
+import { chargeToast, showToast } from './ui/toast'
+import { CHARGE_AMOUNT } from './sim/charges'
 import { VERSION } from './changelog'
-import { channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew'
+import { channelBox, channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew'
 import { CHANNEL, storageKey, TITLE } from './channel'
 import { createControls } from './ui/controls'
 import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
-import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
+import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openChargeOffer } from './ui/chargeOffer'
 import { openTapChoice } from './ui/tapChoice'
 import { bindTimeline } from './ui/timeline'
@@ -248,6 +250,15 @@ function act(action: Action, auto = false): void {
     // The first charge of each kind this run says what it does, so a new button is never a
     // mystery; unless a gem was taken on the same turn (a run over both), whose choice comes first.
     const charge = firstCharge(e)
+    // Any later pickup of a charge just goes into the bag, so a toast says what it was (a swap asks instead).
+    if (!charge && e.type === 'pickedUp' && e.drop.kind !== 'coins' && !result.events.some((x) => x.type === 'chargeOffered')) {
+      const kind = e.drop.kind
+      const n = e.drop.left ?? CHARGE_AMOUNT[kind]
+      const total = result.state.charge?.kind === kind ? result.state.charge.left : n
+      const { title, text } = chargeToast(CHARGE_TEXT[kind].name, n, total)
+      // Not over a choice that is open (a gem's, a charge's): its dialog says enough.
+      window.setTimeout(() => state.offer || state.chargeOffer || showToast(spriteUrl(CHARGE_ICONS[kind]), title, text), PICKUP_DIALOG_MS)
+    }
     if (charge && !result.events.some((x) => x.type === 'chargeOffered')) window.setTimeout(() => state.offer || state.chargeOffer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS)
   }
   selected = null
@@ -501,6 +512,7 @@ function showMenu(): void {
     body:
       `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
       (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
+      channelBox() +
       feedbackChoices() +
       // The version is the release (semver, tagged by hand); the rules version, in brackets, changes
       // exactly when old saves stop resuming. The version opens the list of changes.
@@ -517,6 +529,9 @@ function showMenu(): void {
     ],
     className: 'menu',
   })
+  document.querySelector<HTMLElement>('#dialog-body .channel-switch')?.addEventListener('click', (e) => {
+    location.href = (e.currentTarget as HTMLElement).dataset.go!
+  })
   document.getElementById('changes-link')!.addEventListener('click', (e) => {
     e.preventDefault()
     closeDialog(() => openChanges())
@@ -1205,8 +1220,11 @@ function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] {
   }
   if (g.kind === 'pick') {
     // A test scene's answer to the dialog that opens (a gem's choice, or a charge's): shown, then taken.
+    // After a pause, so the turn before (a slide) is seen to come to rest; between two it is the
+    // settle after the one closing.
     return [
-      { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: INFO_SECONDS },
+      { run: () => {}, then: 1.2 },
+      { run: () => (state.offer ? openOffer(state, state.offer, () => {}) : openChargeOffer(state, () => {})), then: 2.4 },
       { ...play, run: () => (closeDialog(), play.run()) },
     ]
   }
diff --git a/src/style.css b/src/style.css
index 20d0146..94022e8 100644
--- a/src/style.css
+++ b/src/style.css
@@ -188,6 +188,59 @@ img {
   text-transform: uppercase;
 }
 
+#toast {
+  position: fixed;
+  /* A popover (top layer, over dialogs): undo its centred default. */
+  inset: auto;
+  margin: 0;
+  overflow: visible;
+  left: 50%;
+  bottom: calc(env(safe-area-inset-bottom) + 96px);
+  z-index: 3;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 16px 8px 10px;
+  border: 2px solid var(--ink);
+  border-radius: 16px;
+  background: #ece3cc;
+  color: var(--ink);
+  pointer-events: none;
+  opacity: 0;
+  transform: translate(-50%, 8px);
+  transition: opacity 0.25s, transform 0.25s;
+}
+
+#toast:popover-open {
+  display: flex;
+}
+
+#toast.show {
+  opacity: 1;
+  transform: translate(-50%, 0);
+}
+
+#toast img {
+  width: 44px;
+  height: 44px;
+  image-rendering: auto;
+}
+
+#toast strong,
+#toast span {
+  display: block;
+}
+
+#toast span {
+  font-size: 12px;
+  color: var(--muted);
+}
+
+@media (prefers-reduced-motion: reduce) {
+  #toast {
+    transition: none;
+  }
+}
+
 #actions {
   position: fixed;
   bottom: 0;
@@ -624,8 +677,9 @@ img {
   top: 0;
   /* Over the entries scrolling under it (user report, 2026-10-25: their text showed through). */
   z-index: 2;
-  display: flex;
-  justify-content: center;
+  /* As many equal tabs per row as fit, none narrower than its label (user report, 2026-11-01: five tabs overran). */
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
   gap: 6px;
   padding-bottom: 8px;
   background: #ece3cc;
@@ -633,13 +687,6 @@ img {
 
 /* Equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both
    ends, cut off). Four since the Board tab: two rows of two on a phone. */
-@media (max-width: 440px) {
-  .almanac-tabs {
-    display: grid;
-    grid-template-columns: 1fr 1fr;
-  }
-}
-
 .almanac-tabs button {
   flex: 1 1 0;
   min-width: 0;
@@ -896,6 +943,39 @@ button.almanac-entry.has-clip:active {
   font-weight: 700;
 }
 
+#dialog-body .channel-box {
+  margin: 0 0 10px;
+  padding: 8px 10px;
+  border: 2px solid var(--ink);
+  border-radius: 12px;
+  text-align: center;
+}
+
+#dialog-body .channel-box p {
+  margin: 0 0 6px;
+}
+
+#dialog-body .channel-box p:last-child {
+  margin-bottom: 0;
+}
+
+.channel-sub {
+  display: block;
+  font-size: 12px;
+  color: var(--muted);
+}
+
+.channel-switch {
+  margin: 2px 0 6px;
+  padding: 6px 12px;
+  border: 2px solid var(--accent-dark);
+  border-radius: 14px;
+  background: var(--accent);
+  color: #fff;
+  font: 11px 'Kenney Future', ui-rounded, system-ui, sans-serif;
+  cursor: pointer;
+}
+
 #dialog-body .mode {
   font-size: 13px;
   text-align: left;
@@ -1486,6 +1566,49 @@ canvas.clip[hidden] {
 }
 
 /* The demo's ripple and ring, inside the clip's frame instead of on the page. */
+.clip-frame .clip-choice {
+  position: absolute;
+  left: 6%;
+  right: 6%;
+  bottom: 6%;
+  padding: 6px 8px;
+  border: 2px solid var(--ink);
+  border-radius: 12px;
+  background: #ece3cc;
+  color: var(--ink);
+  font-size: 11px;
+  text-align: center;
+  pointer-events: none;
+}
+
+.clip-frame .clip-choice div {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 4px;
+  margin-top: 4px;
+}
+
+.clip-frame .clip-choice span {
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  padding: 2px 6px;
+  border: 2px solid transparent;
+  border-radius: 10px;
+}
+
+.clip-frame .clip-choice span.picked {
+  border-color: var(--accent-dark);
+  background: var(--accent);
+  color: #fff;
+}
+
+.clip-frame .clip-choice img {
+  width: 18px;
+  height: 18px;
+}
+
 .clip-frame .demo-ripple,
 .clip-frame .demo-ring {
   position: absolute;
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index 34a3c1b..0a0e33f 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -6,6 +6,7 @@ import type { SceneKind } from '../sim/demo'
 import { openDialog } from './dialog'
 import { startClip } from './clip'
 import { TEST_KINDS, TEST_SCENES } from '../sim/testScenes'
+import { chargeToast, showToast } from './toast'
 import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
@@ -31,6 +32,12 @@ const POWER_UPS: PowerUpKind[] = [
 
 const ENEMIES = ENEMY_ORDER
 
+/** The Tests tab's samples of the toast: tapping one shows it, as in play (add one for each new kind of toast). */
+const TOAST_SAMPLES = [
+  { kind: 'nuke' as const, name: 'Toast: a charge picked up again', about: 'Two Nuke charges held, three picked up: says what was picked up and the total.', n: 3, total: 5 },
+  { kind: 'shield' as const, name: 'Toast: a single charge', about: 'One Shield picked up, none held: just its name.', n: 1, total: 1 },
+]
+
 /** The first level an enemy type can appear on. */
 function firstLevel(kind: EnemyKind): number {
   let level = 1
@@ -70,7 +77,9 @@ function body(tab: AlmanacTab): string {
   } else if (tab === 'tests') {
     list =
       '<p class="almanac-intro">Developer mode: fixed situations that check edge cases. Each clip replays the same turns; the gem and charge choices play unseen here (they open in the demo with ?dev). What to expect is under each.</p>' +
-      TEST_KINDS.map((k) => entry(TEST_SCENES[k].icon, TEST_SCENES[k].name, [TEST_SCENES[k].caption, ...TEST_SCENES[k].expect.map((e) => `• ${e}`)], k)).join('')
+      TEST_KINDS.map((k) => entry(TEST_SCENES[k].icon, TEST_SCENES[k].name, [TEST_SCENES[k].caption, ...TEST_SCENES[k].expect.map((e) => `• ${e}`)], k)).join('') +
+      '<h3 class="release-head">Interface pieces <small>tap to show</small></h3>' +
+      TOAST_SAMPLES.map((t, i) => `<button type="button" class="almanac-entry" data-toast="${i}"><img src="${spriteUrl(CHARGE_ICONS[t.kind])}" alt="" draggable="false"><div><strong>${t.name}</strong><p>${t.about}</p></div></button>`).join('')
   } else {
     list =
       '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' +
@@ -91,13 +100,26 @@ export function openAlmanac(tab: AlmanacTab = shownTab, dev: boolean = shownDev)
 
 /** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */
 function openEntry(button: HTMLElement): void {
+  // Back returns to where the list was scrolled to (user request, 2026-11-01).
+  const scrolled = dialogBody.scrollTop
   const { clip, sprite, name } = button.dataset
   const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('')
   openDialog({
     icon: spriteUrl(sprite!),
     title: name!,
     body: '<canvas class="clip" aria-hidden="true"></canvas>' + text,
-    buttons: [{ label: 'Back', kind: 'plain', onClick: () => openAlmanac() }],
+    buttons: [
+      {
+        label: 'Back',
+        kind: 'plain',
+        onClick: () => {
+          openAlmanac()
+          dialogBody.scrollTop = scrolled
+          // Once laid out, if the dialog wasn't yet.
+          requestAnimationFrame(() => (dialogBody.scrollTop = scrolled))
+        },
+      },
+    ],
   })
   startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip as SceneKind)
 }
@@ -113,6 +135,13 @@ dialogBody.addEventListener('click', (e) => {
     dialogBody.scrollTop = 0
     return
   }
+  const toast = target.closest<HTMLElement>('[data-toast]')
+  if (toast) {
+    const t = TOAST_SAMPLES[Number(toast.dataset.toast)]!
+    const { title, text } = chargeToast(CHARGE_TEXT[t.kind].name, t.n, t.total)
+    showToast(spriteUrl(CHARGE_ICONS[t.kind]), title, text)
+    return
+  }
   const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]')
   if (entryButton) openEntry(entryButton)
 })
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
index a1d17df..0fa763b 100644
--- a/src/ui/clip.ts
+++ b/src/ui/clip.ts
@@ -6,6 +6,9 @@ import type { Gesture, Scene, SceneKind } from '../sim/demo'
 import { step } from '../sim/turn'
 import type { Action, ActionId, GameState, Vec } from '../sim/types'
 import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg'
+import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import { isCharge } from '../sim/types'
+import { CHARGE_TEXT, POWER_UP_TEXT } from './info'
 import { makeScene } from './scenes'
 import { timelineAfter, type Timeline } from './timeline'
 
@@ -57,6 +60,10 @@ const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([
 const GLIDE_SECONDS = 0.35
 const TAP_SECONDS = 0.18
 const HOLD_SECONDS_PRESS = 0.45
+/** A turn that leaves a choice open (a gem's offer, a charge's sell-or-swap) is held this long, to read it. */
+const CHOICE_SECONDS = 2
+/** The pause before a choice shows (the turn that led to it comes to rest first), and between two choices. */
+const CHOICE_GAP_SECONDS = 1.1
 /** After a turn, before the finger moves on. */
 const SETTLE_SECONDS = 0.45
 
@@ -106,6 +113,7 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene,
 
 /** Stops the clip in `canvas`, if one plays there. */
 export function stopClip(canvas: HTMLCanvasElement): void {
+  canvas.parentElement?.querySelector('.clip-choice')?.remove()
   const bar = canvas.parentElement?.nextElementSibling
   if (bar?.classList.contains('timeline')) bar.remove()
   clips = clips.filter((o) => o.canvas !== canvas)
@@ -129,6 +137,7 @@ function tick(c: Clip): void {
   pruneAnimation(c.anim, time)
   const done = !c.scene || (c.next >= c.scene.actions.length && !c.beats.length && time >= c.at)
   if (done && c.upcoming) {
+    c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
     c.scene = c.upcoming
     c.upcoming = null
     c.state = c.scene.start
@@ -164,10 +173,18 @@ function tick(c: Clip): void {
   if (c.finger) c.beats = gestureBeats(c, c.scene.gestures[i]!, action, last)
   else {
     play(c, action)
-    c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS)
+    // A choice the turn left open (test scenes): after a pause, so the turn is seen to come to
+    // rest, it shows for long enough to read, then goes (the next turn answers it).
+    if (c.state.offer || c.state.chargeOffer) {
+      c.beats = [{ run: () => showChoice(c), then: CHOICE_SECONDS }, { run: () => hideChoice(c), then: CHOICE_GAP_SECONDS }]
+      // The pause after a choice went was already waited, so an answer's own follow-up choice shows after a short one.
+      c.at = time + (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS)
+    } else c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS)
   }
   // The bar fills over the turn: as long as its beats, or until the next turn.
-  c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, c.finger ? c.beats.reduce((sum, b) => sum + b.then, 0) : last ? HOLD_SECONDS : STEP_SECONDS)
+  const beatsSpan = c.beats.reduce((sum, b) => sum + b.then, 0)
+  const span = c.finger ? beatsSpan : c.beats.length ? (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + beatsSpan : last ? HOLD_SECONDS : STEP_SECONDS
+  c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, span)
 }
 
 /**
@@ -183,7 +200,45 @@ function still(c: Clip): void {
   c.finger?.classList.add('away')
 }
 
+const isAnswer = (a: Action): boolean => a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge'
+
+function hideChoice(c: Clip): void {
+  c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
+}
+
+/**
+ * The dialog a turn leaves open, drawn over the clip (the real one isn't shown in a clip): a gem's
+ * choice with its cards, the scripted pick lit, or a charge's sell-or-swap. True if one is open.
+ */
+function showChoice(c: Clip): boolean {
+  const s = c.state!
+  const frame = c.canvas.parentElement!
+  hideChoice(c)
+  // The turn answering it is the next one still to play (`next` has moved on past the turn that opened it).
+  const next = c.scene!.actions[c.next]
+  let html = ''
+  if (s.offer) {
+    const pick = next?.id === 'choose' ? next.kind : null
+    const cards = s.offer.map((k) => {
+      const icon = isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k]
+      const name = isCharge(k) ? CHARGE_TEXT[k].name : POWER_UP_TEXT[k].name
+      return `<span class="${k === pick ? 'picked' : ''}"><img src="${spriteUrl(icon)}" alt="" draggable="false">${name}</span>`
+    })
+    html = `<strong>Choose a power-up${s.waitingGems.length ? ' (another gem is waiting)' : ''}</strong><div>${cards.join('')}</div>`
+  } else if (s.chargeOffer) {
+    const held = s.charge ? CHARGE_TEXT[s.charge.kind].name : 'the one held'
+    html = `<strong>A ${CHARGE_TEXT[s.chargeOffer.kind].name} charge, and you carry ${held}</strong><div><span class="${next?.id === 'sellCharge' ? 'picked' : ''}">Keep (sell the new one)</span><span class="${next?.id === 'swapCharge' ? 'picked' : ''}">Take (sell the old one)</span></div>`
+  }
+  if (!html) return false
+  const box = document.createElement('div')
+  box.className = 'clip-choice'
+  box.innerHTML = html
+  frame.append(box)
+  return true
+}
+
 function play(c: Clip, action: Action): void {
+  c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
   const result = step(c.state!, action)
   animateTurn(c.anim, c.state!, result, action, time)
   c.state = result.state
diff --git a/src/ui/toast.ts b/src/ui/toast.ts
new file mode 100644
index 0000000..93b2d0a
--- /dev/null
+++ b/src/ui/toast.ts
@@ -0,0 +1,40 @@
+/**
+ * A toast (user request, 2026-11-01): a short note over the board that fades by itself, for what
+ * the player got when no dialog explains it (a charge picked up for the second time in a run; the
+ * icons in the top bar are small). One at a time; a new one replaces the one showing.
+ */
+const SHOW_MS = 2200
+
+let el: HTMLElement | null = null
+let timer = 0
+
+/**
+ * What a charge pickup says: `n` picked up, and the total held when that is more (2 held and 3
+ * picked up: "Picked up 3 × Nuke", "5 charges in all"; user request, 2026-11-01).
+ */
+export function chargeToast(name: string, n: number, total: number): { title: string; text: string } {
+  return { title: n > 1 ? `Picked up ${n} × ${name}` : `Picked up ${name}`, text: total > n ? `${total} charges in all` : '' }
+}
+
+export function showToast(icon: string, title: string, text: string): void {
+  if (!el) {
+    el = document.createElement('div')
+    el.id = 'toast'
+    el.setAttribute('role', 'status')
+    // In the top layer, so it also shows over a dialog (the almanac's samples).
+    el.popover = 'manual'
+    document.body.append(el)
+  }
+  el.innerHTML = `<img src="${icon}" alt="" draggable="false"><div><strong>${title}</strong>${text ? `<span>${text}</span>` : ''}</div>`
+  el.classList.remove('show')
+  if (!el.matches(':popover-open')) el.showPopover?.()
+  // Restarts the fade when one replaces another.
+  void el.offsetWidth
+  el.classList.add('show')
+  window.clearTimeout(timer)
+  timer = window.setTimeout(() => {
+    el?.classList.remove('show')
+    // Hidden once the fade is done.
+    window.setTimeout(() => !el?.classList.contains('show') && el?.hidePopover?.(), 300)
+  }, SHOW_MS)
+}
diff --git a/src/ui/whatsnew.ts b/src/ui/whatsnew.ts
index cb5acbc..da8b731 100644
--- a/src/ui/whatsnew.ts
+++ b/src/ui/whatsnew.ts
@@ -1,4 +1,4 @@
-import { CHANNEL, earlyRuns, NEXT_PATH, STABLE_PATH, storageKey } from '../channel'
+import { CHANNEL, earlyRuns, HOME_URL, NEXT_PATH, NEXT_URL, storageKey } from '../channel'
 import { newsFor, RELEASES, UNRELEASED, VERSION, type Change, type Release } from '../changelog'
 import { spriteUrl } from '../render/sprites'
 import type { SceneKind } from '../sim/demo'
@@ -59,17 +59,33 @@ export function loadNextInfo(): void {
     .catch(() => {})
 }
 
-/** The menu's line about the other copy of the game, and how many runs the player has finished on the next one. */
-export function channelLine(): string {
-  if (CHANNEL === 'next') return `<a href="${STABLE_PATH}">Back to the released version</a>`
-  if (CHANNEL !== 'stable') return ''
-  const early = earlyRuns()
+/**
+ * The menu's box at the top (user request, 2026-11-01): which version this is, and a button to the
+ * other one, with both addresses. The released copy only offers the early one once it has heard
+ * from it (`nextInfo`). Each copy keeps its own saved run and scores.
+ */
+export function channelBox(): string {
+  if (CHANNEL === 'local') return ''
+  const next = CHANNEL === 'next'
+  const there = next ? HOME_URL : NEXT_URL
+  const offer = next || nextInfo
   return (
-    (nextInfo ? `<a href="${NEXT_PATH}">Try it early</a>` : '') +
-    (early ? `${nextInfo ? ' · ' : ''}Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : '')
+    `<div class="channel-box"><p><strong>${next ? 'Early adopter version' : 'Official version'}</strong>` +
+    `<span class="channel-sub">${next ? 'what is coming next; it may have rough edges' : 'the released game'}</span></p>` +
+    (offer
+      ? `<button type="button" class="channel-switch" data-go="${there}">Switch to the ${next ? 'official' : 'early adopter'} version</button>` +
+        `<p class="credit">${there.replace(/^https?:\/\//, '')} · each version keeps its own saved run and scores</p>`
+      : '') +
+    `</div>`
   )
 }
 
+/** The menu's small line about the early adopter runs finished on the next version, from either copy. */
+export function channelLine(): string {
+  const early = earlyRuns()
+  return early ? `Early adopter: ${early} ${early === 1 ? 'run' : 'runs'} finished on the next version` : ''
+}
+
 /** What is finished and not yet released: the next copy's own list, or from the released copy the next one's teaser. */
 function unreleased(): string {
   const list = CHANNEL === 'stable' ? nextInfo?.unreleased ?? [] : UNRELEASED
diff --git a/test/channel.test.ts b/test/channel.test.ts
index 141031e..bb6a49e 100644
--- a/test/channel.test.ts
+++ b/test/channel.test.ts
@@ -1,9 +1,13 @@
 import { describe, expect, it } from 'vitest'
-import { CHANNEL, storageKey } from '../src/channel'
+import { CHANNEL, NEXT_PATH, STABLE_PATH, storageKey } from '../src/channel'
 
 describe('channels (user request, 2026-10-31)', () => {
   it('tests run as the local copy, with the released copy’s key names', () => {
     expect(CHANNEL).toBe('local')
     expect(storageKey('run')).toBe('explodorama.run')
   })
+  it('the paths are worked out from the addresses in package.json', () => {
+    expect(STABLE_PATH).toBe('/explodorama/')
+    expect(NEXT_PATH).toBe('/explodorama-next/')
+  })
 })
diff --git a/test/toast.test.ts b/test/toast.test.ts
new file mode 100644
index 0000000..32921ff
--- /dev/null
+++ b/test/toast.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { chargeToast } from '../src/ui/toast'
+
+describe('the charge toast (user request, 2026-11-01)', () => {
+  it('says how many were picked up and the total held', () => {
+    expect(chargeToast('Nuke', 3, 5)).toEqual({ title: 'Picked up 3 × Nuke', text: '5 charges in all' })
+  })
+  it('a single pickup with none held is just the name', () => {
+    expect(chargeToast('Shield', 1, 1)).toEqual({ title: 'Picked up Shield', text: '' })
+  })
+  it('one more on top of those held still gives the total', () => {
+    expect(chargeToast('Shield', 1, 2)).toEqual({ title: 'Picked up Shield', text: '2 charges in all' })
+  })
+})
diff --git a/vite.config.ts b/vite.config.ts
index 4c4e18f..812e5e8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,7 @@
 import { defineConfig } from 'vite'
 import { qrcode } from 'vite-plugin-qrcode'
 import { execSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
 import fuzzyworld from 'fuzzyworld-tools/vite'
 import { UNRELEASED, VERSION } from './src/changelog.ts'
 
@@ -29,6 +30,11 @@ process.env.VITE_COMMIT_DIRTY = git('status --porcelain') ? '1' : ''
 // one, from `main`, for anyone who wants to try what isn't released. Anything else is the released copy.
 const next = (process.env.FW_APP ?? '').endsWith('-next')
 process.env.VITE_CHANNEL = next ? 'next' : 'stable'
+// Where each copy lives, from package.json (`homepage`, `nextHomepage`): the menu shows and links them (src/channel.ts).
+const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { homepage: string; nextHomepage: string }
+// (Assigning undefined would make the text "undefined".)
+if (pkg.homepage) process.env.VITE_HOME_URL = pkg.homepage
+if (pkg.nextHomepage) process.env.VITE_NEXT_URL = pkg.nextHomepage
 
 export default defineConfig({
   base,