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} {/key}
{/if} {/if}
<footer class="site-footer"> {#if view !== 'game'}
Made with <span class="heart">♥</span> by <footer class="site-footer">
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a> Made with <span class="heart">♥</span> by
</footer> <a href="https://miti99.com" target="_blank" rel="noopener noreferrer">miti99</a>
</footer>
{/if}
<style> <style>
.site-footer { .site-footer {
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 6px; bottom: calc(6px + env(safe-area-inset-bottom));
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
+11 -6
View File
@@ -41,9 +41,9 @@
if (!level) return 48; if (!level) return 48;
const maxTile = 56; const maxTile = 56;
const minTile = 16; 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 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 maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height); const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight)); return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
@@ -206,6 +206,11 @@
<style> <style>
.game { .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; gap: 16px;
} }
@@ -233,17 +238,17 @@
} }
.board-wrap { .board-wrap {
flex: 1 1 auto;
width: 100%; width: 100%;
overflow: auto; overflow: auto;
max-height: calc(100vh - 140px); min-height: 0;
max-width: calc(100vw - 48px); max-width: calc(100vw - 48px);
} }
/* On touch devices, hide the duplicated header action buttons (UNDO / /* On touch devices, hide the duplicated header action buttons —
RESTART / LEVELS) and reserve room for the bottom MobileControls. */ MobileControls now occupies its own row at the bottom of the flex column. */
@media (pointer: coarse) { @media (pointer: coarse) {
.desktop-actions { display: none; } .desktop-actions { display: none; }
.board-wrap { max-height: calc(100vh - 260px); }
} }
.error { .error {
+114 -22
View File
@@ -1,47 +1,134 @@
<!-- <!--
Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse). Touch-only on-screen controls. Hidden on desktop via @media (pointer: coarse).
Bottom-left action stack (UNDO / RESTART / LEVELS), bottom-right D-pad. In-flow bottom row: action stack on the left (UNDO / RESET / LVLS), D-pad on the right.
Tap-only — no auto-repeat. Calls back into GameView with plain handlers. 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> <script>
const REPEAT_MS = 130;
const ARM_MS = 2000;
let { onMove, onUndo, onRestart, onLevels } = $props(); 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> </script>
<div class="dock-left"> <div class="mobile-dock">
<button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button> <div class="dock-left">
<button class="action" type="button" onclick={onRestart} aria-label="Restart">RESET</button> <button class="action" type="button" onclick={onUndo} aria-label="Undo">UNDO</button>
<button class="action" type="button" onclick={onLevels} aria-label="Levels">LVLS</button> <button
</div> 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"> <div class="dpad">
<button class="arrow up" type="button" onclick={() => onMove(0, -1)} aria-label="Up">▲</button> <button
<button class="arrow left" type="button" onclick={() => onMove(-1, 0)} aria-label="Left">◀</button> class="arrow up"
<button class="arrow right" type="button" onclick={() => onMove(1, 0)} aria-label="Right">▶</button> type="button"
<button class="arrow down" type="button" onclick={() => onMove(0, 1)} aria-label="Down">▼</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> </div>
<style> <style>
/* Hidden by default. Coarse-pointer (touch) devices only. */ /* Hidden by default. Coarse-pointer (touch) devices only. */
.dock-left, .mobile-dock {
.dpad {
display: none; display: none;
} }
@media (pointer: coarse) { @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 { .dock-left {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
left: calc(12px + env(safe-area-inset-left));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
z-index: 50;
} }
.dpad { .dpad {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom));
right: calc(12px + env(safe-area-inset-right));
display: grid; display: grid;
grid-template-columns: 56px 56px 56px; grid-template-columns: 56px 56px 56px;
grid-template-rows: 56px 56px; grid-template-rows: 56px 56px;
@@ -49,7 +136,6 @@
". up . " ". up . "
"left down right"; "left down right";
gap: 4px; gap: 4px;
z-index: 50;
} }
} }
@@ -70,6 +156,12 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.action.armed {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.arrow { .arrow {
font-size: 22px; font-size: 22px;
color: var(--text); color: var(--text);