diff --git a/src/app.css b/src/app.css index d95d9e9..c407639 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/lib/core/board-model.js b/src/lib/core/board-model.js index 055558e..eac4cce 100644 --- a/src/lib/core/board-model.js +++ b/src/lib/core/board-model.js @@ -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() { diff --git a/src/lib/core/level-parser.js b/src/lib/core/level-parser.js index c36302f..7d1bf63 100644 --- a/src/lib/core/level-parser.js +++ b/src/lib/core/level-parser.js @@ -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 }; +} \ No newline at end of file diff --git a/src/views/AppButton.svelte b/src/views/AppButton.svelte index c7c7485..a3f4895 100644 --- a/src/views/AppButton.svelte +++ b/src/views/AppButton.svelte @@ -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) { diff --git a/src/views/Board.svelte b/src/views/Board.svelte index fd3e5ca..9ec0cba 100644 --- a/src/views/Board.svelte +++ b/src/views/Board.svelte @@ -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); diff --git a/src/views/DonateModal.svelte b/src/views/DonateModal.svelte index afc76a2..c6e73fa 100644 --- a/src/views/DonateModal.svelte +++ b/src/views/DonateModal.svelte @@ -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}