fix(web): unclipped PWA icons, deck validation parity, player-tap audio unlock, manifest-based PWA check, vitest security bump
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 -----------
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 99 KiB |
@@ -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 |
@@ -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 |