fuzzyworld

explodorama / commit

Controller/keyboard/touch fixes Q76-Q90: 1-based turns, inspection mode, Cancel button, slower autopilot, lost-choice safety net (0.2.0 work)

720cc9d32092eafb140ec3a293d6b30aa7700cdb · Pavel Safronov · 2026-11-02 21:18:44 -0800 · parents 92cc4ee3e420

Claude Code usage: this commit 180.2K out / 33.2M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 12 tally 628 in / 180.2K out / 33.2M cached · usage by commit and session

Controller/keyboard/touch fixes Q76-Q90: 1-based turns, inspection mode, Cancel button, slower autopilot, lost-choice safety net (0.2.0 work)

- Turns are shown from 1 (turnNumber); menu link/glyph fixes; cursor stays where
  the player puts it, recovers from a tile that caught fire; Cmd/Shift no longer
  switch input mode.
- "What is that?": reaches pillars, walls and the top bar and yourself, stays on
  after an explanation, single press for Cross/Circle (also in dialogs under it),
  standard R1/L1 scheme, hints moved to the top with symbols.
- Touch: round Cancel button for a picked action or auto-move destination.
- Autopilot step 0.25 s -> 0.4 s.
- reopenPendingChoice: a gem/charge choice with no dialog showing reopens after
  1.5 s (bug report seed 395025326 level 5: player could not move).

FW-Tokens-In: 628
FW-Tokens-Out: 180153
FW-Tokens-Cached: 33172403
FW-Session: dbd8a80a-5404-47e1-b86b-02fc1127d249
FW-Rtk-Saved: 4048010
FW-Rtk-Cmds: 12882

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 CHANGELOG.md                   |  13 ++-
 README.md                      |  16 +--
 assets/kenney-icons/cancel.png | Bin 0 -> 1551 bytes
 assets/kenney-icons/return.png | Bin 0 -> 1819 bytes
 assets/kenney-icons/zoom.png   | Bin 0 -> 1812 bytes
 decisions.md                   |  26 ++++-
 docs/art-provenance.md         |   3 +
 src/changelog.ts               |  29 ++++-
 src/main.ts                    | 245 ++++++++++++++++++++++++++++++++---------
 src/sim/state.ts               |   9 ++
 src/style.css                  |  64 ++++++++++-
 src/ui/controls.ts             |  22 +++-
 src/ui/gamepad.ts              |   5 +
 src/ui/info.ts                 |  14 ++-
 src/ui/keyCursor.ts            |   5 +
 src/ui/padDialog.ts            |   2 +-
 src/ui/padTarget.ts            |  24 +++-
 src/ui/padTiles.ts             |  24 ++--
 test/gamepad.test.ts           |  22 ++++
 test/keyCursor.test.ts         |  12 +-
 test/padTarget.test.ts         |  38 ++++++-
 test/padTiles.test.ts          |  17 ++-
 test/turnNumber.test.ts        |  12 ++
 23 files changed, 511 insertions(+), 91 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1fcd73..8874d51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,17 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **A missing choice dialog comes back** (Q90, user bug report, 2026-11-02, seed 395025326 level 5): replaying the report's run ends with a gem's choice AND a charge's sell-or-swap pending (one slide crossed an Acid charge, coins and a gem) while the player "couldn't move": the rules refuse every action until the choice is made, but nothing showed it. `reopenPendingChoice` in `main.ts` (every frame) reopens the dialog if a choice has waited 1.5 s with no dialog open (the menu or an info dialog opened over it also loses it until closed). Why the dialog was missing is not known (it is `required`, so it cannot be closed).
