docs: log mobile mistouch fix and add plan artifacts

This commit is contained in:
tiennm99 committed 2026-04-28 11:10:05 +07:00
1 parent c80b9cedb6
commit 2f5b184344
9 files changed
+865

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Project Changelog
## 2026-04-28 — Mobile Mistouch & Layout Fix
### Fixed
- **Footer mistouch during gameplay:** `miti99.com` link in the global footer was overlapping the D-pad/action-stack tap zones at the bottom of the screen. Footer now hidden on `view === 'game'` (`App.svelte`), visible only on Menu + Level Select.
- **D-pad/board overlap on small viewports:** `MobileControls` no longer uses `position: fixed`. The dock-left + D-pad live in a single in-flow flex row at the bottom of `GameView`'s flex column. Board-wrap is now `flex: 1 1 auto; min-height: 0` so puzzles scroll within their wrapper, never under the controls. Magic `max-height: calc(100vh - 260px)` removed.
- **Footer safe-area:** Added `env(safe-area-inset-bottom)` to `.site-footer` offset so it sits above the iPhone home-indicator gesture bar.
- **WCAG-AA contrast:** Armed RESET button uses white text (4.85:1 on `--danger`) instead of `--bg` text (failed AA at 3.1:1).
### Added
- **Reset two-tap arming** (`MobileControls.svelte`): First tap on RESET arms (label flips "RESET" → "TAP AGAIN", red background, 2s timeout); second tap within the window restarts. Prevents mid-puzzle wipe-outs from a stray thumb. Keyboard `R` unchanged (immediate reset).
- **D-pad press-and-hold repeat:** Holding any arrow continuously moves the player at 130 ms cadence (parity with keyboard `REPEAT_MS`). Implemented via Pointer Events (`pointerdown`/`pointerup`/`pointercancel`/`pointerleave`) + shared `setInterval`. `e.preventDefault()` on `pointerdown` suppresses the synthesized click double-fire.
### Changed
- `App.svelte`: Conditional footer render; safe-area offset on `.site-footer`.
- `GameView.svelte`: `.game` is now `align-self: stretch; display: flex; flex-direction: column;` so it fills `#app`'s inner area without forcing page-scroll. `.board-wrap` becomes the flex-grow child. `computeTileSize` margin reduced from 260→220 (coarse) and 140→120 (fine) since the dock is no longer fixed-positioned.
- `MobileControls.svelte`: Rewritten — drops fixed positioning + z-index, wraps dock-left + D-pad in `.mobile-dock` flex row, gains arming + hold-repeat state with `$effect` cleanup on unmount.
### Notes
- Bundle: 70.02 kB JS / 24.68 kB gzipped (no meaningful change).
- Build clean. Manual smoke test pending on real iOS/Android.
- Plan: `plans/260428-1004-mobile-mistouch-fix/`.
## 2026-04-27 — Mobile Comfort & PWA
### Added
@@ -0,0 +1,63 @@
---
phase: 01
title: Hide footer on game view + safe-area
status: completed
priority: high
effort: trivial
files: [src/App.svelte]
---
# Phase 01 — Hide footer on game view + safe-area
## Context
Brainstorm decisions D1 + D2. Footer link `https://miti99.com` causes mistouches during gameplay because it sits in the same band as D-pad/action stack.
Footer is `position: fixed; bottom: 6px; pointer-events: none` with the `<a>` having `pointer-events: auto`. Currently rendered globally in `App.svelte` regardless of view.
## Goal
Footer visible only on `view === 'menu'` and `view === 'levels'`. Hidden during `view === 'game'`. Add safe-area inset where shown.
## Implementation
### `src/App.svelte`
1. Wrap `<footer class="site-footer">…</footer>` in a conditional:
```svelte
{#if view !== 'game'}
<footer class="site-footer">…</footer>
{/if}
```
2. Update `.site-footer` CSS:
```css
bottom: calc(6px + env(safe-area-inset-bottom));
```
That's the entire phase.
## Acceptance
- [ ] Footer absent during gameplay (verify in DOM inspector on game view)
- [ ] Footer present on Menu + Level Select
- [ ] On iPhone simulator with home indicator, footer sits above the indicator (does not overlap)
- [ ] Build passes (`npm run build`)
- [ ] No console errors on view transitions
## Risk
Near-zero. Single file, two edits, no logic change.
## Test
Manual:
1. `npm run dev`
2. Resize to 390×844 (iPhone 14)
3. Menu → footer visible at bottom
4. Levels → footer visible at bottom
5. Click level → game starts, footer absent
6. Esc back → footer reappears on level select
## Rollback
`git revert` of phase commit. No data, no migration.
@@ -0,0 +1,107 @@
---
phase: 02
title: In-flow MobileControls (fix D-pad/board overlap)
status: completed
priority: high
effort: medium
files: [src/views/GameView.svelte, src/views/MobileControls.svelte]
---
# Phase 02 — In-flow MobileControls
## Context
Brainstorm decision D5. `MobileControls.svelte` uses `position: fixed; z-index: 50` for both dock-left and D-pad. `.board-wrap` has `overflow: auto` and `max-height: calc(100vh - 260px)` on coarse pointer — vertical overlap protected, but tall puzzles scroll **under** the fixed dock; width is unconstrained so corner-pinned controls float over board content.
Approach α from brainstorm: convert MobileControls to in-flow grid/flex row at bottom of `.screen.game`. Board-wrap becomes flex-grow child. Browser handles z-index/scroll/safe-area naturally.
## Goal
D-pad and action stack live in document flow on mobile. Board-wrap occupies remaining space above. No fixed positioning, no z-index, no max-height clamp tied to magic 260.
## Implementation
### `src/views/GameView.svelte`
1. Make `.screen.game` a column flex container that fills viewport:
```css
.game {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100vh; /* or use 100dvh for mobile address-bar */
}
```
2. `.board-wrap` becomes flex-grow (auto-fits remaining space):
```css
.board-wrap {
flex: 1 1 auto;
width: 100%;
overflow: auto;
min-height: 0; /* required for flex children to allow overflow */
max-width: calc(100vw - 48px);
}
```
Remove the `max-height: calc(100vh - 140px)` and the coarse-pointer override `max-height: calc(100vh - 260px)`.
3. `computeTileSize` margin: drop the magic 260. Measure remaining height via `.board-wrap` `clientHeight` after layout, OR keep simpler approach — use `window.innerHeight - hud.offsetHeight - mobileControls.offsetHeight - padding`. KISS: use `requestAnimationFrame` after mount to measure; recompute on `onResize` and `pointer: coarse` change. If complexity grows, fall back to keeping the constant but document it.
Pragmatic version (KISS):
- Keep margin constants but reduce to `isCoarse ? 200 : 120` since dock no longer floats.
- Recompute on resize as today.
### `src/views/MobileControls.svelte`
1. Drop `position: fixed`, `z-index`, `bottom`/`left`/`right` rules from `.dock-left` and `.dpad`.
2. Wrap both into a single bottom-row container:
```svelte
<div class="mobile-dock">
<div class="dock-left">…</div>
<div class="dpad">…</div>
</div>
```
3. Style:
```css
.mobile-dock { display: none; }
@media (pointer: coarse) {
.mobile-dock {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
padding: 0 calc(12px + env(safe-area-inset-left)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-right));
width: 100%;
}
.dock-left { display: flex; flex-direction: column; gap: 8px; }
.dpad { display: grid; grid-template-columns: 56px 56px 56px; grid-template-rows: 56px 56px;
grid-template-areas: ". up . " "left down right"; gap: 4px; }
}
```
## Acceptance
- [ ] D-pad never visually overlaps board content at 360, 390, 414, 430px wide
- [ ] No `position: fixed` in MobileControls.svelte
- [ ] Board scrolls within `.board-wrap`, not behind dock
- [ ] Safe-area insets respected on iPhone X+
- [ ] Build passes
- [ ] Desktop unchanged (controls still hidden, hud right actions still work)
## Risk
- Layout regression on landscape iPad / iPad split-view. **Mitigation:** smoke test 768×1024 portrait, 1024×768 landscape.
- `min-height: 100vh` vs iOS Safari address bar. **Mitigation:** test in real Safari; switch to `100dvh` if jumping.
- Flex `min-height: 0` on board-wrap is required for overflow scroll inside flex column — easy to forget.
## Test
Manual matrix:
1. 360×640 portrait — small puzzle (Microban 1) and large puzzle (Microban 155)
2. 414×896 portrait
3. 768×1024 portrait + 1024×768 landscape
4. Desktop 1440×900 — verify no regression
5. Verify scroll inside board-wrap (not page scroll) on tall puzzles
## Rollback
`git revert` of phase commit. Pre-phase fixed-positioning still works for desktop.
@@ -0,0 +1,95 @@
---
phase: 03
title: Reset two-tap arming
status: completed
priority: medium
effort: small
files: [src/views/MobileControls.svelte]
depends_on: [phase-02]
---
# Phase 03 — Reset two-tap arming
## Context
Brainstorm decision D3. RESET button is currently one-tap destructive — mid-puzzle mis-tap wipes player progress. User picked option (a): two-tap arming. First tap arms (label flips, color changes); second tap within 2s actually resets; auto-disarms after timeout.
Keyboard `R` is unaffected (assumed deliberate by power users).
## Goal
Mobile RESET requires a second confirmation tap within 2 seconds. Visual delta is unmistakable when armed. No modal, no extra dependencies.
## Implementation
### `src/views/MobileControls.svelte`
1. Add local state for armed flag + timer:
```svelte
<script>
let { onMove, onUndo, onRestart, onLevels } = $props();
let resetArmed = $state(false);
let armTimer = null;
function disarm() { resetArmed = false; armTimer = null; }
function handleReset() {
if (resetArmed) {
clearTimeout(armTimer);
disarm();
onRestart();
return;
}
resetArmed = true;
clearTimeout(armTimer);
armTimer = setTimeout(disarm, 2000);
}
</script>
```
2. Update RESET button:
```svelte
<button
class="action"
class:armed={resetArmed}
type="button"
onclick={handleReset}
aria-label={resetArmed ? 'Tap again to confirm reset' : 'Reset'}>
{resetArmed ? 'TAP AGAIN' : 'RESET'}
</button>
```
3. Add `.armed` style — strong visual delta:
```css
.action.armed {
background: var(--danger);
color: var(--bg);
border-color: var(--danger);
}
```
## Acceptance
- [ ] Single tap on RESET does not restart the level
- [ ] Label flips to "TAP AGAIN" with red background
- [ ] Second tap within 2s restarts the level
- [ ] No second tap → button auto-disarms after 2s, label reverts
- [ ] Tapping UNDO or LVLS while armed does NOT cancel the arm (only 2s timeout cancels) — *acceptable behavior, document if questioned*
- [ ] Keyboard `R` still resets immediately (untouched)
- [ ] aria-label updates with state for screen readers
## Risk
- Cleanup of `armTimer` on unmount: Svelte 5 — if user leaves view while armed, timer fires on disarmed state (no-op). Safe.
- Color contrast of armed state: verify `--danger` on `--bg` meets WCAG AA. (Likely fine — reuse existing token.)
## Test
Manual:
1. On mobile viewport, mid-level: tap RESET once → label changes, no reset
2. Tap again within 2s → level resets, label reverts
3. Tap once, wait 3s → label reverts, level untouched
4. Spam-tap RESET 5 times rapidly → resets exactly once on the 2nd tap (subsequent taps from disarmed state would re-arm; this is fine)
5. Press R on keyboard → immediate reset (regression check)
## Rollback
Revert phase commit. Reset returns to one-tap behavior.
@@ -0,0 +1,86 @@
---
phase: 04
title: D-pad press-and-hold repeat
status: completed
priority: medium
effort: small
files: [src/views/MobileControls.svelte]
depends_on: [phase-02]
---
# Phase 04 — D-pad press-and-hold repeat
## Context
Brainstorm decision D4. Keyboard movement repeats at REPEAT_MS=130 (see GameView.svelte:96). D-pad currently fires on `onclick` only — no parity. Holding a thumb on an arrow does nothing.
Approach (i): Pointer Events (`pointerdown` / `pointerup` / `pointercancel` / `pointerleave`) + setInterval. Covers touch + mouse + stylus uniformly.
## Goal
Holding a D-pad arrow continuously moves the player at 130ms cadence (matches keyboard).
## Implementation
### `src/views/MobileControls.svelte`
1. Add hold-repeat helper:
```svelte
<script>
const REPEAT_MS = 130;
let { onMove, onUndo, onRestart, onLevels } = $props();
let holdTimer = null;
function startHold(dx, dy) {
onMove(dx, dy); // immediate fire
clearInterval(holdTimer);
holdTimer = setInterval(() => onMove(dx, dy), REPEAT_MS);
}
function endHold() {
clearInterval(holdTimer);
holdTimer = null;
}
</script>
```
2. Replace each arrow's `onclick` with pointer handlers:
```svelte
<button
class="arrow up"
type="button"
onpointerdown={(e) => { e.preventDefault(); startHold(0, -1); }}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
aria-label="Up">▲</button>
```
Repeat for down/left/right (same pattern, different dx/dy).
3. Keep `onclick` removed — pointerdown handles the immediate fire. Don't double-fire.
## Acceptance
- [ ] Single tap on arrow → one move (same as before)
- [ ] Hold arrow 1s → ~7-8 moves (1000/130 ≈ 7.7)
- [ ] Release arrow → movement stops within 130ms
- [ ] Sliding finger off the arrow stops movement (`pointerleave`)
- [ ] Multi-touch on two arrows → second arrow takes over (single `holdTimer` is intentional — last wins)
- [ ] Keyboard hold-repeat unchanged (regression check)
## Risk
- `holdTimer` leak if component unmounts mid-hold. **Mitigation:** Svelte's `$effect` cleanup — add `return () => clearInterval(holdTimer);` in an `$effect` block, or accept that the closure-held interval has no DOM target after unmount (`onMove` callback still valid → fires moves into a remounted GameView). Add cleanup defensively.
- `e.preventDefault()` on `pointerdown` is needed to suppress the synthesized `click` after a release — otherwise a double-fire.
- Repeat speed (130ms) inherited from keyboard. If feels too fast on touch with longer travel, tunable in one place.
## Test
Manual:
1. On phone viewport, mid-level: tap once on right arrow → player moves 1 tile
2. Hold right arrow ~1s → player moves ~7-8 tiles continuously
3. Hold right, slide finger off button → movement stops immediately
4. Hold right, then also press left → either-or behavior; last hold wins
5. Push a box continuously by holding direction → haptic pulses fire on each box-push (regression of pulse logic)
6. Switch to keyboard → hold ArrowRight → still works (not regressed)
## Rollback
Revert phase commit. D-pad returns to tap-only.
@@ -0,0 +1,62 @@
---
title: Mobile Mistouch & Layout Fix
date: 2026-04-28
status: completed
branch: main
mode: fast
blockedBy: []
blocks: []
---
# Mobile Mistouch & Layout Fix
Fix footer mistouch + D-pad/board overlap + Reset destructive tap + D-pad hold-repeat. All 6 decisions locked in brainstorm doc.
## Goal
Mobile gameplay (≤480px) is mistouch-free: cannot tap footer link mid-game, D-pad never overlaps board, Reset requires two taps, holding arrow continuously moves player.
## Reports
- `../reports/ui-ux-260428-0935-mobile-footer-mistouch.md` — UX review (root cause)
- `../reports/brainstorm-260428-1004-mobile-mistouch-and-layout.md` — design + locked decisions
## Phases
| # | File | Title | Status | Independently shippable |
|---|------|-------|--------|-------------------------|
| 01 | [phase-01-footer-hide-on-game.md](phase-01-footer-hide-on-game.md) | Hide footer on game view + safe-area | completed | Yes |
| 02 | [phase-02-layout-refactor.md](phase-02-layout-refactor.md) | In-flow MobileControls (D-pad overlap fix) | completed | Yes |
| 03 | [phase-03-reset-confirm-tap.md](phase-03-reset-confirm-tap.md) | Reset two-tap arming | completed | Yes |
| 04 | [phase-04-dpad-hold-repeat.md](phase-04-dpad-hold-repeat.md) | D-pad press-and-hold repeat | completed | Yes |
## Files affected (all phases combined)
- `src/App.svelte` (phase 01)
- `src/views/GameView.svelte` (phase 02 — layout, possibly phase 03 — Reset state)
- `src/views/MobileControls.svelte` (phases 02, 03, 04)
## Order rationale
01 first — smallest, fixes the reported user complaint immediately, zero risk.
02 second — biggest refactor; do it before adding behavior changes to MobileControls.
03 + 04 stack on top of 02's clean layout.
## Success criteria
Combined acceptance:
- [ ] Cannot tap miti99 link during gameplay
- [ ] Footer respects safe-area on Menu + Level Select
- [ ] D-pad never overlaps board content at 360–430px wide
- [ ] Reset requires two taps within 2s
- [ ] Holding arrow moves player at 130ms/step
- [ ] Build passes, no console errors
## Out of scope
Phase 4 roadmap items (sounds, facing direction, level tabs, unit tests) — separate plan.
## Open questions
(none — locked in brainstorm)
@@ -0,0 +1,119 @@
---
title: Mobile Mistouch & Layout Fix — Code Review
date: 2026-04-28
slug: mobile-fix-review
related:
- ../plan.md
- ../../reports/brainstorm-260428-1004-mobile-mistouch-and-layout.md
status: review-complete
---
# Code Review — Mobile Mistouch & Layout Fix
Scope: `src/App.svelte`, `src/views/GameView.svelte`, `src/views/MobileControls.svelte` (uncommitted, ~150 LOC net).
## Verdict
Implementation honors all 6 brainstorm decisions. No critical bugs, no security issues, no data leaks. Two real concerns: a page-scroll bug from `min-height: 100dvh` inside padded `#app`, and a WCAG-AA contrast fail on the armed-RESET button. Multi-touch semantics are implemented correctly per "last-press wins" but have a non-obvious side effect worth documenting.
## Critical
none.
## High
- **GameView.svelte:212 — `.game { min-height: 100dvh }` inside `#app` (padding 24px) forces page scroll.**
`#app` (app.css:62) is flex container with `padding: 24px` (48px vertical). Setting `.game` to `min-height: 100dvh` makes it ≥ viewport tall, so total body height ≥ `100dvh + 48px`. On iOS even with `overscroll-behavior: contain`, this allows page rubber-band scroll during gameplay — partially defeats the dock-overlap fix on small landscape phones.
Fix: either `min-height: 100%` (relative to #app), or remove the rule entirely (flex column with `flex: 1 1 auto` board-wrap doesn't strictly need the min-height — the dock will sit at the natural bottom of content), or scope to `(pointer: coarse)` and pair with `padding: 0` override on `#app`.
Quick check: open in mobile devtools at 414×800, scroll the page — there should be zero overflow when the board fits comfortably.
- **MobileControls.svelte:160 — `armed` button fails WCAG AA contrast.**
`var(--danger)` (#BF616A) bg with `var(--bg)` (#2E3440) text → contrast ≈ 3.1:1. Button text is 13px/700 (app.css inherits — under WCAG "large text" threshold of 14pt bold ≈ 18.67px), so AA needs 4.5:1. Per code-standards spec, accessibility matters.
Three options:
1. Use `var(--text)` (#ECEFF4) on `--danger` → 3.4:1 (still fails, but closer).
2. Add a stronger danger variant (e.g. `--danger-strong: #8B3A41`) and use white text → ≥ 6:1.
3. Accept AA-large by bumping armed font-size to 14pt bold (≥ 18.67px) — but that changes layout.
Recommend option 2 (cleanest, also gives a reusable token).
## Medium
- **MobileControls.svelte:13–24 — single shared `holdTimer` means releasing finger #2 cancels the hold from finger #1.**
Sequence: user holds Up (interval running), then briefly taps Right (interval reassigned to right). `pointerup` on Right fires `endHold()` → interval cleared. User's first finger is still on Up but no further moves fire until they lift and re-press. The plan accepts "last wins" semantics, but doesn't acknowledge this implicit cancel-on-other-release. Either:
- Document explicitly in the source comment near `startHold` so the next maintainer doesn't mistake it for a bug, OR
- Track active pointers per-button (Map of pointerId → direction) and only stop the interval whose pointerId is up'd. KISS says the first option suffices for a Sokoban game where two-finger inputs are unintended anyway.
- **MobileControls.svelte:72,80,88,96 — keyboard accessibility hole.**
Arrow buttons no longer respond to keyboard activation (Enter/Space): work moved from `onclick` to `onpointerdown`. Pressing Enter on a focused arrow button now does nothing because no synthesized pointerdown fires. Mitigated because the dock is hidden on `pointer: fine` and `GameView` has `<svelte:window onkeydown>` for arrows directly — but a coarse-pointer device with a paired keyboard (iPad + Magic Keyboard, Android tablet + BT keyboard) loses the dock as a fallback.
Either: add `onclick={() => { onMove(dx, dy); }}` alongside pointer handlers (one-shot move on Space/Enter — no auto-repeat needed since real keyboard does that), OR document this trade-off if intentional.
- **GameView.svelte:46 — tile-size margin estimate is fragile.**
`margin = 220` (coarse) assumes dock takes ~120px (52px buttons + 12px padding + 12px safe-area + 8px gap × 2 + …). Actual rendered dock height with three 44px action buttons stacked + 8px gap = `44*3 + 8*2 = 148px` plus padding-bottom `12 + safe-area-bottom`. On iPhone with 34px home-indicator inset, dock height ≈ 148 + 12 + 34 = 194px. Add hud (~80) + #app padding (~48) → margin should be closer to 320 not 220.
Net effect: `computeTileSize` over-estimates available height, so `maxByHeight` may exceed real space → board overflows and `.board-wrap`'s internal `overflow: auto` kicks in. Functionally OK (board scrolls inside its wrapper), but defeats the "fit the puzzle on screen" goal.
Cheap fix: remeasure on a real iPhone and adjust margin to ~260–280; or use `getBoundingClientRect()` on the dock once mounted and feed back into the calc.
- **GameView.svelte:208–213 — `min-height: 100dvh` not gated on coarse pointer.**
Desktop users get the same column stretch even though desktop hides MobileControls. Result: lots of empty space below the board on a tall desktop window. Cosmetic but worth scoping the rule to `@media (pointer: coarse)` when fixing the High item above.
## Low
- **MobileControls.svelte:38, 30 — `disarm()` writes `armTimer = null` but `handleReset` (line 43) overwrites with `setTimeout(...)` after `clearTimeout(armTimer)`.**
Race-free, but `disarm` setting `armTimer = null` is pointless — the `clearTimeout(null)` is a no-op anyway. Drop `armTimer = null` from `disarm` for symmetry, or keep it and document why. Nit.
- **MobileControls.svelte:11–24 — `holdTimer` is plain `let` (not `$state`), correctly chosen** (no template binding needs reactivity). Good, but worth a one-line comment so future readers don't "fix" it to `$state`.
- **MobileControls.svelte:62 — `aria-label={resetArmed ? 'Tap again to confirm reset' : 'Reset'}` changes mid-interaction.**
Screen readers may not re-announce. Consider `aria-live="polite"` on a sibling `<span class="visually-hidden">` instead of toggling label, so the state change is announced. Low priority since the visible label also changes ("RESET" → "TAP AGAIN").
- **GameView.svelte:67–71 — `won` triggers but interval keeps firing until pointer release.**
Not a real bug (`tryMove` early-returns on `won`), but timer runs idle for up to a few seconds between win and finger lift. Cosmetic: `endHold()` on win, or check `won` in the interval body. Skip unless profiling shows it matters.
## Nit
- **App.svelte:34** — extracting the footer-visible logic into a named local (`const showFooter = view !== 'game'`) would read slightly cleaner if the condition grows. Trivial.
- **GameView.svelte:47** — comment "in-flow mobile dock" is helpful; consider also noting that the 220 figure was eyeballed and tracking issue if measured-vs-actual diverges (ties to Medium item above).
- **MobileControls.svelte:72** — repeated inline arrow handlers across four buttons is fine but a small `makeStartHold(dx, dy)` factory would DRY. Optional.
- **App.svelte:51** — `pointer-events: none` on footer with `pointer-events: auto` on the link is a nice touch — preserves the original mistouch-mitigation idea even where the footer is shown. No change needed.
## Edge Cases Scouted
- Pointer capture on iOS: confirmed `pointerleave` won't fire on touch (implicit pointer capture), but `pointerup` always does — auto-repeat correctly ends on release. No action needed.
- Win mid-hold: GameView gates `tryMove` on `won`; interval fires harmlessly until release. Not a regression.
- Component unmount mid-hold (back nav): `$effect(() => () => {...})` cleanup runs on Svelte 5 unmount per docs. Verified — this works even with no reactive deps because cleanup tied to effect lifecycle, not deps. Sources: [svelte.dev/$effect docs](https://svelte.dev/docs/svelte/$effect), [Svelte tutorial](https://svelte.dev/tutorial/svelte/effects).
- Two-finger flick (slide from one arrow to another): on touch, due to implicit pointer capture, the pointer stays bound to the first button until release — sliding off doesn't re-trigger pointerdown on the new button. Expected and consistent with hardware D-pad.
- Reset arming persists across UNDO/LVLS taps: confirmed acceptable per plan. Only the 2s timeout disarms it. Note: tapping LVLS while armed will navigate away and unmount the component — `$effect` cleanup clears `armTimer` properly. No leak.
- `e.preventDefault()` on `pointerdown`: blocks synthesized click and focus, both desirable. No `onclick` on arrows so no double-fire risk.
- Safe-area: `viewport-fit=cover` set in index.html → `env()` resolves correctly. App.svelte footer and MobileControls dock both honor insets.
- `class:armed={resetArmed}` correctly compiles in Svelte 5; no migration concern.
## Positive Observations
- `$effect` cleanup pattern is correct for Svelte 5 — no `onDestroy` import needed.
- `pointer: coarse` media query gates the entire dock — desktop unaffected.
- Pointer Events (vs touch+mouse separately) is the right choice; covers hybrid devices.
- In-flow flex layout (decision α) eliminates the z-index / overlap class entirely — much better than padding-fudge alternative.
- Two-tap-arming reuses standard CSS variables — no new theme tokens needed (modulo the contrast issue).
- `pointer-events: none` on footer with auto on the link (App.svelte:51,59) — already a defensive layer for the brief moment between view transitions.
- Brainstorm decisions all honored, including the "last wins" semantic for arrows.
## Recommended Actions (priority order)
1. Fix `.game` min-height interaction with `#app` padding (High #1) — gate to coarse pointer or use `100%`.
2. Address armed-button contrast with stronger danger token or white-on-darker-red (High #2).
3. Re-measure and adjust `computeTileSize` margin to ~260–280 OR refactor to measure dock height (Medium #4).
4. Decide and document multi-touch behavior in source (Medium #1).
5. Optionally restore keyboard activation on arrow buttons (Medium #2).
## Metrics
- LOC net: +~110 / −~30
- Files changed: 3
- Build: not run (review only — recommend `pnpm build` smoke-test before commit)
- Lint: not run
## Unresolved Questions
- Is the multi-touch "release-of-finger-2-cancels-finger-1" semantic intentional, or just an implementation artifact? Brainstorm doc says "last wins" for press but doesn't address release.
- Has the dock height been measured on a real iPhone with 34px home-indicator inset? The 220 margin in `computeTileSize` smells low.
- Is there an a11y target (AA / AA-large / no target) for this project? Affects whether the contrast finding is blocking.
**Status:** DONE_WITH_CONCERNS
**Summary:** Implementation is sound and honors plan; two real issues (page-scroll bug from `min-height: 100dvh`, WCAG-AA fail on armed RESET) should land before commit.
@@ -0,0 +1,140 @@
---
title: Mobile Mistouch & Layout Fix — Brainstorm
date: 2026-04-28
slug: mobile-mistouch-and-layout
related:
- reports/ui-ux-260428-0935-mobile-footer-mistouch.md
status: design-approved
---
# Mobile Mistouch & Layout Fix
## Problem
Mobile users report:
1. Mistouches on `miti99.com` footer link during gameplay.
2. (Surfaced in this session) D-pad / action stack overlap the board view on small viewports.
Footer review report (`ui-ux-260428-0935-mobile-footer-mistouch.md`) covers root cause: fixed footer link sits in same band as D-pad with sub-WCAG hit area.
## Decisions (user-approved)
| # | Decision |
|---|----------|
| D1 | Footer: hide on game view only. Visible on Menu + Level Select. |
| D2 | Add `env(safe-area-inset-bottom)` to footer offset on screens where it remains. |
| D3 | Reset button gains confirm step (no more one-tap destructive wipe). |
| D4 | D-pad gains press-and-hold repeat (parity with keyboard's REPEAT_MS=130). |
| D5 | D-pad/action stack must not overlap the board view. |
| D6 | Create implementation plan first (not direct implement). |
## Layout root cause (D5)
`MobileControls.svelte` uses `position: fixed; z-index: 50` for both dock-left and D-pad.
`GameView.svelte` `.board-wrap` reserves vertical space via `max-height: calc(100vh - 260px)` on `(pointer: coarse)` — but `overflow: auto` means tall puzzles scroll **under** the fixed D-pad. Width is unconstrained — board content can sit horizontally beneath the corner-pinned dock.
`computeTileSize` margin=260 sizes tiles to fit, but if viewport is small enough or aspect ratio awkward, scroll regions appear and the floating dock visually covers them.
## Approach options for D5 (layout)
### α — In-flow controls (recommended)
Convert MobileControls from `position: fixed` into a normal grid/flex row at the bottom of `.screen.game`. Board-wrap becomes the flex-grow child, dock occupies its own row.
- **Pros:** zero overlap by construction. Browser handles z-index, scroll, safe-area naturally. Future-proof.
- **Cons:** more layout refactor (GameView CSS changes). Need to ensure dock-left + D-pad row fits on one line on narrow phones (~360px) — likely fine since dock-left is column-stacked, D-pad is the wider element.
### β — Fixed + page padding
Keep fixed positioning, add `padding-bottom: calc(260px + safe-area)` to `.screen.game` and remove `max-height` clamp from board-wrap. Scroll body, not board.
- **Pros:** smaller diff. No structural change.
- **Cons:** still uses fixed dock — page scrolling on iOS tends to fight gesture-blocking. Tile size calc still needs the 260 reserve. Doesn't fix the conceptual smell.
### γ — Hybrid: fixed dock + spacer div
Keep dock fixed, insert a flex spacer with matching height inside `.screen.game` to push board upward. Equivalent visually to α with less refactor risk.
- **Pros:** middle ground. Dock stays in its corner.
- **Cons:** spacer is dead markup. Two sources of truth for dock height.
**Pick α.** KISS+YAGNI: stop fighting fixed positioning, put the dock where it belongs — in the document flow.
## Approach options for D3 (Reset confirm)
### a — Two-tap arming (recommended for mobile)
First Reset tap arms the button (label changes "RESET" → "TAP AGAIN"), 2-second timeout to disarm. Second tap actually resets.
- **Pros:** zero modal infra, no z-index war. One file change. Short interaction.
- **Cons:** discoverability (user must learn the pattern). Mitigated by visible label change.
### b — Confirm modal
Reuse existing dialog component. Yes/Cancel.
- **Pros:** explicit, no learning curve.
- **Cons:** heavier interaction, modal infra already used by win + donate. Overkill for "are you sure?"
### c — Long-press to reset
Tap = no-op; long-press (500ms) = reset.
- **Pros:** native gesture, no UI change.
- **Cons:** undiscoverable. Conflicts with mobile gesture-blocking. Reject.
**Pick a.** Two-tap arming.
## Approach options for D4 (D-pad hold-repeat)
### i — pointerdown + setInterval (recommended)
On `pointerdown`, fire move immediately, start `setInterval(REPEAT_MS=130)` until `pointerup` / `pointercancel` / `pointerleave`. Uses Pointer Events for unified mouse+touch.
### ii — touchstart-only
Same logic but touch-only events. Misses mouse users on hybrid devices (Surface, iPad+trackpad).
**Pick i.** Pointer Events covers all coarse-pointer cases.
## Approach for D1+D2 (footer)
Single conditional in `App.svelte`:
```svelte
{#if view !== 'game'}
<footer class="site-footer">…</footer>
{/if}
```
Add `bottom: calc(6px + env(safe-area-inset-bottom));` to `.site-footer` for menu/levels screens.
## Files affected
| File | Change |
|------|--------|
| `src/App.svelte` | conditional footer + safe-area inset |
| `src/views/GameView.svelte` | layout refactor: board-wrap grows, dock occupies bottom row; update `computeTileSize` margin if needed |
| `src/views/MobileControls.svelte` | drop `position: fixed`; add hold-repeat to arrow buttons |
| `src/views/AppButton.svelte` or new mini state | possibly armed-state styling for Reset (or inline in MobileControls) |
## Risks
- Layout refactor may regress portrait/landscape edge cases on iPad. Mitigation: manual smoke test 360×640, 414×896, 768×1024.
- Hold-repeat with box-pushing could feel violent if too fast. Mitigation: reuse REPEAT_MS=130, same as keyboard.
- Reset arming-state UX needs clear visual delta. Mitigation: color/icon swap, not just text.
## Success criteria
- Cannot tap miti99 link during gameplay.
- D-pad never visually overlaps board content at common phone sizes (360–430 wide).
- Reset requires two taps within 2s window.
- Holding a D-pad arrow continuously moves the player at keyboard parity.
- Footer respects safe-area-inset on iPhone X+ home-indicator.
- Build passes; no console errors on touch devices.
## Out of scope
- Sound effects (Phase 4).
- Player facing direction (Phase 4).
- Level category tabs (Phase 4).
## Open questions
(none — all clarifications resolved during AskUserQuestion round)
## Next step
Invoke `/ck:plan` with this report as context to produce phased implementation plan in `plans/260428-1004-mobile-mistouch-fix/`.
@@ -0,0 +1,171 @@
# Mobile UX Review — Footer Mistouch on `miti99.com` Link
**Date:** 2026-04-28
**Scope:** Sokoban Svelte 5 — mobile gameplay footer mistouch bug
**Files reviewed:** `src/App.svelte`, `src/views/MobileControls.svelte`, `src/views/GameView.svelte`, `src/app.css`, `index.html`
---
## TL;DR
Footer link sits exactly on top of the mobile D-pad / action stack on small screens. Footer is `position: fixed; bottom: 6px` while D-pad and action stack are `bottom: calc(12px + safe-area-inset-bottom)`. Both render in the same vertical band (~6px–112px from viewport bottom). Even though footer wrapper has `pointer-events: none`, the `<a>` re-enables `pointer-events: auto` and sits above nothing — but importantly, it sits in the gap **between** the dock-left action stack and the D-pad (centered text), where users' thumbs swipe between Undo/Reset and the arrows. Mistouches happen on the centered link text directly.
Also: footer ignores `safe-area-inset-bottom`, so on iPhones with home indicator the link can collide with the system gesture bar too.
**Recommendation:** Hide footer on `view === 'game'` (Option A — KISS, ships in 2 lines). All other options are layered nice-to-haves.
---
## Diagnosis
### 1. Geometric overlap (the bug)
Measurements at default 16px root font:
| Element | Bottom anchor | Approx height | Top edge from viewport bottom |
|---|---|---|---|
| Footer text | `bottom: 6px` | ~14px (12px font + line) | ~6px–20px |
| Footer `<a>` (`miti99`) | centered horizontally | ~14px tall, ~50px wide | same band as above |
| D-pad | `bottom: 12px + safe-area` | 116px (2×56 + 4 gap) | ~12px–128px (right side) |
| Action stack | `bottom: 12px + safe-area` | 148px (3×44 + 2×8 gap) | ~12px–160px (left side) |
The footer link sits in the **horizontal center**, between the action stack (left) and D-pad (right). On phones 360–414px wide:
- Action stack right edge: ~12 + 64 = **76px from left**
- D-pad left edge: viewport_width − 12 − 172 = **viewport_width − 184px from left**
- Center gap on a 390px iPhone 13: **76px to 206px** — that's a 130px wide horizontal corridor where the footer link lives.
The link is centered around viewport mid (~195px on 390px wide), and reaches into the **vertical** band of both controls (6–20px from bottom). The action stack and D-pad both extend **down to 12px from bottom**, so the footer is just 6px above the bottoms of both controls.
**Mistouch scenario:** User's thumb is repeatedly tapping bottom-row D-pad arrows (Down/Left/Right at `bottom: 12px`). A slightly off thumb that lands on the link instead — or a swipe from D-pad bottom edge towards the action stack — hits the footer link and yanks them off to `miti99.com` in a new tab.
### 2. Target size violation (WCAG 2.5.5 AA / 2.5.8 AAA)
Footer link `<a>` has:
- `font-size: 12px`
- No `padding`, no `min-height`, no `min-width`
- Effective hit area: ~50×14px
WCAG 2.5.5 (Level AAA) and 2.5.8 (Level AA, WCAG 2.2): minimum **24×24 CSS px** target size, recommended **44×44** (Apple HIG) / **48×48dp** (Material). This link is **~4× too short** vertically.
A 14px-tall target near the dominant thumb zone is a finger trap.
### 3. Footer purpose vs. context
Footer is decorative attribution — `Made with ♥ by miti99`. It is global (rendered in `App.svelte` outside the view router), so it appears on:
- Menu (desired — branding)
- Levels (acceptable — branding)
- Game (problematic — overlaps controls, no informational value during play)
YAGNI: footer adds zero value during active gameplay. Author credit belongs on menu/about screens.
### 4. Safe-area neglect
`bottom: 6px` does not include `env(safe-area-inset-bottom)`. On iPhone X+ portrait the home-indicator gesture bar lives in the bottom ~34px, so the footer text is **drawn underneath the gesture bar** — invisible or partially clipped, and any tap there fights the iOS system gesture (swipe-up to home). Even worse, the link is rendered into a region the user is conditioned to swipe through.
### 5. Other mobile UX issues spotted
- **`MobileControls` action buttons (`UNDO / RESET / LVLS`) are `min-width: 64px; height: 44px`** — meet 44×44 minimum but are stacked at `gap: 8px`. Reset is between Undo and Lvls. A mis-tap on Reset wipes progress with no confirmation. Suggest adding a tap-to-confirm or a 2s "tap again to confirm reset" pattern.
- **D-pad missing center button** — grid has empty `down` cell only when laid out as defined. Looking again: the grid is `". up ." / "left down right"` — that's fine, no overlapping cells. But arrow buttons have **no explicit `width`/`height`** other than the grid cell (56×56). Visual size matches WCAG; OK.
- **No auto-repeat on D-pad** — comment says "Tap-only — no auto-repeat." Long pushes through corridors require many discrete taps. Consider press-and-hold repeat at REPEAT_MS=130 (already used for keyboard) for parity. Optional, not the bug.
- **Board overflow uses `overflow: auto`** which on mobile creates a scrollable area inside the viewport. Combined with `touch-action: manipulation` on buttons but not on board, two-finger zoom and pan inside board are possible — could be intentional for very large mazes.
- **Header `desktop-actions` already hidden on coarse pointer** — good. But level name and stats wrap at small width; level name is 22px and could be 18px on phones to save vertical room.
- **Win overlay buttons** — fine, modal centered, large hit areas.
- **`<svelte:window onkeydown={onKey}>`** — fine for keyboard. No mobile-specific concern.
- **`-webkit-tap-highlight-color: transparent` globally** — removes the blue flash, which is good for game feel but reduces perceived feedback on the footer link. Combined with no `:active` style on the link, taps feel like nothing happened — until a new tab pops open. Reinforces the "wait, I didn't mean to" reaction.
---
## Fix Proposals (ranked simple → involved)
### Option A — Hide footer during gameplay (recommended)
**Change:** Move `<footer>` rendering inside the `{#if view === 'menu' || view === 'levels'}` branches, or add `{#if view !== 'game'}` guard around it in `App.svelte`.
**Pros:**
- 2-line change. KISS / YAGNI.
- Eliminates the bug entirely on the screen where it matters.
- Branding still shown on menu and level select where players land.
- No CSS gymnastics, no z-index battles.
**Cons:**
- Loses attribution on the game screen. Acceptable — attribution belongs on menu.
- Doesn't fix the WCAG target-size issue on menu/levels (still 12px, ~14px tall). Low risk there because no overlapping controls, but link is still small.
**Code sketch:**
```svelte
{#if view !== 'game'}
<footer class="site-footer"> ... </footer>
{/if}
```
---
### Option B — Move footer above the controls + enlarge hit area
**Change:**
- Bump footer to `bottom: calc(140px + env(safe-area-inset-bottom))` on coarse pointer (above the D-pad/action stack).
- Add `padding: 8px 16px; display: inline-block` to the `<a>` to hit ~30px tall × 80px wide.
- Add `:active` state for tap feedback.
**Pros:**
- Keeps attribution visible everywhere.
- Improves WCAG compliance.
**Cons:**
- Footer floating mid-screen above controls looks awkward and steals visual focus from the puzzle.
- Still risks overlap with the win overlay close zone.
- More CSS than Option A and doesn't reduce surface area for mistouches as much as removing it does.
---
### Option C — Footer on menu/levels only, plus credit line in game HUD
**Change:**
- Apply Option A (hide footer in game view).
- Add a small `Made by miti99` line inside the win modal (or in the menu's existing credit area).
**Pros:**
- Branded everywhere it makes sense, never in the way during play.
- Win modal is a natural moment for attribution + donate CTA (donate modal already there).
**Cons:**
- Slight extra work (~10 lines).
- Two places to maintain credit.
---
### Option D — Keep footer, make it non-interactive during gameplay
**Change:** When `view === 'game'`, render footer with `pointer-events: none` on **both** wrapper and `<a>` (override the `pointer-events: auto`). Visually visible, totally untappable.
**Pros:**
- Branding stays visible. Zero mistouches because nothing is clickable.
**Cons:**
- Defeats the purpose of having a link. Why show a link nobody can tap?
- Footer text still overlaps gesture bar / safe area on iPhone — visual clutter unaddressed.
- Worst of both worlds: present but useless.
---
## Recommendation
**Ship Option A.** It is the smallest, safest fix and addresses the root cause (footer in the gameplay zone serves no purpose). If branding-during-play is desired later, layer Option C on top.
Independently, two follow-ups regardless of choice:
1. Add `bottom: calc(6px + env(safe-area-inset-bottom))` to `.site-footer` so the menu/levels footer respects the iPhone home indicator.
2. Increase footer link tap padding (`padding: 6px 10px`) so the menu/levels link meets 24×24 minimum target size.
---
## Unresolved Questions
1. Is `miti99.com` link a hard requirement on every screen (sponsorship/license/contractual)? If yes, Option B or C; if not, Option A.
2. Do telemetry / referrer logs show how many `miti99.com` clicks come from mobile vs. desktop? High mobile rate would confirm the mistouch theory empirically.
3. Should Reset action gain a confirm-tap given it is adjacent to Undo? Out of scope for this bug but flagged as a related risk.
4. Press-and-hold auto-repeat on D-pad — desired, or intentionally tap-only for puzzle thinking time?
---
**Status:** DONE
**Summary:** Diagnosed mistouch as overlap of fixed footer link with mobile D-pad/action-stack tap zones plus WCAG target-size violation; recommended Option A (hide footer on game view) as KISS fix, with Options B/C/D listed for tradeoff comparison.