refactor: dedup dialog CSS and tighten focus management

- DonateModal: focus the dialog on open so screen readers announce it
  and Tab cycles within; restore focus to the previously-active element
  on close.
- Move shared overlay/dialog scaffolding to app.css so GameView's win
  dialog and DonateModal stop duplicating the same backdrop and frame.
- Move touch-action and tap-highlight neutralization to a global
  button rule; drop the per-component copies.
- level-parser: collapse the key/cellKey alias - export cellKey
  directly.
- BoardModel.isSolved: inline the empty-box-set guard into the return.
- LevelSelectView: completedCount is read once and never reassigned;
  drop the $state wrapper.
- Board: hoist the wall-trim DIRS array out of $derived so it isn't
  rebuilt every reactive recomputation.
This commit is contained in:
2026-04-27 21:03:22 +07:00
parent 78eac20994
commit 0570ca75f8
9 changed files with 73 additions and 65 deletions
+30
View File
@@ -68,6 +68,36 @@ body {
button {
font-family: inherit;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Shared modal scaffolding. Specific dialogs add their own padding/sizing. */
.overlay {
position: fixed;
inset: 0;
background: rgba(12, 16, 24, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
animation: dialog-fade-in 180ms ease;
}
.dialog {
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
}
@keyframes dialog-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
h1, h2, h3, p {
+2 -3
View File
@@ -64,10 +64,9 @@ export class BoardModel {
return true;
}
/** True when every box sits on a target. */
/** True when every box sits on a target. (Empty box-set is never "solved".) */
isSolved() {
if (this.boxes.length === 0) return false;
return this.boxes.every(b => this.isTarget(b.x, b.y));
return this.boxes.length > 0 && this.boxes.every(b => this.isTarget(b.x, b.y));
}
get moveCount() {
+7 -9
View File
@@ -15,7 +15,7 @@ const BOX_ON_TARGET = '*';
const PLAYER = '@';
const PLAYER_ON_TARGET = '+';
const key = (x, y) => `${x},${y}`;
export const cellKey = (x, y) => `${x},${y}`;
function parseGrid(xsb) {
const lines = xsb.split('\n').filter(l => l.length > 0 && !l.startsWith(';'));
@@ -35,24 +35,24 @@ function extractEntities(lines, width, height) {
const ch = row[x] || ' ';
switch (ch) {
case WALL:
walls.add(key(x, y));
walls.add(cellKey(x, y));
break;
case TARGET:
targets.add(key(x, y));
targets.add(cellKey(x, y));
break;
case BOX:
boxes.push({ x, y });
break;
case BOX_ON_TARGET:
boxes.push({ x, y });
targets.add(key(x, y));
targets.add(cellKey(x, y));
break;
case PLAYER:
player = { x, y };
break;
case PLAYER_ON_TARGET:
player = { x, y };
targets.add(key(x, y));
targets.add(cellKey(x, y));
break;
}
}
@@ -68,7 +68,7 @@ function floodFillFloors(player, walls, width, height) {
while (stack.length) {
const { x, y } = stack.pop();
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const k = key(x, y);
const k = cellKey(x, y);
if (floors.has(k) || walls.has(k)) continue;
floors.add(k);
stack.push({ x: x + 1, y });
@@ -84,6 +84,4 @@ export function parseLevel(xsb) {
const { walls, targets, boxes, player } = extractEntities(lines, width, height);
const floors = floodFillFloors(player, walls, width, height);
return { width, height, walls, targets, boxes, player, floors };
}
export { key as cellKey };
}
-2
View File
@@ -42,8 +42,6 @@
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn:hover:not(:disabled) {
+4 -1
View File
@@ -15,6 +15,10 @@
tileSize = 48
} = $props();
// 8-neighborhood for the wall-trim pass below. Hoisted out of $derived
// so it isn't reallocated on every reactive recomputation.
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
function keyToXY(k) {
const [x, y] = k.split(',').map(Number);
return { x, y };
@@ -38,7 +42,6 @@
// Only render walls that touch a floor tile — skips the unused outer border.
let wallCells = $derived.by(() => {
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
const out = [];
for (const k of walls) {
const { x, y } = keyToXY(k);
+25 -21
View File
@@ -7,6 +7,22 @@
let { open = false, onClose } = $props();
let dialogEl = $state();
let prevFocus = null;
// On open: stash the previously-focused element and move focus into the
// dialog so screen readers announce it and Tab cycles within. On close:
// restore the prior focus so keyboard users land back where they were.
$effect(() => {
if (open) {
prevFocus = document.activeElement;
queueMicrotask(() => dialogEl?.focus());
} else if (prevFocus instanceof HTMLElement) {
prevFocus.focus();
prevFocus = null;
}
});
function onKey(e) {
if (open && e.key === 'Escape') onClose();
}
@@ -23,7 +39,14 @@
{#if open}
<div class="overlay" onclick={onBackdropClick} role="presentation">
<div class="dialog" role="dialog" aria-modal="true" aria-label="Donate" tabindex="-1">
<div
class="dialog"
bind:this={dialogEl}
role="dialog"
aria-modal="true"
aria-label="Donate"
tabindex="-1"
>
<h2>Thanks for playing!</h2>
<p class="sub">Scan to send a tip — supports 50+ banking apps.</p>
<img
@@ -37,31 +60,17 @@
{/if}
<style>
/* .overlay + .dialog base lives in app.css; only specific tweaks here. */
.overlay {
position: fixed;
inset: 0;
background: rgba(12, 16, 24, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: fade-in 180ms ease;
padding: 16px;
}
.dialog {
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius-lg);
padding: 24px 28px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 380px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
}
.dialog h2 {
@@ -83,9 +92,4 @@
border-radius: var(--radius);
display: block;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+1 -22
View File
@@ -251,27 +251,10 @@
font-weight: 700;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(12, 16, 24, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade-in 180ms ease;
}
/* .overlay + .dialog base lives in app.css; only specific tweaks here. */
.dialog {
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius-lg);
padding: 32px 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
}
.dialog h2 {
@@ -291,8 +274,4 @@
margin-top: 8px;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+3 -1
View File
@@ -15,7 +15,9 @@
let page = $state(0);
let completedCount = $state(progressStore.getCompletedCount());
// Snapshot once on entry; doesn't reactively refresh, but the user
// navigates away from this screen to play, so this is fine.
const completedCount = progressStore.getCompletedCount();
let visibleLevels = $derived.by(() => {
// Read storage once per page render instead of 2× per visible level.
+1 -6
View File
@@ -53,6 +53,7 @@
}
}
/* Global `button` rules (touch-action, tap-highlight) live in app.css. */
.action {
min-width: 64px;
height: 44px;
@@ -60,30 +61,24 @@
font-size: 13px;
font-weight: 700;
letter-spacing: 1px;
font-family: inherit;
color: var(--text);
background: var(--panel);
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.arrow {
font-size: 22px;
font-family: inherit;
color: var(--text);
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius);
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.arrow.up { grid-area: up; }