+- **Autopilot slower; a Cancel button** (Q89, user report from a bug report, 2026-11-02): the autopilot's step is 0.4 s (2.5 moves a second, was 0.25 s). A round **Cancel** button (cross, `kenney-icons/cancel.png`, in `ui/controls.ts`) shows over the action bar's top right while an action is picked or auto-move has a destination (not with "What is that?" on, which has its own ? button; hidden for the pad, whose L1 cancels, and in the demos); it does what Esc does (`cancel()` in `main.ts`). Touch had no way to put these away.
+- **"What is that?": one press, not a hold** (Q88, user request, 2026-11-02; amends Q85): Cross explains and Circle leaves at the first press, since nothing in the mode can hurt (`instant` in `ui/gamepad.ts`, answered by `main.ts`; the label says "Press"). The explanation dialogs it opens take a single press too (`inspectDialog`), and so does any dialog opened from them in turn; the menu and other dialogs keep the hold. Holding there is to become an option (`INSPECT_HOLD`, Q71).
+- **"What is that?" stays on after an explanation** (Q87, user request, 2026-11-02): with the pad or the keyboard's aim, closing an info dialog returns to the mode with the aim where it was, so the next thing can be checked at once (`aimExplain` in `main.ts` no longer ends the mode); leaving is a held Circle, Esc, or "Walk there". A tap or click on a tile still ends it.
+- **"What is that?" can be aimed at yourself** (Q86, user report, 2026-11-02): your own tile now has an explanation ("You": lives left, how a turn goes; `tileInfo`), so L1 can put the aim on you and the d-pad can reach you; the aim still starts on the nearest other thing. Tapping yourself in the mode explains you too (no "Walk there" on your own tile).
+- **"What is that?" with the pad: hold Cross to view, hold Circle to leave** (Q85, user request, 2026-11-02): the same hold-to-confirm scheme as the rest of the pad. R1 turns it on (and does nothing once on), the d-pad moves the aim, **holding** the confirm button explains, **holding** the cancel button leaves; L1 only brings the aim back to the player's side, and no longer leaves or explains. The label shows both buttons with their rings (`main.ts` `leaveInspect`, `padBack`). Supersedes the L1/R1 wording of Q84 for inspection.
+- **The L1/R1 hints move to the top of the board, in full words, with symbols** (Q84, user request, 2026-11-02): under the top bar instead of above the bottom label; "Move cursor back to you" / "Cancel what you picked" / "Leave inspection mode" for L1 (an undo arrow), "Activate inspection mode" / "Explain what is aimed at" for R1 (a magnifying glass); both icons are Game Icons (`assets/kenney-icons/zoom.png`, `return.png`, listed in `docs/art-provenance.md`). The keyboard's legend says "Activate inspection mode" too.
+- **"What is that?" with the pad says to hold** (Q83, user report, 2026-11-02): the four action buttons are greyed while it is on (it explains, it does not act), and the label shows the confirm button's picture with "Hold to find out what this is"; the hold ring goes round that picture (`main.ts`: `barInspect`, the label, `hold`). The keyboard's label is unchanged.
+- **"What is that?" with the pad reaches pillars, walls and the top bar** (Q82, user report, 2026-11-02): the d-pad aim included only tiles with something special, so a pillar or wall could not be asked about. It now includes them (`explainableTiles(s, true)`), still starting on the nearest interesting tile (`aimStart`). Up from the top row goes on to the status icons of the top bar, Left and Right move along them, Down comes back; the confirm button explains the item (`main.ts` `aimStep`, `aimExplain`, `syncHudAim`).
+- **Three pad cursor fixes** (Q79-Q81, user report, 2026-11-02): (Q79) Wait pressed with the cursor on the player leaves it on the player, even while an auto-move walk is still set (`cursorPinned` in `main.ts`); (Q80) the cursor no longer starts on a walk's destination that a flame made unsafe (`cursorStart` takes the candidate tiles), and a d-pad press from a tile with no action moves to the nearest one that has, so it cannot be stuck; (Q81) a modifier key alone, or any Cmd combination (a screenshot's Cmd+Shift+4), no longer hands the board back from the pad (`ignoredForMode` in `ui/keyCursor.ts`).
+- **Turns count from 1 on screen; the menu's links all reachable by pad** (Q76-Q78, user report, 2026-11-02): the top bar, its info box and the menu say the turn you are on (`turnNumber` in `sim/state.ts`: `turn + 1` while playing, the count itself once the level is over), so a level starts at Turn 1; the sim, par and scores still count turns taken, and the bug report says "N turns taken". In the controller's dialog selector, any link (`a[href]`) can be selected, not only `#` ones: the menu's commit and Source code links, and the Try it early link. In the menu, the controller picture on the small Almanac / Scores / Demo / Bug buttons hangs on their corner instead of pushing the word aside (CSS only; not yet looked at on a real pad).
 - **Move works on a tile that is also a bomb target** (Q75b, user bug report, 2026-11-02): with an RPG, an empty tile two away in a line was bright blue and Move was greyed there, so you could fire at it but not walk to it. `cursorActions` (`ui/padTiles.ts`) now offers Move as the walk ("Walk here · N steps") on any tile auto-move can reach, whatever else targets it; Bomb stays beside it. Tests: `test/q75.test.ts`.
 - **The cursor reaches every faded tile while a walk is set** (Q75, user bug report, 2026-11-02): with an auto-move destination set, only the destination was a faded tile and a cursor candidate, so a coin elsewhere (an enemy's drop beside a bomb) looked unreachable. `currentFar` (`main.ts`) now lists all faded tiles always; Move on another sets a new destination. Rules were fine; test: `test/q75.test.ts`.
 - **Moving the mouse no longer hands the board back** (user request, 2026-11-02): with the pad's or the keyboard's cursor showing, only a click, a tap or a key switches to the mouse; a plain mouse move (e.g. to take a screenshot) does not.
@@ -36,7 +47,7 @@ saved before the change is dropped and the next visit starts a new run.
 - **Hold to confirm on the pad** (Q59, user request, 2026-11-02): a face button now acts only when held until a ring has filled (`HOLD_SECONDS` = 0.5 in `ui/padHold.ts`, pure, tested), and then (amended the same day, see "Hold to confirm acts when the ring fills" above: the moment the ring fills, not on release); releasing early, a swapped pad or a window that loses focus cancels. The ring (`.hold-ring` in `style.css`, a CSS conic gradient, no art; `ui/holdRing.ts` for the page, `PadOverlay.hold` for the demo's controller) fills clockwise from 12 o'clock round the button's picture on the action bar, in dialogs and on the banner. `PadTargets` gains `hold(face, progress)` and `holdEnd()`; `press` for a face button is delivered when the ring fills (`ui/gamepad.ts`; first built as on release); the other buttons (d-pad, shoulders, View, R3, Start) stay instant, and touch, mouse and keyboard are unchanged. Demo: the confirm face button (Cross / A at the bottom; a Nintendo pad's A on the right), held, starts play; other face buttons no longer end it, and the hint reads "Hold {button} to play" whenever the demo is shown for a controller (a detected one, or one picked with the demo's input button). The waking press still only shows the cursor.
 - **Controller demos and clips** (Q58, 2026-11-02): while a pad is in use, the demo shows a virtual controller (`ui/padOverlay.ts`; first CSS shapes with Kenney Input Prompts pictures, since Q63 a line drawing with the pictures on it: d-pad, face buttons, shoulders, right stick, Start) lighting the pressed buttons and a dashed cursor on the board instead of the finger, and the dialogs' clips do the same for the finger kinds (`FINGER_KINDS` in `ui/clip.ts`; a clip switches finger or controller at its next loop, and reduced motion hides the controller like the finger). The pad has its own scene list (`PAD_DEMO_ORDER`, built by `padDemoOrder` in `ui/padDemo.ts`): the cursor, Move, Bomb, Wait, L1, R1, the stick, Start, then the touch demo's gameplay scenes without the touch-only ones (long-press bomb, auto-move, the top bar, "What is that?") and the charge swap (`PAD_LATER` in `sim/demo.ts`). `ui/padDemo.ts` (pure, tested) turns a turn's action into pad presses (`padPlan`, `cursorPath`, `pressPart`, `padHops`). Scenes carry an optional `padCaption` with `{dpad}`, `{move}` ... pictures (`padCaptionHtml`), redrawn if the kind of pad becomes known mid-scene. The two lists keep separate saved places and switch only at the next scene. Almanac Tests tab: a "Demo controller" sample. Ending the demo during the R1 scene no longer leaves "What is that?" on. Art provenance: 36 more Input Prompts files (the controller outline was first noted as hand-made; the Q63 line drawing replaced it).
 - **Autopilot replaces the hoover, sim part** (Q73, user request, 2026-11-02; supersedes Q56 below): the turnless `hoover` action is gone (`canHoover` / `hoover` removed from `sim/turn.ts`, `test/hoover.test.ts` deleted). New pure `sim/autopilot.ts`: `autopilotTargets` (every drop and uncovered gem) and `autopilotPlan` (the next ordinary move toward the cheapest order through them, fewest total turns, exit last; targets on the way count as free; waits for crumbling crates; re-planned every turn with `safeStep`). Tests `test/autopilot.test.ts`; test scenes `testAutopilot` and `testAutopilotCrates`. **SIM_VERSION 42 to 43** (a record holding `hoover` can no longer replay).
-- **Autopilot, UI part** (Q73, user request, 2026-11-02): `autopilotTick` in `main.ts` replaces `autoFinish`. When the last enemy dies it shows the note `#autopilot` ("Autopilot: cleaning up" / "waiting for the crates" / "waiting for the bombs" / "off to the exit"; top centre, not a popover, `role="status"`, with the Esc cap or the L1 picture and a Stop button), waits `AUTOPILOT_START_SECONDS` (1.0), then plays `autopilotPlan(state, predictDanger(state))` every `AUTOPILOT_STEP_SECONDS` (0.25) as `auto` turns, pausing while a dialog or offer is open. Stops: Esc, L1, the Stop button, turning the setting off, or a stall (6 waits in a row, not counting waits while a bomb or flame is on the board, or no safe turn: toast "Autopilot stopped / Your turn"). `boardLocked()` makes the board ignore taps, keys, the action bar (dimmed by `body.autopilot`) and the pad's cursor and buttons while it walks; looking around, dialogs, the menu and the fullscreen button still work; `act()` has a backstop. `cursorWanted` (`ui/padTiles.ts`) takes an `autopilot` flag (tested). The setting is "Autopilot" (input `autopilot`), still stored under the old `autoFinish` key.
+- **Autopilot, UI part** (Q73, user request, 2026-11-02): `autopilotTick` in `main.ts` replaces `autoFinish`. When the last enemy dies it shows the note `#autopilot` ("Autopilot: cleaning up" / "waiting for the crates" / "waiting for the bombs" / "off to the exit"; top centre, not a popover, `role="status"`, with the Esc cap or the L1 picture and a Stop button), waits `AUTOPILOT_START_SECONDS` (1.0), then plays `autopilotPlan(state, predictDanger(state))` every `AUTOPILOT_STEP_SECONDS` (0.4, was 0.25 until Q89) as `auto` turns, pausing while a dialog or offer is open. Stops: Esc, L1, the Stop button, turning the setting off, or a stall (6 waits in a row, not counting waits while a bomb or flame is on the board, or no safe turn: toast "Autopilot stopped / Your turn"). `boardLocked()` makes the board ignore taps, keys, the action bar (dimmed by `body.autopilot`) and the pad's cursor and buttons while it walks; looking around, dialogs, the menu and the fullscreen button still work; `act()` has a backstop. `cursorWanted` (`ui/padTiles.ts`) takes an `autopilot` flag (tested). The setting is "Autopilot" (input `autopilot`), still stored under the old `autoFinish` key.
 - ~~**The level finishes itself** (Q56, user request, 2026-11-01): once the last enemy is gone, the new turnless action `hoover` (`canHoover` / `hoover` in `sim/turn.ts`) gathers the coins, charges and uncovered gems, then `autoFinish` in `main.ts` walks to the exit with auto-move, waiting for crumbling crates. Setting "Finish the level for me" (on by default). Rules version unchanged (a new action; old records replay as before).~~ Superseded by Q73 above.
 - ~~**Fullscreen from the first tap, click or key** (Q57, user request, 2026-11-01): `startFullscreen` in `main.ts`; setting "Start in fullscreen" (on by default).~~ Superseded by Q70 above.
 - **Controller support, stage 1** (Q29, 2026-11-01): the pure core, not yet wired in: `ui/padMap.ts` (gamepad snapshot to intents, with repeat, stick hysteresis, deadzone, controller family) and `ui/spatial.ts` (directional focus), with tests.
diff --git a/README.md b/README.md
index 815b5ea..466954d 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ where it lands), a long press for a bomb (a ring closes in, the bomb's blast is
 bomb goes down when the finger lifts), a button and then a tile (Bomb then a tile; Move then two
 tiles away for a Run), Wait, and Go. The player wears a green **"You"** tag over their head throughout, since it isn't otherwise obvious which of the
 moving figures is you, and the finger appears beside the player at the start of each scene, so
-your eye starts where the action is. The top bar shows throughout, as in play (each scene starts at turn 0 with no coins). It starts
+your eye starts where the action is. The top bar shows throughout, as in play (each scene starts at turn 1 with no coins). It starts
 with scenes about the controls alone: stepping, a bomb by long press, a bomb with the Bomb button,
 waiting, auto-move (tap a far tile, then Go), **a tour of the top bar** (the finger points at
 each item, ringed, while the caption says what it is: lives, level, enemies left, coins, turn
@@ -179,7 +179,7 @@ move goes (the first in red when it isn't safe), and the **Move** button becomes
 Go — or each tap on the destination itself — takes **one step** along the way (one turn, as always — the world still
 moves between steps). The way is worked out again every turn. Tapping another far tile sets a
 new way. Auto-move ends when you arrive, when you **move somewhere off the blue way** (you
-changed your mind), when the destination can no longer be reached, when you tap a wall or crate, or on **Esc**. The way **never crosses
+changed your mind), when the destination can no longer be reached, when you tap a wall or crate, or on **Esc** (touch: the round **Cancel** button above the action bar's top right, shown while a destination or a picked action is set). The way **never crosses
 the open exit** (that would end the level on the way); it only steps onto the exit when the
 exit is where you are going. If the only way somewhere is over the open exit, auto-move can't go there.
 It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, into
@@ -191,7 +191,7 @@ steps a second (still one turn per step).
 
 **Autopilot** (Settings in the menu, "Autopilot", on by default): the moment the last enemy is
 gone, a note at the top says **Autopilot: cleaning up**, and after a one-second beat the character
-walks round by itself, four moves a second, to every coin, charge and gem still lying on the floor,
+walks round by itself, two and a half moves a second, to every coin, charge and gem still lying on the floor,
 then onto the exit. Every move is an ordinary turn (a Run if you own it), so **gathering costs
 turns** and can lose the par bonus. The route is the one that takes the fewest turns in all (the
 exit last; a coin on the way is picked up free). A gem, or a charge that needs a choice (one is
@@ -391,7 +391,7 @@ open, so pick them up soon. Coins are spent in **the shop** (below).
 - **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's
   toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it
   lingers a turn), but a new explosion kills them as usual.
-- **Clearing a level pays 2 coins.** **Par:** the top bar shows `turns taken / par`. Finish within par for **5 more**.
+- **Clearing a level pays 2 coins.** **Par:** the top bar shows the turn you are on (from 1) `/ par`. Finish within par for **5 more**.
   Par is 10 turns, plus 0.3 a tile of the board (floor and crates), plus 8 per enemy: 41 on level 1.
 
 ### Power-ups
@@ -610,10 +610,10 @@ time; another one pressed meanwhile is ignored. The d-pad, LB, RB, View, R3 and
 When it fires, it **does that action on the cursor's tile**. On the
 action bar, the buttons that cannot be used on that tile are greyed (pressing one just shakes it),
 and one that would cost a life gets a red outline and a warning mark; it is still done if you press
-it. **LB** puts "What is that?" away, then anything left picked, and brings the cursor back to your
-own tile. A small legend above the line shows the L1/LB and R1/RB pictures and what they do ("Back to you",
-"Cancel" or "Stop asking", dimmed when L1 has nothing to undo; "What is that?"). **RB or View** is "What is that?": it aims at what can be explained, the d-pad jumps
-between those tiles, and Cross (or RB again) explains the tile; LB leaves it. **Start** opens the
+it. **LB** puts anything left picked away and brings the cursor back to your
+own tile (in "What is that?" it puts the aim on you). A small legend at the top of the board shows the L1/LB and R1/RB pictures, a symbol (an undo arrow, a magnifying glass) and what they do ("Move cursor back to you" or
+"Cancel what you picked", dimmed when L1 has nothing to undo; "Activate inspection mode"). **RB or View** is "What is that?": it aims at what can be explained (pillars and walls too, and the top bar's icons by going up past the top row), the d-pad jumps
+between those, and **Cross** explains it (a single press, since nothing here can hurt; you stay in the mode, to ask about the next thing); **Circle** leaves it (the action bar is greyed meanwhile, and the label says so). **Start** opens the
 menu (and closes it), **R3** brings the view back to you, and the **right stick** looks around the
 board. The left stick does nothing for now. A key, a click or a tap hands the board back to the keyboard
 and mouse (just moving the mouse does not); the next pad press brings the cursor back on your tile (a face button pressed then only
diff --git a/assets/kenney-icons/cancel.png b/assets/kenney-icons/cancel.png
new file mode 100644
index 0000000..d06cf00
Binary files /dev/null and b/assets/kenney-icons/cancel.png differ
diff --git a/assets/kenney-icons/return.png b/assets/kenney-icons/return.png
new file mode 100644
index 0000000..d80ad28
Binary files /dev/null and b/assets/kenney-icons/return.png differ
diff --git a/assets/kenney-icons/zoom.png b/assets/kenney-icons/zoom.png
new file mode 100644
index 0000000..9d67cc0
Binary files /dev/null and b/assets/kenney-icons/zoom.png differ
diff --git a/decisions.md b/decisions.md
index 9cfc5c6..e1b5c4c 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2865,7 +2865,7 @@ UI part (same day, decided with the user): (7) the note is `#autopilot`, top cen
 bar, a pill like the toast but not a popover (dialogs cover it), text `role="status"`: "Autopilot:
 cleaning up", "Autopilot: waiting for the crates", "Autopilot: off to the exit". It shows at once
 on the last kill, even if a dialog from that turn is open; the walk starts after a 1.0 s beat
-(and after the answer). (8) Speed: 4 moves a second (`AUTOPILOT_STEP_SECONDS` 0.25; auto-move
+(and after the answer). (8) Speed: 2.5 moves a second (`AUTOPILOT_STEP_SECONDS` 0.4, was 0.25 = 4 a second until Q89, when a bug report found it too fast; auto-move
 stays at 0.5), no new animation; a Run only when owned. (9) Stop: Esc, pad L1 (its picture is on
 the note) and a Stop button on the note for touch and mouse; stopping ends it for the level; so
 does turning the setting off. A stall (6 waits in a row outside the crates phase, or no safe turn)
@@ -2928,3 +2928,27 @@ which made screenshots difficult while testing with a controller. Decided: only
 Q65 keyboard-cursor rule 'a mouse move of 3 px or more hands the cursor back, like the pad'. The mouse's
 hover route preview only shows while no pad or keyboard cursor is active, so it does not appear while
 the mouse merely moves over a pad-driven board; a click on the board is the way back.
+
+### 2026-11-02 — Q79-Q82: cursor and aim fixes (Pavel's pad tests)
+
+- **Q79**: Wait pressed with the cursor on the player starts the next turn's cursor on the player, even
+  with an auto-move walk set (`cursorPinned`); other actions still restart on the walk's destination or the player.
+- **Q80**: `cursorStart` only picks the walk's destination if it is still a candidate tile (a flame can make
+  it unsafe); a d-pad press from a tile with no action jumps to the nearest that has one (never stuck).
+- **Q81**: modifier keys alone and anything with Cmd held (`ignoredForMode`) never hand the board back
+  from the pad (screenshots). A click still does. Amends the 'any key hands the board back' rule.
+- **Q82**: the "What is that?" aim now includes plain pillars and the outer wall (`explainableTiles(s, true)`;
+  the demo keeps the old list), but starts on the nearest tile that is not a plain wall (`aimStart`). Up from the
+  top row of tiles goes on to the top-bar items (Left/Right along them, Down back to the board; `Aim.hud`,
+  outlined with `.pad-aimed`); the confirm button explains the item (`hudInfo`). Supersedes 'not plain pillars or the outer wall' above.
+- **Q83**: while "What is that?" is on (pad), the action bar's four buttons are greyed and the label reads "Hold to find out what this is" beside the confirm button's picture, which carries the hold ring.
+- **Q82 detail**: Up from the top row lands on the top-bar item nearest the aimed tile's column on screen.
+- **Q84**: the L1/R1 legend (`#pad-hint`) sits at the top of the board under the top bar; L1 shows an undo arrow, R1 a magnifying glass (Game Icons), with full wording (`IDLE_L1_TEXT`).
+- **Q85**: inspection with the pad follows the hold scheme: R1 on, d-pad moves, hold Cross (confirm) views, hold Circle (cancel) leaves; L1 only brings the aim back to the player's side. R1 while on does nothing (dimmed in the legend). The keyboard's Esc still leaves it. Supersedes 'RB again explains, LB leaves it' (2026-11-01).
+- **Q86**: "What is that?" can rest on the player's own tile (`tileInfo` says "You"); L1 in the mode puts the aim there. The demo's list (`explainableTiles(s)`) and the aim's start still leave the player out.
+- **Q87**: after the aim explains something (pad, keyboard), "What is that?" and the aim stay on; leaving is a held Circle, Esc or the dialog's "Walk there". Touch/mouse taps still end it (open question for Pavel).
+- **Q88**: in "What is that?" the pad's Cross (explain) and Circle (leave) act on a single press, no ring (`PadTargets.instant`); amends Q85's hold. `INSPECT_HOLD = false` is the switch for the later option (with Q71's expert options).
+- **Q88 detail**: the info dialogs opened by the aim (`inspectDialog`) also take a single press; every other dialog keeps hold-to-confirm (the menu has New, which must not fire on a tap).
+- **Q88 detail 2**: the single press covers everything under an inspection action: dialogs opened from the info dialogs inherit `inspectDialog` until no dialog is left open.
+- **Q89**: autopilot step 0.25 s -> 0.4 s (bug report: too fast). Touch got a Cancel button (a picked action or auto-move destination had no way out but Esc/L1): round, over the bar's top right, absolute so the bar's height (the board's inset) is unchanged; hidden for the pad, the demos and while inspecting. The "demo became really tall" screenshot (touch, scene 5/57, the board drawn as one thin column, sprites squeezed, no bar) could not be reproduced yet (open question to Pavel).
+- **Q90**: bug report (seed 395025326, level 5): 'Couldn't move'. Replay: `offer` (5 gems) and `chargeOffer` (Acid) both pending after a slide; `isAvailable` refuses all actions while either is set, and no dialog was showing. Cause of the missing dialog unknown (offer dialog is `required`); suspects: a dialog opened over it (menu / bug report) replacing it, or a timing race between the pickup timers. Fix is a safety net: `reopenPendingChoice` reopens after 1.5 s without a dialog.
diff --git a/docs/art-provenance.md b/docs/art-provenance.md
index 3e2cb16..ea73e5c 100644
--- a/docs/art-provenance.md
+++ b/docs/art-provenance.md
@@ -122,6 +122,9 @@ Badge = green disc with dark rim, upper right, Kenney symbol on it. Base sprites
 | kenney-icons/question.png | game-icons | PNG/Black/2x/question.png |
 | kenney-icons/larger.png | game-icons | PNG/Black/2x/larger.png |
 | kenney-icons/smaller.png | game-icons | PNG/Black/2x/smaller.png |
+| kenney-icons/zoom.png | game-icons | PNG/Black/2x/zoom.png |
+| kenney-icons/return.png | game-icons | PNG/Black/2x/return.png |
+| kenney-icons/cancel.png | game-icons | PNG/Black/2x/cross.png |
 | kenney-ui-rpg/panel_beigeLight.png | ui-pack-rpg-expansion | PNG/panel_beigeLight.png |
 | kenney-crosshair/crosshair041.png | crosshair-pack | PNG/Black Retina/crosshair041.png |
 | kenney-cursor/cursor_none.png | cursor-pack | PNG/Basic/Default/cursor_none.png |
diff --git a/src/changelog.ts b/src/changelog.ts
index aa556cc..c0b5129 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,33 @@ 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: 'changed',
+    icon: 'star',
+    title: 'Turns count from 1, and "What is that?" on a controller',
+    text: [
+      'The turn counter now starts at 1. With a controller or the keyboard, "What is that?" reaches pillars, walls and the top bar and can be aimed at yourself, stays on after each explanation, and takes a single press. Pressing Cmd, Shift or another modifier key no longer hands the board from the controller to the keyboard.',
+    ],
+    date: '2026-11-02',
+  },
+  {
+    kind: 'changed',
+    icon: 'star',
+    title: 'A lost choice comes back',
+    text: [
+      'If a gem’s or charge’s choice was waiting but its window was gone (so nothing would move), the window now opens again by itself after a moment.',
+    ],
+    date: '2026-11-02',
+  },
+  {
+    kind: 'changed',
+    icon: 'star',
+    title: 'Slower autopilot, and a Cancel button',
+    text: [
+      'The autopilot now walks two and a half steps a second instead of four. And while you have an action picked, or a walk set with a tap on a far tile, a round Cancel button appears just above the action buttons to put it away.',
+    ],
+    date: '2026-11-02',
+  },
   {
     kind: 'changed',
     icon: 'star',
@@ -100,7 +127,7 @@ export const UNRELEASED: Change[] = [
     icon: 'coin',
     title: 'Autopilot',
     text: [
-      'When the last enemy is gone, the character walks round on its own, four moves a second, to every coin, charge and gem still lying about, then to the exit, along a safe way and the route with the fewest turns. A note at the top says what it is doing. Gems and charges still ask what you want, and the walk waits for your answer. Every move is a real turn, so gathering costs turns and can lose the par bonus.',
+      'When the last enemy is gone, the character walks round on its own, two and a half moves a second, to every coin, charge and gem still lying about, then to the exit, along a safe way and the route with the fewest turns. A note at the top says what it is doing. Gems and charges still ask what you want, and the walk waits for your answer. Every move is a real turn, so gathering costs turns and can lose the par bonus.',
       'Stop it with Esc, L1 on a controller, or the Stop button on the note. The board takes no input while it walks. If you would rather do it all yourself, turn off "Autopilot" under Settings in the menu.',
     ],
     date: '2026-11-02',
diff --git a/src/main.ts b/src/main.ts
index ad33942..66fa2ab 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -32,7 +32,7 @@ import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from '.
 import { autopilotPlan } from './sim/autopilot'
 import { isDeadly, predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
-import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state'
+import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS, turnNumber } from './sim/state'
 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'
@@ -43,22 +43,22 @@ import { VERSION } from './changelog'
 import { channelBox, channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew'
 import { CHANNEL, storageKey, TITLE } from './channel'
 import { createControls } from './ui/controls'
-import type { Step } from './ui/spatial'
+import { nextFocus, type Step } from './ui/spatial'
 import { createPadInput } from './ui/gamepad'
 import { glyphFor, padCaptionHtml, shoulderGlyph } from './ui/padGlyph'
-import { confirmFace, type PadFamily } from './ui/padMap'
+import { cancelFace, confirmFace, type FaceIndex, type PadFamily } from './ui/padMap'
 import { PAD_STILL_CAPTIONS, padDemoOrder, padHops, padPlan, pressPart, type PadPress } from './ui/padDemo'
 import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS } from './ui/padOverlay'
 import { KEY_STILL_CAPTIONS, KEY_STILLS, keyCaptionHtml, keyDemoOrder, keyPlan, pressParts, type KeyPress, type KeyStill } from './ui/keyDemo'
 import { createKeyOverlay } from './ui/keyOverlay'
 import { createHoldRing } from './ui/holdRing'
 import { padDialog } from './ui/padDialog'
-import { explainableTiles, firstTarget, jumpTarget } from './ui/padTarget'
+import { aimStart, explainableTiles, jumpTarget } from './ui/padTarget'
 import { routePreview, type RouteView } from './ui/routePreview'
-import { actionableTiles, cursorDeadly, cursorLines, barButtonFor, cursorWanted, cursorActions, farTileAt, farTiles, ghostAction, cursorStart, keepCursor, l1Hint, moveCursor, tileActions, type BarButton, type FarTile, type TileAction } from './ui/padTiles'
-import { defaultButton, escStep, KEY_CAPS, keyButton, keyDirection, keyRole, modeAfterKey, panStep, targetTakesKey, type InputMode } from './ui/keyCursor'
+import { actionableTiles, cursorDeadly, cursorLines, barButtonFor, cursorWanted, cursorActions, farTileAt, farTiles, ghostAction, cursorStart, keepCursor, restingCursor, IDLE_L1_TEXT, l1Hint, moveCursor, tileActions, type BarButton, type FarTile, type TileAction } from './ui/padTiles'
+import { defaultButton, escStep, KEY_CAPS, keyButton, keyDirection, ignoredForMode, keyRole, modeAfterKey, panStep, targetTakesKey, type InputMode } from './ui/keyCursor'
 import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
-import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
+import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type HudItem, type Info } from './ui/info'
 import { openChargeOffer } from './ui/chargeOffer'
 import { openTapChoice } from './ui/tapChoice'
 import { bindTimeline } from './ui/timeline'
@@ -82,6 +82,8 @@ import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
 import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg'
 import mouseIcon from '../assets/kenney-cursor/cursor_none.png'
 import questionIcon from '../assets/kenney-icons/question.png'
+import returnIcon from '../assets/kenney-icons/return.png'
+import zoomIcon from '../assets/kenney-icons/zoom.png'
 
 // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random
 // seed — chosen here, never inside the sim.
@@ -108,6 +110,8 @@ interface Aim {
   tiles: Vec[]
   target: Vec | null
   label: string
+  /** On a top-bar item instead of a tile (Up from the top row; Q82): what pressing again explains. */
+  hud?: HudItem
 }
 let aim: Aim | null = null
 /** What animates the board between turns (render/animate.ts). */
@@ -135,8 +139,8 @@ const LONG_PRESS_MS = 450
  * Half a second, twice as fast as at first (user request, 2026-10-23: "at least 50% faster").
  */
 const AUTO_STEP_SECONDS = 0.5
-/** The autopilot (Q73) moves four times a second, and waits this long after the last enemy before its first move (the note shows meanwhile). */
-const AUTOPILOT_STEP_SECONDS = 0.25
+/** The autopilot (Q73) moves two and a half times a second (Q89: it was four, a bug report found it too fast), and waits this long after the last enemy before its first move (the note shows meanwhile). */
+const AUTOPILOT_STEP_SECONDS = 0.4
 const AUTOPILOT_START_SECONDS = 1.0
 /** Waits in a row (not for crates, nor with a bomb or flame on the board) after which the autopilot gives the turn back. */
 const AUTOPILOT_STALL_WAITS = 6
@@ -385,6 +389,27 @@ function showChargeOffer(): void {
   if (state.chargeOffer && !state.offer) openChargeOffer(state, (choice) => act({ id: choice === 'fire' ? 'fireCharge' : choice === 'swap' ? 'swapCharge' : 'sellCharge' }))
 }
 
+/** Since when a gem's or charge's choice has been waiting with no dialog showing (0: not waiting); see `reopenPendingChoice`. */
+let choiceMissingSince = 0
+
+/**
+ * A choice the rules wait for (a gem's, a charge's) must always have its dialog: with none, every
+ * action is refused and the player can't move (Q90, bug report: a slide took a charge and a gem
+ * at once, then nothing worked). Called every frame; after a short grace (the pickup's own
+ * timers open the dialog) it opens whichever is missing.
+ */
+function reopenPendingChoice(): void {
+  if (demo || !(state.offer || state.chargeOffer) || dialogOpen()) {
+    choiceMissingSince = 0
+    return
+  }
+  choiceMissingSince ||= time
+  if (time - choiceMissingSince < 1.5) return
+  choiceMissingSince = 0
+  showOffer()
+  showChargeOffer()
+}
+
 function afterChange(): void {
   if (aim && (state.status !== 'playing' || !inspecting)) aim = null
   // "What is that?" was what woke the keyboard's aim (not the cursor): when it ends, the board is the plain keys' again.
@@ -498,8 +523,13 @@ function setDestination(p: Vec): void {
 function showTileInfo(p: Vec): void {
   const info = tileInfo(state, p)
   if (!info) return
-  const reachable = cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p)
-  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [])
+  const reachable = !eq(p, state.player) && cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p)
+  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => {
+    // Walking ends the asking (the mode stays on after an explanation, Q87).
+    inspecting = false
+    aim = null
+    setDestination(p)
+  } }] : [])
 }
 
 /** An info dialog; with a clip, that kind of scene looping above the words (ui/clip.ts). */
@@ -532,7 +562,7 @@ function bugDetails(withRecord: boolean): string {
   return [
     `Version ${VERSION} (rules ${SIM_VERSION})${commit}`,
     `Device: ${device()}; ${navigator.userAgent}`,
-    `Seed ${state.seed}, level ${state.level}, turn ${state.turn}, ${state.lives} lives, ${state.status}; modes: ${modes}`,
+    `Seed ${state.seed}, level ${state.level}, ${state.turn} turns taken, ${state.lives} lives, ${state.status}; modes: ${modes}`,
     `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`,
     ...(withRecord ? [`Run record: ${JSON.stringify(record)}`] : []),
   ].join('\n')
@@ -573,7 +603,7 @@ function commitLine(): string {
  * Resume and New side by side, then one row of single words — Almanac, Scores, Demo, Bug.
  */
 function showMenu(): void {
-  const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : ''
+  const where = state.status === 'playing' ? `Level ${state.level}, turn ${turnNumber(state)}. ` : ''
   const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean)
   openDialog({
     title: MENU_TITLE,
@@ -939,6 +969,7 @@ const controls = createControls({
   },
   info: (item) => showInfo(hudInfo(item, state)),
   inspect,
+  cancel,
   menu: showMenu,
 })
 
@@ -1066,7 +1097,7 @@ function keyAction(id: ActionId): void {
 
 /** `?`: turns "What is that?" on, aimed at the nearest tile; pressed again on an aim, explains that tile. */
 function keyInspect(): void {
-  if (inspecting && aim?.target) return tapTile(aim.target)
+  if (inspecting && aimExplain()) return
   inspect()
   if (inspecting) aimInspect()
 }
@@ -1074,7 +1105,10 @@ function keyInspect(): void {
 /** Enter with the keyboard's cursor: explains the aimed tile; confirms the picked action on the cursor's tile; else does the tile's first action. */
 function keyEnter(): void {
   if (boardLocked()) return
-  if (inspecting) return aim?.target ? tapTile(aim.target) : aimInspect()
+  if (inspecting) {
+    if (!aimExplain()) aimInspect()
+    return
+  }
   if (!cursor) return
   if (selected) {
     const tile = cursor.tile
@@ -1228,13 +1262,25 @@ function refreshCursor(): void {
 function ensureCursor(): void {
   const wanted = cursorWanted({ padActive: inputMode !== null, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), autopilot, status: state.status })
   if (!wanted) cursor = null
-  else if (!cursor) setCursor(cursorStart(state, destination))
+  else if (!cursor) {
+    // Waited with the cursor on the player (Q79): it stays there, whatever walk is still set.
+    const stay = cursorPinned
+    cursorPinned = false
+    setCursor(stay ? restingCursor(state) : cursorStart(state, destination, cursorCandidates({ tiles: actionableTiles(state), far: currentFar() })))
+  }
 }
 
+/** Wait was pressed with the cursor on the player's own tile: the cursor starts there again, not on a walk's destination. */
+let cursorPinned = false
+
 /** A d-pad press: the cursor moves to the next actionable tile that way (from the player's tile, if it was resting). */
 function cursorMove(dx: Step, dy: Step): void {
   ensureCursor()
-  if (cursor) setCursor(moveCursor(cursorCandidates(cursor), cursor.tile, dx, dy))
+  if (!cursor) return
+  const tiles = cursorCandidates(cursor)
+  // On a tile that has no action any more (Q80): the press takes it to the nearest one that has, so it is never stuck.
+  if (!tiles.some((t) => eq(t, cursor!.tile))) return setCursor(keepCursor(tiles, cursor.tile) ?? restingCursor(state))
+  setCursor(moveCursor(tiles, cursor.tile, dx, dy))
 }
 
 /**
@@ -1248,7 +1294,10 @@ function padDo(button: BarButton): void {
   const action = c?.actions.find((a) => a.button === button)
   if (!c || !action?.possible) return controls.flashPad(button)
   cursor = null
-  if (button === 'wait') return pick('wait', false)
+  if (button === 'wait') {
+    cursorPinned = eq(c.tile, state.player)
+    return pick('wait', false)
+  }
   // A faded tile: nothing is picked, the tile is tapped, which is auto-move's walk there (or its next step).
   if (action.far) {
     selected = null
@@ -1260,12 +1309,14 @@ function padDo(button: BarButton): void {
 }
 
 /**
- * L1: puts away "What is that?" first; else what is left picked (after a cancelled choice
+ * L1: in "What is that?" puts the aim on you (Q85, Q86); else what is left picked (after a cancelled choice
  * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile.
  */
 function padBack(): void {
-  if (inspecting) inspecting = false
-  else if (selected || destination) {
+  cursorPinned = false
+  // In "What is that?" it only puts the aim on you: leaving is Circle (Q85, Q86).
+  if (inspecting) return aimAt(explainableTiles(state, true), state.player)
+  if (selected || destination) {
     selected = null
     destination = null
   }
@@ -1274,16 +1325,87 @@ function padBack(): void {
   afterChange()
 }
 
-/** An arrow with "What is that?" on: the aim jumps to the next tile that has something to say that way, nothing wraps round. With no aim yet (it was turned on by mouse, or the aim was put away), the first press makes it. */
+/**
+ * The dialog open now is an explanation the pad's "What is that?" asked for, or one opened from it (Q88): its buttons also take a single press.
+ * It lasts while dialogs follow one another (a button that opens the next one: the `close` event comes after that dialog has already opened), and ends when none is left.
+ */
+let inspectDialog = false
+document.getElementById('dialog')!.addEventListener('close', () => {
+  if (!dialogOpen()) inspectDialog = false
+})
+
+/** Whether "What is that?" asks for a held press like the rest of the pad (to be an option, Q71/Q88); off: one press does it. */
+const INSPECT_HOLD = false
+
+/** Circle in "What is that?": the mode ends, the cursor comes back (Q85). */
+function leaveInspect(): void {
+  inspecting = false
+  aim = null
+  cursor = null
+  afterChange()
+}
+
+/**
+ * An arrow with "What is that?" on: the aim jumps to the next tile that has something to say that way, nothing wraps round;
+ * pillars and walls included (Q82). Up from the top row goes on to the top bar's items, Left and Right move along them, Down comes back to the board.
+ * With no aim yet (it was turned on by mouse, or the aim was put away), the first press makes it.
+ */
 function aimStep(dx: Step, dy: Step): void {
-  if (aim?.target) aimAt(aim.tiles, jumpTarget(aim.tiles, aim.target, dx, dy))
-  else aimInspect()
+  if (!aim?.target) return aimInspect()
+  const items = hudAimItems()
+  if (aim.hud) {
+    if (dy > 0) return aimAt(aim.tiles, aim.target)
+    const at = items.findIndex((el) => el.dataset.info === aim!.hud)
+    const rects = items.map((el) => el.getBoundingClientRect()).map((r) => ({ x: r.x, y: r.y, w: r.width, h: r.height }))
+    const to = items[nextFocus(rects, Math.max(0, at), dx, dy)]
+    if (to) aim = { ...aim, hud: to.dataset.info as HudItem }
+    return
+  }
+  const next = jumpTarget(aim.tiles, aim.target, dx, dy)
+  if (dy < 0 && eq(next, aim.target) && items[0]) {
+    // The item nearest the tile's column on screen.
+    const x = tilePoint(aim.target).x
+    const nearest = items.reduce((a, b) => (Math.abs(centreX(b) - x) < Math.abs(centreX(a) - x) ? b : a))
+    aim = { ...aim, hud: nearest.dataset.info as HudItem }
+  }
+  else aimAt(aim.tiles, next)
+}
+
+const centreX = (el: HTMLElement): number => {
+  const r = el.getBoundingClientRect()
+  return r.left + r.width / 2
+}
+
+/** The top bar's items the aim can rest on, left to right. */
+function hudAimItems(): HTMLElement[] {
+  return [...document.querySelectorAll<HTMLElement>('#hud [data-info]')].filter((el) => el.getClientRects().length > 0)
 }
 
-/** "What is that?" is on: aim at the nearest tile with something to explain. */
+/** Explains what the aim is on (a tile, or a top-bar item); false if it is on nothing. The mode stays on (Q87). */
+function aimExplain(): boolean {
+  // The mode and the aim stay (Q87): when the dialog closes the player is still asking, and can go on to the next thing.
+  if (aim?.hud) {
+    showInfo(hudInfo(aim.hud, state))
+    inspectDialog = dialogOpen()
+    return true
+  }
+  if (!aim?.target) return false
+  if (state.status !== 'playing' || boardLocked()) return true
+  showTileInfo(aim.target)
+  inspectDialog = dialogOpen()
+  return true
+}
+
+/** The top-bar item the aim is on gets the selector's outline (`.pad-aimed`), the others lose it. Every frame, since the bar is rebuilt on each change. */
+function syncHudAim(): void {
+  const on = aimShown() ? aim?.hud : undefined
+  for (const el of document.querySelectorAll<HTMLElement>('#hud [data-info]')) el.classList.toggle('pad-aimed', !!on && el.dataset.info === on)
+}
+
+/** "What is that?" is on: aim at the nearest tile with something to explain (a plain pillar or wall only if there is nothing else). */
 function aimInspect(): void {
-  const tiles = explainableTiles(state)
-  const first = firstTarget(tiles, state.player)
+  const tiles = explainableTiles(state, true)
+  const first = aimStart(state, tiles)
   if (first) aimAt(tiles, first)
 }
 
@@ -1295,9 +1417,6 @@ function aimShown(): boolean {
 let lastBarKey = ''
 let lastNote = ''
 let lastHintKey = ''
-/** The label's height plus its gap, for the hint above it; -1 = to be measured. */
-let padLabelHeight = -1
-addEventListener('resize', () => (padLabelHeight = -1))
 /** The demo's virtual controller (ui/padOverlay.ts), under its words; shown when the demo plays for a pad. */
 const demoPad = createPadOverlay('large')
 demoPad.el.hidden = true
@@ -1382,6 +1501,9 @@ const pad = createPadInput({
     cursorMove(dx, dy)
   },
   // A face button held (ui/padHold.ts): the ring fills round its picture; the press follows the moment it is full (the ring stays full until the button is let go or the place changes).
+  // "What is that?" acts on a single press (Q88): nothing in it can hurt. Holding there is to become an option (Q71).
+  // The info dialogs it opens are part of the asking: the same (the menu and the like keep the hold).
+  instant: () => !INSPECT_HOLD && !demo && (dialogOpen() ? inspectDialog : inspecting && state.status === 'playing' && !boardLocked()),
   hold(face, progress) {
     if (demo) {
       // In the demo only the confirm button (Cross / A; a Nintendo pad's right-hand A) does anything: it starts play.
@@ -1438,18 +1560,21 @@ const pad = createPadInput({
     if (button === 'reset') resetPan()
     else if (button === 'cancel') padBack()
     else if (button === 'info') {
-      // RB or View: "What is that?" on, and aimed; pressed again on a target, it explains that tile.
+      // RB or View: "What is that?" on, and aimed.
+      // Q85: it turns the mode on; leaving is Circle, explaining is Cross.
       cursor = null
-      if (aim?.button === 'inspect' && aim.target) tapTile(aim.target)
-      else if (inspecting) aimInspect()
-      else {
+      if (inspecting) {
+        if (!aim) aimInspect()
+      } else {
         inspect()
         if (inspecting) aimInspect()
       }
     } else if (face !== undefined) {
       // Move/Go (Cross, A) explains the aimed tile while "What is that?" is on; the other face buttons do nothing then.
       if (inspecting) {
-        if (button === 'move' && aim?.target) tapTile(aim.target)
+        // Cross (confirm) views, Circle (cancel) leaves (Q85); a press each (Q88).
+        if (button === 'move') aimExplain()
+        else if (button === 'wait') leaveInspect()
       } else {
         // The press that woke the pad (a key or mouse had put the cursor away) only shows the cursor.
         const woke = padWoke
@@ -1500,6 +1625,7 @@ document.getElementById('dialog')!.addEventListener('dialogopen', () => {
 function handBack(next: InputMode): void {
   const was = padActive
   const before = inputMode
+  cursorPinned = false
   setInputMode(next)
   if (next === null) keysFromInspect = false
   if (was && demo) syncDemoHint()
@@ -1515,6 +1641,8 @@ function handBack(next: InputMode): void {
 addEventListener(
   'keydown',
   (e) => {
+    // A modifier on its own, or a Cmd shortcut (a screenshot's Cmd+Shift+4), is not play: it leaves the pad's cursor alone (Q81).
+    if (ignoredForMode(e)) return
     const next = modeAfterKey(inputMode, e, inspecting, dialogOpen() || !!demo)
     keyWoke = next === 'keys' && inputMode !== 'keys'
     // An arrow or `?` that woke the aim while "What is that?" was on, or `?` itself, is not the cursor's: it goes with "What is that?".
@@ -2647,6 +2775,7 @@ await engineInit(
   timed(guarded('update', () => {
     pad.poll(performance.now())
     demoTick()
+    reopenPendingChoice()
     // In the demo the board and the action bar under it fit above the caption panel, with room
     // between them and it.
     const panel = demoPanel.getBoundingClientRect().height
@@ -2676,14 +2805,15 @@ await engineInit(
     const keysUi = inputMode === 'keys'
     const note = labelCursor ? (lines.length ? JSON.stringify([keysUi ? 'keys' : padKind, lines]) : '') : aimShown() ? (keysUi ? `Enter|${aim!.label}` : aim!.label) : ''
     // The action bar's buttons show what the cursor's tile offers: greyed, or marked when it costs a life.
-    const barKey = shownCursor ? `${shownCursor.tile.x},${shownCursor.tile.y}:${shownCursor.actions.map((a) => (a.possible ? (a.deadly ? 'd' : 'o') : 'x')).join('')}` : ''
+    // "What is that?" explains, it does not act: the bar's four buttons are greyed (Q83).
+    const barInspect = !shownCursor && aimShown() && inspecting
+    const barKey = barInspect ? 'inspect' : shownCursor ? `${shownCursor.tile.x},${shownCursor.tile.y}:${shownCursor.actions.map((a) => (a.possible ? (a.deadly ? 'd' : 'o') : 'x')).join('')}` : ''
     if (barKey !== lastBarKey) {
       lastBarKey = barKey
-      controls.setPadCursor(shownCursor ? Object.fromEntries(shownCursor.actions.map((a) => [a.button, a.possible ? (a.deadly ? 'danger' : 'ok') : 'off'])) : null)
+      controls.setPadCursor(barInspect ? { move: 'off', bomb: 'off', charged: 'off', wait: 'off' } : shownCursor ? Object.fromEntries(shownCursor.actions.map((a) => [a.button, a.possible ? (a.deadly ? 'danger' : 'ok') : 'off'])) : null)
     }
     if (lastNote !== note) {
       lastNote = note
-      padLabelHeight = -1
       // The cursor's lines each start with the button's own picture (the keyboard's: its key cap); the inspect aim's is plain text (the keyboard's: after the Enter key cap).
       if (labelCursor) {
         padLabel.replaceChildren(
@@ -2703,6 +2833,19 @@ await engineInit(
         text.textContent = aim!.label
         row.append(keyCap('Enter'), text)
         padLabel.replaceChildren(row)
+      } else if (aim && note) {
+        // The pad's confirm button explains and the cancel button leaves (Q85): each with its picture; a held press only if INSPECT_HOLD (Q88).
+        const verb = INSPECT_HOLD ? 'Hold' : 'Press'
+        const row = (face: FaceIndex, words: string): HTMLElement => {
+          const el = document.createElement('div')
+          el.className = 'pad-line'
+          const text = document.createElement('span')
+          text.textContent = words
+          const pic = buttonPicture(spriteUrl(glyphFor(padKind, face === confirmFace(padKind) ? 'move' : 'wait')))
+          el.append(pic, text)
+          return el
+        }
+        padLabel.replaceChildren(row(confirmFace(padKind), `${verb} to find out what this is`), row(cancelFace(padKind), `${verb} to leave inspection mode`))
       } else padLabel.textContent = note
       padLabel.hidden = !note
     }
@@ -2710,16 +2853,22 @@ await engineInit(
     // The hint for L1 and R1, just above the label (or the bar, with no label): shown with the cursor.
     const l1 = l1Hint({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) })
     // The keyboard's Esc, on the player with nothing to undo, puts the cursor away (padTiles `l1Hint` says "Back to you" for the pad).
-    const escText = keysUi && l1.text === 'Back to you' && l1.dim ? 'Hide cursor' : l1.text
+    const escText = keysUi && inspecting ? 'Leave inspection mode' : keysUi && l1.text === IDLE_L1_TEXT && l1.dim ? 'Hide cursor' : l1.text
     const hintKey = aimShown() ? `${keysUi ? 'keys' : padKind}|${escText}|${l1.dim && !keysUi}|${inspecting}` : ''
     if (hintKey !== lastHintKey) {
       lastHintKey = hintKey
       padHint.hidden = !hintKey
       if (hintKey) {
-        const item = (side: 'l' | 'r', text: string, dim: boolean): HTMLElement => {
+        // The shoulder button's picture, a symbol of what it does (a magnifying glass for inspection, an undo arrow for going back), and the words.
+        const item = (side: 'l' | 'r', symbol: string, text: string, dim: boolean): HTMLElement => {
           const el = document.createElement('span')
           el.className = dim ? 'pad-hint-item dim' : 'pad-hint-item'
-          el.append(buttonPicture(spriteUrl(shoulderGlyph(padKind, side))), text)
+          const sym = document.createElement('img')
+          sym.className = 'pad-hint-symbol'
+          sym.alt = ''
+          sym.draggable = false
+          sym.src = symbol
+          el.append(buttonPicture(spriteUrl(shoulderGlyph(padKind, side))), sym, text)
           return el
         }
         const keyItem = (cap: string, text: string): HTMLElement => {
@@ -2728,15 +2877,12 @@ await engineInit(
           el.append(keyCap(cap), text)
           return el
         }
-        if (keysUi) padHint.replaceChildren(keyItem('Shift + arrows', inspecting ? 'Next tile' : 'Pick a tile'), keyItem('Esc', escText), keyItem('?', inspecting ? 'Explain it' : 'What is that?'))
-        else padHint.replaceChildren(item('l', l1.text, l1.dim), item('r', 'What is that?', false))
+        if (keysUi) padHint.replaceChildren(keyItem('Shift + arrows', inspecting ? 'Next tile' : 'Pick a tile'), keyItem('Esc', escText), keyItem('?', inspecting ? 'Explain it' : 'Activate inspection mode'))
+        else padHint.replaceChildren(item('l', returnIcon, l1.text, l1.dim), item('r', zoomIcon, 'Activate inspection mode', inspecting))
       }
     }
-    if (hintKey) {
-      // The label's height is measured when its text changes or the window does, not every frame.
-      if (padLabelHeight < 0) padLabelHeight = padLabel.hidden ? 0 : padLabel.offsetHeight + 6
-      padHint.style.bottom = `${controls.insets().bottom + 8 + padLabelHeight}px`
-    }
+    // The hint sits at the top of the board, just under the top bar (Q84; the label has the bottom).
+    if (hintKey) padHint.style.top = `${controls.insets().top + 8}px`
     snapCamera = false
     pruneAnimation(anim, time)
     autopilotTick()
@@ -2746,10 +2892,11 @@ await engineInit(
   timed(guarded('draw', () => {
     // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts).
     drawClips()
+    syncHudAim()
     // The controller's cursor: the pad's own, or the pad demo's.
     const shown = aimShown() ? cursor : demo?.pad || demo?.keys ? demo.cursor : null
     drawBoard({
-      state, danger, selected, hover: aimShown() && !cursor && aim?.target ? aim.target : hover, target: aimShown() ? (cursor?.tile ?? aim!.target ?? state.player) : (shown?.tile ?? null), destination, path, armed, ...anim, deadly: deadlyTargets, refused,
+      state, danger, selected, hover: aimShown() && !cursor && aim?.target && !aim.hud ? aim.target : hover, target: aimShown() ? (aim?.hud ? null : cursor?.tile ?? aim!.target ?? state.player) : (shown?.tile ?? null), destination, path, armed, ...anim, deadly: deadlyTargets, refused,
       // The controller's cursor: every actionable tile blue, and the result of the first action there, faded.
       preview: previewNow(),
       lit: shown?.tiles, faded: shown?.far && !selected ? shown.far.map((f) => f.tile) : undefined, deadlyCursor: !!shown && cursorDeadly(shown.actions), ghost: shown ? ghostAction(shown.actions, shown.tile, state.player) : null,
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 95529d4..d994c12 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -121,6 +121,15 @@ export function nearestFloor(s: GameState, p: Vec): Vec {
   return s.player
 }
 
+/**
+ * The turn the player is on, counting from 1 (user request, 2026-11-02: 0-based feels odd outside
+ * programming). `s.turn` is the number of turns TAKEN, so while playing the one being chosen is the next;
+ * once the level is over the last one played is the count itself. Display only: the sim, par and scores keep the count.
+ */
+export function turnNumber(s: GameState): number {
+  return s.status === 'playing' ? s.turn + 1 : s.turn
+}
+
 /** The exit can be used once its block is gone and every enemy is dead. */
 export function exitOpen(s: GameState): boolean {
   return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor'
diff --git a/src/style.css b/src/style.css
index 8a86cfb..f3ecd5d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -407,6 +407,32 @@ body.autopilot #actions {
   display: none;
 }
 
+/* Cancel (Q89): a round button over the bar's top right, while an action is picked or auto-move
+   has a destination. Not for the pad (L1 does it) nor the demo. */
+#actions > button.cancel {
+  position: absolute;
+  right: 16px;
+  top: -56px;
+  flex: none;
+  width: 48px;
+  min-width: 48px;
+  min-height: 48px;
+  margin: 0;
+  padding: 0;
+  border-radius: 50%;
+}
+
+#actions > button.cancel img {
+  width: 28px;
+  height: 28px;
+  pointer-events: none;
+}
+
+body.pad #actions > button.cancel,
+body.demo #actions > button.cancel {
+  display: none;
+}
+
 /* The charge button with no charge: an empty slot, a dashed square where a charge's icon goes. */
 #actions .slot {
   width: 30px;
@@ -2260,6 +2286,21 @@ body.pad #banner .pad-glyph {
   margin-left: 8px;
 }
 
+/* A quarter-row button of the menu (Almanac, Scores, Demo, Bug) has no room for a picture beside its
+   word: it hangs on the corner instead of pushing the word aside (user report, 2026-11-02). */
+#dialog .panel.menu .ui-button.plain {
+  position: relative;
+}
+
+#dialog .panel.menu .ui-button.plain .pad-glyph {
+  position: absolute;
+  top: -10px;
+  right: -8px;
+  margin: 0;
+  width: 20px;
+  height: 20px;
+}
+
 #dialog .tab-glyph {
   width: 16px;
   height: 16px;
@@ -2349,14 +2390,17 @@ body.pad #actions > button[data-pad='danger']::after {
 }
 
 /* The legend for the shoulder buttons (main.ts `padHint`): above the label, same family of style,
-   smaller. L1 is dimmed when there is nothing for it to undo; `bottom` is set from the bar and label. */
+   smaller. L1 is dimmed when there is nothing for it to undo. At the top of the board (Q84); `top`
+   is set from the top bar. */
 #pad-hint {
   position: fixed;
   left: 50%;
-  bottom: 140px;
+  top: 56px;
   z-index: 3;
   display: flex;
-  gap: 8px;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 4px 16px;
   max-width: calc(100vw - 32px);
   transform: translateX(-50%);
   padding: 4px 12px;
@@ -2380,6 +2424,13 @@ body.pad #actions > button[data-pad='danger']::after {
   white-space: nowrap;
 }
 
+/* What the shoulder button does, as a picture (black on the cream box). */
+#pad-hint .pad-hint-symbol {
+  flex: none;
+  width: 20px;
+  height: 20px;
+}
+
 #pad-hint .pad-hint-item.dim {
   opacity: 0.5;
 }
@@ -2409,3 +2460,10 @@ body.pad #actions > button[data-pad='danger']::after {
   font-size: 11px;
   line-height: 18px;
 }
+
+/* The top-bar item "What is that?" is aimed at with the d-pad (main.ts `syncHudAim`; Q82). */
+#hud .pad-aimed {
+  outline: 4px solid #ffc83d;
+  outline-offset: 2px;
+  border-radius: 10px;
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 12f41c8..0083b50 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,5 +1,5 @@
 import { canPick } from '../sim/picks'
-import { CLEAR_BONUS, PAR_BONUS, START_LIVES } from '../sim/state'
+import { CLEAR_BONUS, PAR_BONUS, START_LIVES, turnNumber } from '../sim/state'
 import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info'
@@ -7,6 +7,7 @@ import { glyphFor, type GlyphButton } from './padGlyph'
 import { faceOf, type PadFamily } from './padMap'
 import { openScores } from './scores'
 import questionIcon from '../../assets/kenney-icons/question.png'
+import cancelIcon from '../../assets/kenney-icons/cancel.png'
 
 /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
 export interface Handlers {
@@ -24,6 +25,8 @@ export interface Handlers {
   info(item: HudItem): void
   /** The "What is that?" button: the next tap on the board (or an action button) explains it. */
   inspect(): void
+  /** The Cancel button (shown while an action is picked or auto-move has a destination): puts it away, as Esc does. */
+  cancel(): void
   menu(): void
 }
 
@@ -129,6 +132,18 @@ export function createControls(handlers: Handlers) {
     buttons.set(def, b)
   }
 
+  // Cancel (Q89, user report: touch had no way to put away a picked action or a walk): a small round
+  // button over the bar's top right, only while there is something to cancel.
+  const cancelButton = document.createElement('button')
+  cancelButton.type = 'button'
+  cancelButton.className = 'cancel'
+  cancelButton.hidden = true
+  cancelButton.title = 'Cancel'
+  cancelButton.setAttribute('aria-label', 'Cancel')
+  cancelButton.innerHTML = `<img src="${cancelIcon}" alt="" draggable="false">`
+  cancelButton.addEventListener('click', () => handlers.cancel())
+  bar.append(cancelButton)
+
   let current: GameState | null = null
   let bannerNote = ''
   let currentSelected: ActionId | null = null
@@ -174,6 +189,7 @@ export function createControls(handlers: Handlers) {
      */
     update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean, deadlyWait = false): void {
       inspectButton.setAttribute('aria-pressed', String(inspecting))
+      cancelButton.hidden = !(s.status === 'playing' && !inspecting && (selected !== null || autoMove))
       inspectButton.disabled = s.status !== 'playing'
       current = s
       currentSelected = selected
@@ -224,10 +240,10 @@ export function createControls(handlers: Handlers) {
       show(hud.level, item('level', icon('flag_green_a') + number(s.level, 'Level ')))
       show(hud.enemies, item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')))
       show(hud.coins, item('coins', icon('hud_coin') + number(s.coins, 'Coins ')))
-      // Turns taken, and the par to beat for the bonus.
+      // The turn the player is on (from 1), and the par to beat for the bonus.
       show(
         hud.turn,
-        item('turn', `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`),
+        item('turn', `<small aria-hidden="true">Turn</small>` + number(turnNumber(s), 'Turn ') + `<small class="par">/${s.par}</small>`),
       )
       // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns.
       const passes = PASSES.filter(([p]) => s.powers[p])
diff --git a/src/ui/gamepad.ts b/src/ui/gamepad.ts
index a8a0558..00ef799 100644
--- a/src/ui/gamepad.ts
+++ b/src/ui/gamepad.ts
@@ -30,6 +30,8 @@ export interface PadTargets {
   /** The right stick, while pushed: -1..1, y positive down. Called every poll. */
   look(dx: number, dy: number): void
   lookEnd(): void
+  /** Whether a face button acts the moment it is pressed, with no hold (Q88: "What is that?", where nothing can hurt). Asked at each press. */
+  instant?(face: 0 | 1 | 2 | 3): boolean
   /** A face button is being held: how full its ring is, 0..1, every poll. */
   hold?(face: 0 | 1 | 2 | 3, progress: number): void
   /** The ring goes: the button was let go (a completed hold's press came earlier, when the ring filled), the hold was cancelled, or the buttons now act somewhere else. */
@@ -153,6 +155,9 @@ export function createPadInput(
         if (intent.face === undefined) {
           if (!hold.done) holdEvents(cancelHold(hold), null)
           targets.press(intent.button)
+        } else if (targets.instant?.(intent.face)) {
+          if (!hold.done) holdEvents(cancelHold(hold), null)
+          targets.press(intent.button, intent.face)
         } else if (startHold(hold, intent.face, nowMs)) {
           holdButton = intent.button
           holdContext = context()
diff --git a/src/ui/info.ts b/src/ui/info.ts
index bb51dbe..23f5418 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,7 +1,7 @@
 import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
 import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges'
 import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen'
-import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
+import { bombAt, cellAt, eq, exitOpen, isBurning, turnNumber, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
 import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions'
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn'
 import { SHOP_EVERY } from '../sim/shop'
@@ -213,8 +213,8 @@ export function hudInfo(item: HudItem, s: GameState): Info {
     case 'turn':
       return {
         icon: 'hud_character_0',
-        title: `Turn ${s.turn}, par ${s.par}`,
-        body: [`Turns taken on this level. Finish it in ${s.par} turns or fewer for ${PAR_BONUS} bonus coins (on top of the ${CLEAR_BONUS} for clearing it).`],
+        title: `Turn ${turnNumber(s)}, par ${s.par}`,
+        body: [`The turn you are on in this level. Finish it in ${s.par} turns or fewer for ${PAR_BONUS} bonus coins (on top of the ${CLEAR_BONUS} for clearing it).`],
       }
     case 'bombs':
       return {
@@ -455,6 +455,14 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
     const t = CHARGE_TEXT[drop.kind]
     return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take it (you carry one charge at a time). A blast destroys it.'], clip: drop.kind }
   }
+  // Yourself, when nothing else is on your tile (Q86: the aim must be able to rest on you).
+  if (eq(s.player, p)) {
+    return {
+      icon: 'hud_character_0',
+      title: 'You',
+      body: [`${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left. Each turn you pick one action (move, bomb, charge or wait); then the world takes its turn.`],
+    }
+  }
   return null
 }
 
diff --git a/src/ui/keyCursor.ts b/src/ui/keyCursor.ts
index f799820..5a77129 100644
--- a/src/ui/keyCursor.ts
+++ b/src/ui/keyCursor.ts
@@ -89,6 +89,11 @@ export function modeAfterKey(mode: InputMode, k: KeyInfo, inspecting: boolean, l
   return role === 'direction' || role === 'diagonal' ? null : 'keys'
 }
 
+/** Keys that are no play: a modifier pressed alone, or anything with Cmd held (the OS's and browser's shortcuts, such as a screenshot). They never change who has the board. */
+export function ignoredForMode(k: KeyInfo): boolean {
+  return k.metaKey || ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'OS'].includes(k.key)
+}
+
 /** Alt + arrow: how far the view moves for one key press (or repeat), in tiles, y up (camera.ts `panView`). */
 export const PAN_TILES_PER_KEY = 0.5
 
diff --git a/src/ui/padDialog.ts b/src/ui/padDialog.ts
index 3b54757..8d2fb9a 100644
--- a/src/ui/padDialog.ts
+++ b/src/ui/padDialog.ts
@@ -35,7 +35,7 @@ let tapHinted = false
 /** `refresh` is running (it has the observer off already). */
 let refreshing = false
 
-const SELECTABLE = 'button, [role="button"], a[href="#"], input[type="checkbox"], input[type="radio"]'
+const SELECTABLE = 'button, [role="button"], a[href], input[type="checkbox"], input[type="radio"]'
 
 const isCard = (el: HTMLElement): boolean => el.classList.contains('offer-choice')
 
diff --git a/src/ui/padTarget.ts b/src/ui/padTarget.ts
index 952aa9a..1e02dc8 100644
--- a/src/ui/padTarget.ts
+++ b/src/ui/padTarget.ts
@@ -1,4 +1,4 @@
-import { cellAt } from '../sim/state'
+import { cellAt, eq } from '../sim/state'
 import type { GameState } from '../sim/types'
 import { tileInfo } from './info'
 import { nextFocus, type Step } from './spatial'
@@ -38,20 +38,34 @@ export function jumpTarget(tiles: readonly Tile[], current: Tile, dx: Step, dy:
   return tiles[nextFocus(rects, at, dx, dy)] ?? current
 }
 
+/** A pillar or outer wall that is nothing else (not cracked, not about to fall). */
+export function isPlainWall(s: GameState, p: Tile): boolean {
+  const i = p.y * s.width + p.x
+  return cellAt(s, p) === 'wall' && !s.cracked.includes(i) && !s.collapsing.some((c) => c.cells.includes(i))
+}
+
+/** Where the aim starts: the nearest tile with something to say besides a plain wall (those are all around), else the nearest of all. */
+export function aimStart(s: GameState, tiles: readonly Tile[]): Tile | null {
+  const others = tiles.filter((t) => !eq(t, s.player))
+  return firstTarget(others.filter((t) => !isPlainWall(s, t)), s.player) ?? firstTarget(others, s.player) ?? null
+}
+
 /**
  * Every tile "What is that?" has something to say about, for the controller's inspect aim: what
  * `tileInfo` explains (enemies, bombs, crates, metal, rubble, barrels, hazards, bushes, oil, gems,
  * the exit, drops, a decoy, flame, a falling ceiling, a cracked pillar), nearest the player first.
  * Plain pillars and the outer wall are left out: there are dozens, and the nearest would always be
- * one of them (a tap or click still explains them). The player's own tile has nothing to explain.
+ * one of them (a tap or click still explains them). The player's own tile is left out too, unless `plain` is on (Q86).
+ * `plain`: those are in too (Q82: the aim must be able to ask about a pillar or a wall).
  */
-export function explainableTiles(s: GameState): Tile[] {
+export function explainableTiles(s: GameState, plain = false): Tile[] {
   const tiles: Tile[] = []
   for (let y = 0; y < s.height; y++) {
     for (let x = 0; x < s.width; x++) {
       const p = { x, y }
-      const i = y * s.width + x
-      if (cellAt(s, p) === 'wall' && !s.cracked.includes(i) && !s.collapsing.some((c) => c.cells.includes(i))) continue
+      if (!plain && isPlainWall(s, p)) continue
+      // Yourself is only in the aim's own list (`plain`; the demo's keeps the old one).
+      if (!plain && eq(p, s.player)) continue
       if (tileInfo(s, p)) tiles.push(p)
     }
   }
diff --git a/src/ui/padTiles.ts b/src/ui/padTiles.ts
index 1051d65..395e8fb 100644
--- a/src/ui/padTiles.ts
+++ b/src/ui/padTiles.ts
@@ -150,10 +150,14 @@ export function restingCursor(s: GameState): Vec {
 
 /**
  * Where the cursor starts each turn (decisions.md 2026-11-02): on the destination while an auto-move
- * walk is on, so "Keep walking" is at hand; else on the player (`restingCursor`).
+ * walk is on, so "Keep walking" is at hand; else on the player (`restingCursor`). The destination
+ * only if it is still one of the `candidates` (when given): a flame may have made it unsafe, and a cursor
+ * on a tile with no action can neither act nor move (Q80).
  */
-export function cursorStart(s: GameState, destination: Vec | null): Vec {
-  return destination && !eq(destination, s.player) ? { ...destination } : restingCursor(s)
+export function cursorStart(s: GameState, destination: Vec | null, candidates?: readonly Vec[]): Vec {
+  if (!destination || eq(destination, s.player)) return restingCursor(s)
+  if (candidates && !candidates.some((t) => eq(t, destination))) return restingCursor(s)
+  return { ...destination }
 }
 
 /**
@@ -201,15 +205,19 @@ export function cursorDeadly(actions: readonly TileAction[]): boolean {
   return possible.length > 0 && possible.every((a) => a.deadly)
 }
 
+/**
+ * What L1's hint says when there is nothing to undo, so the legend does not jump around.
+ */
+export const IDLE_L1_TEXT = 'Move cursor back to you'
+
 /**
  * The board hint for L1 (it returns the cursor to the player, and puts away what is in the way):
- * what it says now, and whether it is dimmed because there is nothing to undo. The text stays
- * "Back to you" when idle so the legend does not jump around.
+ * what it says now, and whether it is dimmed because there is nothing to undo.
  */
 export function l1Hint(c: { inspecting: boolean; picked: boolean; onPlayer: boolean }): { text: string; dim: boolean } {
-  if (c.inspecting) return { text: 'Stop asking', dim: false }
-  if (c.picked) return { text: 'Cancel', dim: false }
-  return { text: 'Back to you', dim: c.onPlayer }
+  if (c.inspecting) return { text: IDLE_L1_TEXT, dim: false }
+  if (c.picked) return { text: 'Cancel what you picked', dim: false }
+  return { text: IDLE_L1_TEXT, dim: c.onPlayer }
 }
 
 /** The first action of Move, Bomb, Charge that is possible on a tile that is not the player's own: what the ghost previews. */
diff --git a/test/gamepad.test.ts b/test/gamepad.test.ts
index fd0e3c5..e04b6ed 100644
--- a/test/gamepad.test.ts
+++ b/test/gamepad.test.ts
@@ -388,6 +388,28 @@ describe('hold to confirm', () => {
     expect(h.log).toEqual(['press info undefined'])
   })
 
+  it('Q88: a face button acts at once, with no ring, where `instant` says so', () => {
+    const t = setup()
+    let instant = true
+    t.targets.instant = () => instant
+    t.set(pad())
+    t.input.poll(0)
+    t.calls.length = 0
+    t.set(pad({}, [0]))
+    t.input.poll(16)
+    expect(t.calls).toContain('press move')
+    // Held on, it does not act again; let go and pressed again, it does.
+    t.calls.length = 0
+    t.input.poll(32)
+    expect(t.calls).not.toContain('press move')
+    t.set(pad())
+    t.input.poll(48)
+    instant = false
+    t.set(pad({}, [0]))
+    t.input.poll(64)
+    expect(t.calls).not.toContain('press move')
+  })
+
   it('holds one button at a time', () => {
     const h = held()
     h.show(0)
diff --git a/test/keyCursor.test.ts b/test/keyCursor.test.ts
index 81de3e2..be97244 100644
--- a/test/keyCursor.test.ts
+++ b/test/keyCursor.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { defaultButton, escStep, keyButton, keyDirection, keyRole, modeAfterKey, panStep, targetTakesKey, type KeyInfo } from '../src/ui/keyCursor'
+import { defaultButton, escStep, keyButton, keyDirection, keyRole, modeAfterKey, ignoredForMode, panStep, targetTakesKey, type KeyInfo } from '../src/ui/keyCursor'
 import { cursorWanted, tileActions } from '../src/ui/padTiles'
 import { board, v } from './helpers'
 
@@ -151,3 +151,13 @@ describe('escStep', () => {
     expect(escStep({ inspecting: false, picked: false, onPlayer: true })).toBe('hide')
   })
 })
+
+describe('ignoredForMode (Q81: a screenshot must not take the board from the pad)', () => {
+  it('a modifier alone and any Cmd combination are ignored; plain play keys are not', () => {
+    expect(ignoredForMode(key('MetaLeft', { key: 'Meta', metaKey: true }))).toBe(true)
+    expect(ignoredForMode(key('ShiftLeft', { key: 'Shift', shiftKey: true }))).toBe(true)
+    expect(ignoredForMode(key('Digit4', { key: '4', metaKey: true, shiftKey: true }))).toBe(true)
+    expect(ignoredForMode(key('ArrowUp', { key: 'ArrowUp', shiftKey: true }))).toBe(false)
+    expect(ignoredForMode(key('KeyM', { key: 'm' }))).toBe(false)
+  })
+})
diff --git a/test/padTarget.test.ts b/test/padTarget.test.ts
index e4fb353..23a0f0b 100644
--- a/test/padTarget.test.ts
+++ b/test/padTarget.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { board } from './helpers'
-import { explainableTiles, firstTarget, jumpTarget } from '../src/ui/padTarget'
+import { aimStart, explainableTiles, firstTarget, jumpTarget } from '../src/ui/padTarget'
 
 describe('firstTarget', () => {
   it('is null for no tiles', () => {
@@ -57,3 +57,39 @@ describe('explainableTiles', () => {
     expect(key[0]).toBe('3,1')
   })
 })
+
+describe('Q82: pillars and walls are in the aim', () => {
+  const s = board(`
+    #######
+    #P.+.B#
+    #.#...#
+    #######`)
+  it('with `plain` the pillar and the outer wall can be aimed at; the aim still starts on something else', () => {
+    const all = explainableTiles(s, true).map((t) => `${t.x},${t.y}`)
+    expect(all).toContain('2,2')
+    expect(all).toContain('0,0')
+    expect(all.length).toBeGreaterThan(explainableTiles(s).length)
+    // The nearest tile is a wall, but the start is the crate.
+    expect(aimStart(s, explainableTiles(s, true))).toEqual({ x: 3, y: 1 })
+  })
+  it('a d-pad press from the crate reaches the pillar below it and the wall above it', () => {
+    const tiles = explainableTiles(s, true)
+    expect(jumpTarget(tiles, { x: 3, y: 1 }, 0, -1)).toEqual({ x: 3, y: 0 })
+    expect(jumpTarget(tiles, { x: 3, y: 1 }, -1, 1)).toEqual({ x: 2, y: 2 })
+  })
+})
+
+describe('Q86: the aim can rest on yourself', () => {
+  const s = board(`
+    #######
+    #P.+.B#
+    #######`)
+  it('the full list has the player, the old list and the start do not', () => {
+    expect(explainableTiles(s, true).some((t) => t.x === 1 && t.y === 1)).toBe(true)
+    expect(explainableTiles(s).some((t) => t.x === 1 && t.y === 1)).toBe(false)
+    expect(aimStart(s, explainableTiles(s, true))).toEqual({ x: 3, y: 1 })
+  })
+  it('a d-pad press from the crate goes back to the player', () => {
+    expect(jumpTarget(explainableTiles(s, true), { x: 3, y: 1 }, -1, 0)).toEqual({ x: 1, y: 1 })
+  })
+})
diff --git a/test/padTiles.test.ts b/test/padTiles.test.ts
index 66e1367..9fd3d02 100644
--- a/test/padTiles.test.ts
+++ b/test/padTiles.test.ts
@@ -97,6 +97,11 @@ describe('the cursor', () => {
     // Arrived (destination is the player's own tile): back on the player.
     expect(cursorStart(s, v(3, 3))).toEqual(v(3, 3))
   })
+  it('Q80: a destination that is no longer a candidate (a flame made it unsafe) is not where the cursor starts', () => {
+    const s = room()
+    expect(cursorStart(s, v(5, 1), [v(3, 3), v(5, 1)])).toEqual(v(5, 1))
+    expect(cursorStart(s, v(5, 1), [v(3, 3)])).toEqual(v(3, 3))
+  })
   it('the first d-pad press moves from the resting tile', () => {
     expect(moveCursor(tiles, v(3, 3), 1, 0)).toEqual(v(4, 3))
     expect(moveCursor(tiles, v(3, 3), 0, -1)).toEqual(v(3, 2))
@@ -128,14 +133,14 @@ describe('the cursor', () => {
 
 describe('the L1 hint', () => {
   it('says what L1 will do, dimmed only when there is nothing to undo', () => {
-    expect(l1Hint({ inspecting: true, picked: false, onPlayer: true })).toEqual({ text: 'Stop asking', dim: false })
-    expect(l1Hint({ inspecting: false, picked: true, onPlayer: true })).toEqual({ text: 'Cancel', dim: false })
-    expect(l1Hint({ inspecting: false, picked: false, onPlayer: false })).toEqual({ text: 'Back to you', dim: false })
-    expect(l1Hint({ inspecting: false, picked: false, onPlayer: true })).toEqual({ text: 'Back to you', dim: true })
+    expect(l1Hint({ inspecting: true, picked: false, onPlayer: true })).toEqual({ text: 'Move cursor back to you', dim: false })
+    expect(l1Hint({ inspecting: false, picked: true, onPlayer: true })).toEqual({ text: 'Cancel what you picked', dim: false })
+    expect(l1Hint({ inspecting: false, picked: false, onPlayer: false })).toEqual({ text: 'Move cursor back to you', dim: false })
+    expect(l1Hint({ inspecting: false, picked: false, onPlayer: true })).toEqual({ text: 'Move cursor back to you', dim: true })
   })
   it('"What is that?" wins over a pick, which wins over the cursor being away', () => {
-    expect(l1Hint({ inspecting: true, picked: true, onPlayer: false }).text).toBe('Stop asking')
-    expect(l1Hint({ inspecting: false, picked: true, onPlayer: false }).text).toBe('Cancel')
+    expect(l1Hint({ inspecting: true, picked: true, onPlayer: false }).text).toBe('Move cursor back to you')
+    expect(l1Hint({ inspecting: false, picked: true, onPlayer: false }).text).toBe('Cancel what you picked')
   })
 })
 
diff --git a/test/turnNumber.test.ts b/test/turnNumber.test.ts
new file mode 100644
index 0000000..4a7e9f6
--- /dev/null
+++ b/test/turnNumber.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { turnNumber } from '../src/sim/state'
+import { board } from './helpers'
+
+describe('turnNumber (display, from 1)', () => {
+  it('is the turn being chosen while playing, the last one played once the level is over', () => {
+    const s = { ...board('P.'), turn: 0 }
+    expect(turnNumber(s)).toBe(1)
+    expect(turnNumber({ ...s, turn: 7 })).toBe(8)
+    expect(turnNumber({ ...s, turn: 7, status: 'cleared' })).toBe(7)
+  })
+})