diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 6227e05..da52839 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -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 diff --git a/plans/260428-1004-mobile-mistouch-fix/phase-01-footer-hide-on-game.md b/plans/260428-1004-mobile-mistouch-fix/phase-01-footer-hide-on-game.md new file mode 100644 index 0000000..9bf18c3 --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/phase-01-footer-hide-on-game.md @@ -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 `` 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 `` in a conditional: + ```svelte + {#if view !== 'game'} + + {/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. diff --git a/plans/260428-1004-mobile-mistouch-fix/phase-02-layout-refactor.md b/plans/260428-1004-mobile-mistouch-fix/phase-02-layout-refactor.md new file mode 100644 index 0000000..0fd6449 --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/phase-02-layout-refactor.md @@ -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 +
+
…
+
…
+
+ ``` +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. diff --git a/plans/260428-1004-mobile-mistouch-fix/phase-03-reset-confirm-tap.md b/plans/260428-1004-mobile-mistouch-fix/phase-03-reset-confirm-tap.md new file mode 100644 index 0000000..8ec359c --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/phase-03-reset-confirm-tap.md @@ -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 + + ``` +2. Update RESET button: + ```svelte + + ``` +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. diff --git a/plans/260428-1004-mobile-mistouch-fix/phase-04-dpad-hold-repeat.md b/plans/260428-1004-mobile-mistouch-fix/phase-04-dpad-hold-repeat.md new file mode 100644 index 0000000..e674078 --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/phase-04-dpad-hold-repeat.md @@ -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 + + ``` +2. Replace each arrow's `onclick` with pointer handlers: + ```svelte + + ``` + 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. diff --git a/plans/260428-1004-mobile-mistouch-fix/plan.md b/plans/260428-1004-mobile-mistouch-fix/plan.md new file mode 100644 index 0000000..897337a --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/plan.md @@ -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) diff --git a/plans/260428-1004-mobile-mistouch-fix/reports/code-reviewer-260428-1058-mobile-fix-review.md b/plans/260428-1004-mobile-mistouch-fix/reports/code-reviewer-260428-1058-mobile-fix-review.md new file mode 100644 index 0000000..77e1ebc --- /dev/null +++ b/plans/260428-1004-mobile-mistouch-fix/reports/code-reviewer-260428-1058-mobile-fix-review.md @@ -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 `` 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 `` 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. diff --git a/plans/reports/brainstorm-260428-1004-mobile-mistouch-and-layout.md b/plans/reports/brainstorm-260428-1004-mobile-mistouch-and-layout.md new file mode 100644 index 0000000..cad207d --- /dev/null +++ b/plans/reports/brainstorm-260428-1004-mobile-mistouch-and-layout.md @@ -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'} + +{/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/`. diff --git a/plans/reports/ui-ux-260428-0935-mobile-footer-mistouch.md b/plans/reports/ui-ux-260428-0935-mobile-footer-mistouch.md new file mode 100644 index 0000000..2419d49 --- /dev/null +++ b/plans/reports/ui-ux-260428-0935-mobile-footer-mistouch.md @@ -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 `
` 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 `` (`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 `` 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. +- **``** — 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 `