fix(web): unclipped PWA icons, deck validation parity, player-tap audio unlock, manifest-based PWA check, vitest security bump

This commit is contained in:
tiennm99 committed 2026-10-12 09:46:33 +07:00
1 parent bf91585390
commit 2831953380
14 files changed
+251 -87

No files matched your search

+13
View File
@@ -117,6 +117,19 @@ The script auto-discovers all available `vi-*` voices and writes an updated mani
---
## Regenerating Icons
`static/icons/source.svg` is the source for `icon-192.png` and `icon-512.png`;
`static/icons/source-maskable.svg` (full-bleed background, wordmark inside the
80% safe zone) is the source for `icon-maskable-512.png`. Render them with the
real **Roboto Condensed Black Italic** TTF loaded explicitly — a fallback font is
wider and clips the wordmark. Any SVG rasterizer that accepts a font file works,
e.g. [`@resvg/resvg-js`](https://github.com/thx/resvg-js) with
`font: { fontFiles: [...], loadSystemFonts: false }` and `fitTo` set to each
PNG's exact pixel width. Open the results and check nothing touches the edges.
---
## Deployment
Deployed to GitHub Pages by the `deploy-pages` job in
+69 -69
View File
@@ -23,7 +23,7 @@
"svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vitest": "4.1.10"
"vitest": "^4.1.11"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.33.0"
@@ -3164,16 +3164,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3182,13 +3182,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3209,9 +3209,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3222,13 +3222,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -3236,14 +3236,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3252,9 +3252,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3262,13 +3262,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3504,9 +3504,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3642,9 +3642,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3902,9 +3902,9 @@
}
},
"node_modules/devalue": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz",
"integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"dev": true,
"license": "MIT"
},
@@ -4443,9 +4443,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
@@ -4508,9 +4508,9 @@
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6423,9 +6423,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6998,9 +6998,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -7418,9 +7418,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8078,19 +8078,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8118,12 +8118,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+2 -3
View File
@@ -21,8 +21,7 @@
},
"overrides": {
"serialize-javascript": "^7.0.5",
"cookie": "^0.7.2",
"ws": "^8.20.1"
"cookie": "^0.7.2"
},
"devDependencies": {
"@eslint/js": "^10",
@@ -40,6 +39,6 @@
"svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vitest": "4.1.10"
"vitest": "^4.1.11"
}
}
+34 -10
View File
@@ -19,7 +19,7 @@
* `verify:pwa` script), with the same `BUILD_PROFILE`/`NEXT_BASE_PATH` env
* the build used.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { resolveBase } from "../base-path.js";
@@ -55,19 +55,43 @@ try {
process.exit(1);
}
// --- C1: some built client chunk actually registers it -------------------
// --- the layout's virtual:pwa-register import survived as its own chunk --
// Located through Vite's client manifest rather than by grepping chunk
// contents for "serviceWorker" + "sw.js" together: Rollup is free to split
// those strings across chunks, which would fail this gate with no real
// regression. The manifest names the chunk by its source id regardless.
const appDir = join(BUILD_DIR, "_app");
const jsFiles = walkJsFiles(appDir);
const registersSW = jsFiles.some((f) => {
const src = readFileSync(f, "utf8");
return src.includes("serviceWorker") && src.includes("sw.js");
});
if (!registersSW) {
const CLIENT_MANIFEST = join(".svelte-kit", "output", "client", ".vite", "manifest.json");
/** @type {Record<string, { file: string, dynamicImports?: string[] }>} */
let clientManifest = {};
try {
clientManifest = JSON.parse(readFileSync(CLIENT_MANIFEST, "utf8"));
} catch {
fail(`${CLIENT_MANIFEST} not found or unreadable — run this right after the build.`);
}
const registerKey = Object.keys(clientManifest).find((k) =>
k.endsWith("virtual:pwa-register"),
);
if (!registerKey) {
fail(
`No built JS chunk under ${appDir} registers the service worker ` +
'(expected a "serviceWorker" + "sw.js" reference reachable from the ' +
"virtual:pwa-register import in +layout.svelte).",
"The client build has no virtual:pwa-register chunk — the dynamic " +
"import in +layout.svelte that registers the service worker is gone.",
);
} else {
const registerFile = join(BUILD_DIR, clientManifest[registerKey].file);
if (!existsSync(registerFile)) {
fail(`virtual:pwa-register chunk ${registerFile} is missing from ${BUILD_DIR}.`);
}
const importedBy = Object.values(clientManifest).some((entry) =>
entry.dynamicImports?.includes(registerKey),
);
if (!importedBy) {
fail("No client chunk dynamically imports virtual:pwa-register.");
}
}
if (!jsFiles.some((f) => readFileSync(f, "utf8").includes("sw.js"))) {
fail(`No built JS under ${appDir} references "sw.js" — nothing registers it.`);
}
// --- H1: precached audio entries carry the resolved base path -----------
+10 -1
View File
@@ -17,7 +17,12 @@
import { pushOverlay } from "$lib/overlay-history.js";
import { applyMasterCalls } from "$lib/player-auto-cross.js";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
import {
cancelPlayback,
playBingo,
playWaiting,
unlockAudio,
} from "$lib/voice.js";
const STORAGE_PREFIX = "loto";
const PREVIEW_CELLS = [...Array(27).keys()];
@@ -371,6 +376,10 @@
* @param {number} col
*/
function handleCellClick(row, col) {
// A cell tap is the player side's user gesture — prime the shared
// <audio> element here too, so "Chờ"/"Kinh" can play on iOS in
// player-only mode, where the master's buttons are never tapped.
unlockAudio();
if (
typeof navigator !== "undefined" &&
navigator.vibrate &&
+18 -1
View File
@@ -46,6 +46,22 @@ function isValidNumberArray(v) {
);
}
/**
* Same rule as the Android loader: a restored round must split the
* 90-number deck exactly — no duplicates, no overlap, nothing missing.
* An overlapping or short deck (partial write, tampered storage) would
* make player auto-cross mark a cell twice or never. Both arrays empty
* is also accepted: it is what a tab persists before any round starts.
* @param {number[]} called
* @param {number[]} remaining
*/
function isCompleteDeckPartition(called, remaining) {
const total = called.length + remaining.length;
if (total === 0) return true;
if (total !== 90) return false;
return new Set([...called, ...remaining]).size === total;
}
export function loadMaster() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -56,7 +72,8 @@ export function loadMaster() {
if (
!parsed ||
!isValidNumberArray(parsed.called) ||
!isValidNumberArray(parsed.remaining)
!isValidNumberArray(parsed.remaining) ||
!isCompleteDeckPartition(parsed.called, parsed.remaining)
) {
return;
}
+47
View File
@@ -80,6 +80,53 @@ describe("master-store", () => {
expect(masterState.called).toEqual([]);
});
describe("deck partition validation", () => {
const full = Array.from({ length: 90 }, (_, i) => i + 1);
/** @param {unknown} payload */
function loadFrom(payload) {
localStorage.setItem("loto_master", JSON.stringify(payload));
loadMaster();
}
it("accepts a complete split of the 90-number deck", () => {
loadFrom({ called: full.slice(0, 10), remaining: full.slice(10) });
expect(masterState.called).toEqual(full.slice(0, 10));
expect(masterState.remaining).toHaveLength(80);
});
it("accepts the empty pre-round state", () => {
masterState.called = [3];
masterState.remaining = [4];
loadFrom({ called: [], remaining: [] });
expect(masterState.called).toEqual([]);
expect(masterState.remaining).toEqual([]);
});
it("rejects a short deck", () => {
loadFrom({ called: [1, 2], remaining: [3, 4] });
expect(masterState.called).toEqual([]);
expect(masterState.remaining).toEqual([]);
});
it("rejects a number in both called and remaining", () => {
// 90 entries, but 1 appears twice and 90 is missing.
loadFrom({ called: [1], remaining: full.slice(0, 89) });
expect(masterState.called).toEqual([]);
expect(masterState.remaining).toEqual([]);
});
it("rejects duplicates inside called", () => {
loadFrom({ called: [5, 5], remaining: full.filter((n) => n !== 5 && n !== 6) });
expect(masterState.called).toEqual([]);
});
it("still marks the store hydrated after a rejected payload", () => {
loadFrom({ called: [1, 2], remaining: [3] });
expect(masterState.hydrated).toBe(true);
});
});
it("loadMaster rejects payloads exceeding the size cap", () => {
const big = "x".repeat(20_000);
localStorage.setItem("loto_master", big);
+6 -1
View File
@@ -57,7 +57,12 @@ export function unlockAudio() {
const a = getAudio();
const played = a.play();
if (played && typeof played.then === "function") {
played.then(() => a.pause()).catch(() => {
// Pause only if no clip took the element over in the meantime —
// a caller may start playback right after unlocking, in the same
// gesture, and the priming promise must not cut that clip off.
played.then(() => {
if (activeClip !== a) a.pause();
}).catch(() => {
/* The priming play() itself may reject (e.g. no src yet) — that's
fine, the attempt still happened synchronously inside the
gesture, which is what iOS actually checks. */
+19
View File
@@ -143,4 +143,23 @@ describe("voice — iOS unlock (M6)", () => {
expect(audios[0]).toBe(primed);
expect(audios[0].src).toMatch(/\/9\.mp3$/);
});
it("the priming play() does not pause a clip started in the same gesture", async () => {
// Player cell tap: unlockAudio() then playWaiting() in one click task.
voice.unlockAudio();
voice.playWaiting(5);
// Let the priming play() promise settle.
await Promise.resolve();
await Promise.resolve();
expect(audios.length).toBe(1);
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
expect(audios[0].paused).toBe(false);
});
it("the priming play() pauses the element when nothing else started", async () => {
voice.unlockAudio();
await Promise.resolve();
await Promise.resolve();
expect(audios[0].paused).toBe(true);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!--
Maskable variant of source.svg: the launcher's mask supplies the shape,
so the gradient bleeds to every edge (no rounded tile, no dark border)
and the wordmark shrinks to 160 so it stays inside the central 80%
safe-zone circle. Same composition as the Android adaptive icon.
-->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f43f5e"/>
<stop offset="50%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#f43f5e"/>
</linearGradient>
<linearGradient id="text" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fff8e7"/>
<stop offset="100%" stop-color="#ffe9b8"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<text
x="256" y="316"
text-anchor="middle"
font-family="Roboto Condensed, Arial Narrow, system-ui, sans-serif"
font-weight="900"
font-size="160"
font-style="italic"
letter-spacing="-6"
fill="url(#text)"
style="paint-order:stroke;stroke:#0a0f1f;stroke-width:6;stroke-opacity:0.18"
>Lô tô</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+2 -2
View File
@@ -13,11 +13,11 @@
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<rect x="56" y="56" width="400" height="400" rx="64" fill="#0a0f1f" opacity="0.18"/>
<text
x="256" y="312"
x="256" y="330"
text-anchor="middle"
font-family="Roboto Condensed, Arial Narrow, system-ui, sans-serif"
font-weight="900"
font-size="240"
font-size="200"
font-style="italic"
letter-spacing="-8"
fill="url(#text)"

Before

Width:  |  Height:  |  Size: 996 B

After

Width:  |  Height:  |  Size: 996 B