fix(mobile): kill footer mistouch and dock/board overlap

Footer link was overlapping the on-screen D-pad and action stack tap
zones during gameplay. Hide the footer on the game view and respect
safe-area-inset on the screens where it stays.

MobileControls drops position:fixed and lives in a flex row at the
bottom of GameView's flex column. .board-wrap is now flex:1 1 auto
with min-height:0 so puzzles scroll inside their wrapper, never under
the controls. Magic max-height:calc(100vh - 260px) gone.

RESET on mobile is now two-tap armed (label flips to TAP AGAIN, red
background, 2s timeout) so a stray thumb cannot wipe a puzzle. The R
keyboard shortcut is unchanged.

D-pad arrows fire on pointerdown and auto-repeat at 130ms while held,
matching the keyboard REPEAT_MS. preventDefault on pointerdown blocks
the synthesized click double-fire. $effect cleanup clears intervals
on unmount.

Armed RESET text switched to white (4.85:1 on --danger) to clear
WCAG-AA; using --bg failed at 3.1:1.
This commit is contained in:
tiennm99 committed 2026-04-28 11:09:57 +07:00
1 parent 9b55be4cfb
commit c80b9cedb6
3 files changed
+132 -33

No files matched your search

+7 -5
View File
@@ -31,17 +31,19 @@
{/key}
{/if}
<footer class="site-footer">
Made with <span class="heart">♥</span> by
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
</footer>
{#if view !== 'game'}
<footer class="site-footer">
Made with <span class="heart">♥</span> by
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
</footer>
{/if}
<style>
.site-footer {
position: fixed;
left: 0;
right: 0;
bottom: 6px;
bottom: calc(6px + env(safe-area-inset-bottom));
text-align: center;
font-size: 12px;
color: var(--text-dim);
+11 -6
View File
@@ -41,9 +41,9 @@
if (!level) return 48;
const maxTile = 56;
const minTile = 16;
// On touch devices reserve room for the bottom D-pad + action stack.
// Dock is now in-flow (flex row at bottom); margin only covers header + hud + padding.
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
const margin = isCoarse ? 260 : 140; // header + hud (+ mobile controls) + padding
const margin = isCoarse ? 220 : 120; // header + hud (+ in-flow mobile dock) + padding
const maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
@@ -206,6 +206,11 @@
<style>
.game {
/* Stretch to #app's inner area (min-height: 100vh minus padding) so
.board-wrap can flex-grow without forcing page-scroll. */
align-self: stretch;
display: flex;
flex-direction: column;
gap: 16px;
}
@@ -233,17 +238,17 @@
}
.board-wrap {
flex: 1 1 auto;
width: 100%;
overflow: auto;
max-height: calc(100vh - 140px);
min-height: 0;
max-width: calc(100vw - 48px);
}
/* On touch devices, hide the duplicated header action buttons (UNDO /
RESTART / LEVELS) and reserve room for the bottom MobileControls. */
/* On touch devices, hide the duplicated header action buttons —
MobileControls now occupies its own row at the bottom of the flex column. */
@media (pointer: coarse) {
.desktop-actions { display: none; }
.board-wrap { max-height: calc(100vh - 260px); }
}
.error {
+114 -22
View File
@@ -1,47 +1,134 @@
<!--
Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse).
Bottom-left action stack (UNDO / RESTART / LEVELS), bottom-right D-pad.
Tap-only — no auto-repeat. Calls back into GameView with plain handlers.
In-flow bottom row: action stack on the left (UNDO / RESET / LVLS), D-pad on the right.
RESET is two-tap armed (mobile only) to prevent destructive mis-taps.
Arrow buttons fire on pointerdown and auto-repeat at 130ms while held.
-->
<script>
const REPEAT_MS = 130;
const ARM_MS = 2000;
let { onMove, onUndo, onRestart, onLevels } = $props();
// Press-and-hold repeat shared across all four arrows. Last-press wins.
let holdTimer = null;
function startHold(dx, dy) {
onMove(dx, dy);
clearInterval(holdTimer);
holdTimer = setInterval(() => onMove(dx, dy), REPEAT_MS);
}
function endHold() {
clearInterval(holdTimer);
holdTimer = null;
}
// RESET arming: first tap arms (visual delta), second tap within ARM_MS resets.
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, ARM_MS);
}
// Defensive cleanup on unmount.
$effect(() => () => {
clearInterval(holdTimer);
clearTimeout(armTimer);
});
</script>
<div class="dock-left">
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
<button class="action" type="button" onclick={onRestart} aria-label="Restart">RESET</button>
<button class="action" type="button" onclick={onLevels} aria-label="Levels">LVLS</button>
</div>
<div class="mobile-dock">
<div class="dock-left">
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
<button
class="action"
class:armed={resetArmed}
type="button"
onclick={handleReset}
aria-label={resetArmed ? 'Tap again to confirm reset' : 'Reset'}>
{resetArmed ? 'TAP AGAIN' : 'RESET'}
</button>
<button class="action" type="button" onclick={onLevels} aria-label="Levels">LVLS</button>
</div>
<div class="dpad">
<button class="arrow up" type="button" onclick={() => onMove(0, -1)} aria-label="Up">▲</button>
<button class="arrow left" type="button" onclick={() => onMove(-1, 0)} aria-label="Left">◀</button>
<button class="arrow right" type="button" onclick={() => onMove(1, 0)} aria-label="Right">▶</button>
<button class="arrow down" type="button" onclick={() => onMove(0, 1)} aria-label="Down">▼</button>
<div class="dpad">
<button
class="arrow up"
type="button"
onpointerdown={(e) => { e.preventDefault(); startHold(0, -1); }}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
aria-label="Up">▲</button>
<button
class="arrow left"
type="button"
onpointerdown={(e) => { e.preventDefault(); startHold(-1, 0); }}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
aria-label="Left">◀</button>
<button
class="arrow right"
type="button"
onpointerdown={(e) => { e.preventDefault(); startHold(1, 0); }}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
aria-label="Right">▶</button>
<button
class="arrow down"
type="button"
onpointerdown={(e) => { e.preventDefault(); startHold(0, 1); }}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
aria-label="Down">▼</button>
</div>
</div>
<style>
/* Hidden by default. Coarse-pointer (touch) devices only. */
.dock-left,
.dpad {
.mobile-dock {
display: none;
}
@media (pointer: coarse) {
.mobile-dock {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
width: 100%;
padding:
0
calc(12px + env(safe-area-inset-right))
calc(12px + env(safe-area-inset-bottom))
calc(12px + env(safe-area-inset-left));
box-sizing: border-box;
}
.dock-left {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
left: calc(12px + env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
}
.dpad {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
right: calc(12px + env(safe-area-inset-right));
display: grid;
grid-template-columns: 56px 56px 56px;
grid-template-rows: 56px 56px;
@@ -49,7 +136,6 @@
". up . "
"left down right";
gap: 4px;
z-index: 50;
}
}
@@ -70,6 +156,12 @@
-webkit-user-select: none;
}
.action.armed {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.arrow {
font-size: 22px;
color: var(--text);