@joystyk/pwa 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +215 -2
  3. package/dist/asset-manifest.d.ts +76 -0
  4. package/dist/asset-manifest.js +80 -0
  5. package/dist/asset-manifest.js.map +1 -0
  6. package/dist/build/documents.d.ts +79 -0
  7. package/dist/build/documents.js +135 -0
  8. package/dist/build/documents.js.map +1 -0
  9. package/dist/build/graph.d.ts +39 -0
  10. package/dist/build/graph.js +70 -0
  11. package/dist/build/graph.js.map +1 -0
  12. package/dist/build/icons.d.ts +30 -0
  13. package/dist/build/icons.js +192 -0
  14. package/dist/build/icons.js.map +1 -0
  15. package/dist/build/index.d.ts +4 -0
  16. package/dist/build/index.js +5 -0
  17. package/dist/build/plugin.d.ts +32 -0
  18. package/dist/build/plugin.js +172 -0
  19. package/dist/build/plugin.js.map +1 -0
  20. package/dist/client/availability.d.ts +12 -0
  21. package/dist/client/availability.js +94 -0
  22. package/dist/client/availability.js.map +1 -0
  23. package/dist/client/control.d.ts +3 -0
  24. package/dist/client/control.js +19 -0
  25. package/dist/client/control.js.map +1 -0
  26. package/dist/client/elements.d.ts +45 -0
  27. package/dist/client/elements.js +319 -0
  28. package/dist/client/elements.js.map +1 -0
  29. package/dist/client/forced.d.ts +33 -0
  30. package/dist/client/forced.js +57 -0
  31. package/dist/client/forced.js.map +1 -0
  32. package/dist/client/index.d.ts +7 -0
  33. package/dist/client/index.js +8 -0
  34. package/dist/client/pinned.d.ts +13 -0
  35. package/dist/client/pinned.js +29 -0
  36. package/dist/client/pinned.js.map +1 -0
  37. package/dist/client/pins.d.ts +43 -0
  38. package/dist/client/pins.js +140 -0
  39. package/dist/client/pins.js.map +1 -0
  40. package/dist/client/register.d.ts +64 -0
  41. package/dist/client/register.js +255 -0
  42. package/dist/client/register.js.map +1 -0
  43. package/dist/control.d.ts +46 -0
  44. package/dist/control.js +88 -0
  45. package/dist/control.js.map +1 -0
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +8 -0
  48. package/dist/names.d.ts +47 -0
  49. package/dist/names.js +64 -0
  50. package/dist/names.js.map +1 -0
  51. package/dist/offline/offline.css +94 -0
  52. package/dist/offline/offline.d.ts +1 -0
  53. package/dist/offline/offline.js +26 -0
  54. package/dist/offline/offline.js.map +1 -0
  55. package/dist/offline-mark.d.ts +15 -0
  56. package/dist/offline-mark.js +51 -0
  57. package/dist/offline-mark.js.map +1 -0
  58. package/dist/pin-record.d.ts +14 -0
  59. package/dist/pin-record.js +36 -0
  60. package/dist/pin-record.js.map +1 -0
  61. package/dist/preloads.d.ts +2 -0
  62. package/dist/preloads.js +26 -0
  63. package/dist/preloads.js.map +1 -0
  64. package/dist/routing.d.ts +57 -0
  65. package/dist/routing.js +81 -0
  66. package/dist/routing.js.map +1 -0
  67. package/dist/sw/remove-sw.d.ts +1 -0
  68. package/dist/sw/remove-sw.js +16 -0
  69. package/dist/sw/remove-sw.js.map +1 -0
  70. package/dist/sw/sw.d.ts +1 -0
  71. package/dist/sw/sw.js +165 -0
  72. package/dist/sw/sw.js.map +1 -0
  73. package/dist/validate.d.ts +8 -0
  74. package/dist/validate.js +24 -0
  75. package/dist/validate.js.map +1 -0
  76. package/package.json +65 -11
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","names":[],"sources":["../../src/build/plugin.ts"],"sourcesContent":["// The PWA build step, as a Vite plugin for the site's client build. After Vite writes the\n// client, it adds, from the built output:\n//\n// assets/icon-*.svg|png, assets/offline-*.js|css hashed, immutable (Rule 1)\n// manifest.webmanifest, offline.html served no-cache\n// games/<id>/manifest.json every file each game needs (Rule 1)\n// sw.js one classic script, precache list injected\n// sw-remove.js the emergency self-removing worker (Rule 5)\n// pwa-control.json the default flag file\n// release.json the release ID and time, for the server\n//\n// The worker is bundled from Workbox modules with Vite itself, so the build needs no other\n// bundler. Precache revisions hash the rendered output (the blog's lesson): the site renders\n// its shell pages at build time exactly as the server will, and the build fails if any\n// precache entry is missing.\nimport { existsSync, statSync } from 'node:fs';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { build, type Logger, type Plugin, type ResolvedConfig } from 'vite';\nimport * as z from 'zod/mini';\nimport { canonicalReleaseTime, DEFAULT_CONTROL, RELEASE_RECORD_FILE, type ReleaseRecord } from '../control.ts';\nimport { OFFLINE_URL, WEB_MANIFEST_URL } from '../names.ts';\nimport {\n contentHash,\n gameAssetManifest,\n injectManifest,\n offlinePage,\n precacheEntries,\n webManifest,\n type BuildGame,\n type PrecacheSource,\n} from './documents.ts';\nimport { fileSets, type GameSource } from './graph.ts';\nimport { iconPng, iconSvg } from './icons.ts';\n\n/** What the site supplies at build time, once the client build is on disk. */\nexport type SiteBuildInput = {\n /** The games this build lists: each gets an asset manifest. */\n readonly games: readonly BuildGame[];\n /**\n * Games whose code is in the bundle but which this build doesn't list, such as test games\n * outside test builds. Their code is kept out of the shell and gets no asset manifest.\n */\n readonly unlisted?: readonly GameSource[];\n /** Answers a request exactly as the production server will; used to hash the shell pages. */\n readonly render: (path: string) => Promise<Response>;\n /** Server-rendered paths to precache, such as '/' and '/favicon.svg'. */\n readonly shellPages: readonly string[];\n};\n\nexport type PwaPluginOptions = {\n /** The release ID baked into the worker (git SHA + date). */\n readonly release: string;\n /**\n * When the release's code was committed, as an RFC 3339 date-time. Releases are ordered by\n * it: the flag file's minimum release is one (control.ts).\n */\n readonly releaseTime: string;\n /** Loads the site's half, given the output directory. Called after the client is written. */\n readonly site: (context: { readonly outDir: string }) => Promise<SiteBuildInput>;\n};\n\nconst SOURCE = fileURLToPath(new URL('..', import.meta.url));\n\n// Workspace builds use TypeScript; installed tarballs carry the same modules as JS.\nconst script = (folder: string, name: string): string => {\n const source = join(SOURCE, folder, `${name}.ts`);\n return existsSync(source) ? source : join(SOURCE, folder, `${name}.js`);\n};\n\n/** Bundles one browser or worker script into a single minified classic script. */\nconst bundleScript = async (entry: string, define: Record<string, string> = {}): Promise<string> => {\n const result = await build({\n configFile: false,\n root: SOURCE,\n logLevel: 'warn',\n publicDir: false,\n define: { 'process.env.NODE_ENV': JSON.stringify('production'), ...define },\n build: {\n write: false,\n emptyOutDir: false,\n copyPublicDir: false,\n minify: true,\n target: 'es2022',\n sourcemap: false,\n reportCompressedSize: false,\n lib: { entry, formats: ['iife'], name: 'joystykPwa', fileName: () => 'bundle.js' },\n },\n });\n for (const output of Array.isArray(result) ? result : [result]) {\n if (!('output' in output)) continue;\n for (const item of output.output) if (item.type === 'chunk' && item.isEntry) return item.code;\n }\n throw new Error(`[pwa] Bundling ${entry} produced no script.`);\n};\n\nconst write = async (file: string, content: string | Uint8Array): Promise<void> => {\n await mkdir(dirname(file), { recursive: true });\n await writeFile(file, content);\n};\n\nconst manifestSchema = z.record(\n z.string(),\n z.object({\n file: z.string(),\n name: z.optional(z.string()),\n src: z.optional(z.string()),\n isEntry: z.optional(z.boolean()),\n isDynamicEntry: z.optional(z.boolean()),\n imports: z.optional(z.array(z.string())),\n dynamicImports: z.optional(z.array(z.string())),\n css: z.optional(z.array(z.string())),\n assets: z.optional(z.array(z.string())),\n }),\n);\n\n/** The plugin. Add it to the site's client build only. */\nexport const joystykPwa = (options: PwaPluginOptions): Plugin => {\n const releaseTime = canonicalReleaseTime(options.releaseTime);\n if (releaseTime === null)\n throw new Error(`[pwa] releaseTime \"${options.releaseTime}\" isn't a date-time such as 2026-10-09T14:03:12Z.`);\n let config: ResolvedConfig | undefined;\n let logger: Logger | undefined;\n return {\n name: 'joystyk-pwa',\n apply: 'build',\n enforce: 'post',\n configResolved(resolved) {\n config = resolved;\n logger = resolved.logger;\n },\n async writeBundle(output, bundle) {\n if (config === undefined || config.build.ssr !== false) return;\n const outDir = resolve(config.root, output.dir ?? config.build.outDir);\n const manifestPath = join(outDir, '.vite', 'manifest.json');\n if (!existsSync(manifestPath)) throw new Error('[pwa] The client build needs `build.manifest: true`.');\n const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')));\n const sources = new Map(\n Object.values(bundle)\n .filter((item) => item.type === 'chunk')\n .map((chunk) => [chunk.fileName, chunk.moduleIds]),\n );\n const site = await options.site({ outDir });\n\n // Hashed, immutable files beside Vite's own.\n const hashed = async (name: string, ext: string, content: string | Uint8Array): Promise<string> => {\n const file = `assets/${name}-${contentHash(content)}.${ext}`;\n await write(join(outDir, file), content);\n return file;\n };\n const iconFiles = {\n svg: await hashed('icon', 'svg', iconSvg()),\n png192: await hashed('icon-192', 'png', iconPng(192)),\n png512: await hashed('icon-512', 'png', iconPng(512)),\n maskable512: await hashed('icon-maskable-512', 'png', iconPng(512, true)),\n };\n const offlineCss = await hashed('offline', 'css', await readFile(join(SOURCE, 'offline', 'offline.css')));\n const offlineJs = await hashed('offline', 'js', await bundleScript(script('offline', 'offline')));\n const url = (file: string): string => `/${file}`;\n\n const manifestJson = `${JSON.stringify(webManifest({ svg: url(iconFiles.svg), png192: url(iconFiles.png192), png512: url(iconFiles.png512), maskable512: url(iconFiles.maskable512) }), null, 2)}\\n`;\n await write(join(outDir, 'manifest.webmanifest'), manifestJson);\n const offlineHtml = offlinePage({ css: url(offlineCss), js: url(offlineJs), icon: url(iconFiles.svg) });\n await write(join(outDir, 'offline.html'), offlineHtml);\n\n // Each game's asset manifest.\n const sets = fileSets(manifest, site.games, config.root, site.unlisted ?? [], sources);\n const sizeOf = (file: string): number => statSync(join(outDir, file)).size;\n for (const game of site.games) {\n const assets = gameAssetManifest(\n game,\n options.release,\n sets.shell,\n sets.games.get(game.id) ?? new Set(),\n sizeOf,\n );\n if (!game.assets.startsWith('/') || game.assets.includes('..'))\n throw new Error(`[pwa] ${game.id}: assets \"${game.assets}\" must be a site path.`);\n await write(join(outDir, game.assets), `${JSON.stringify(assets, null, 2)}\\n`);\n }\n\n // Tier 1: the shell's code, the icons, the offline page and the rendered shell pages.\n const rendered: PrecacheSource[] = [];\n for (const path of site.shellPages) {\n const response = await site.render(path);\n if (response.status !== 200)\n throw new Error(`[pwa] Precaching ${path}: the server answers ${String(response.status)}, not 200.`);\n rendered.push({ url: path, hashed: false, body: new Uint8Array(await response.arrayBuffer()) });\n }\n const files = [...sets.shell, ...Object.values(iconFiles), offlineCss, offlineJs];\n const entries = precacheEntries(\n [\n ...rendered,\n { url: OFFLINE_URL, hashed: false, body: offlineHtml },\n { url: WEB_MANIFEST_URL, hashed: false, body: manifestJson },\n ...files.map((file): PrecacheSource => ({ url: url(file), hashed: true, file })),\n ],\n (file) => existsSync(join(outDir, file)),\n );\n\n const worker = injectManifest(\n await bundleScript(script('sw', 'sw'), { __JOYSTYK_RELEASE__: JSON.stringify(options.release) }),\n entries,\n );\n await write(join(outDir, 'sw.js'), worker);\n await write(join(outDir, 'sw-remove.js'), await bundleScript(script('sw', 'remove-sw')));\n await write(join(outDir, 'pwa-control.json'), `${JSON.stringify(DEFAULT_CONTROL, null, 2)}\\n`);\n const record: ReleaseRecord = { release: options.release, releaseTime };\n await write(join(outDir, RELEASE_RECORD_FILE), `${JSON.stringify(record, null, 2)}\\n`);\n logger?.info(\n `[pwa] sw.js ${(worker.length / 1024).toFixed(1)} kB for release ${options.release} (${releaseTime}):${String(entries.length)} precache entries; asset manifests for ${site.games.map((g) => g.id).join(', ') || 'no games'}.`,\n );\n },\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare const PIN_CHANGED = "joystyk:pin-changed";
2
+ export type AvailabilityPorts = {
3
+ readonly root: Document;
4
+ readonly online: () => boolean;
5
+ readonly pins: () => Promise<readonly {
6
+ readonly id: string;
7
+ }[]>;
8
+ };
9
+ /** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per
10
+ * refresh serves the entire listing. Unavailable links explain their state and do not
11
+ * navigate to a broken game while offline. */
12
+ export declare const watchOfflineAvailability: (ports?: AvailabilityPorts) => (() => void);
@@ -0,0 +1,94 @@
1
+ import { pinnedGames } from "./pinned.js";
2
+ import { isGameId } from "@joystyk/contracts";
3
+ //#region src/client/availability.ts
4
+ var PIN_CHANGED = "joystyk:pin-changed";
5
+ /** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per
6
+ * refresh serves the entire listing. Unavailable links explain their state and do not
7
+ * navigate to a broken game while offline. */
8
+ var watchOfflineAvailability = (ports = {
9
+ root: document,
10
+ online: () => navigator.onLine,
11
+ pins: () => pinnedGames()
12
+ }) => {
13
+ let disposed = false;
14
+ let generation = 0;
15
+ let scheduled = false;
16
+ const roots = () => {
17
+ const found = [];
18
+ const visit = (root) => {
19
+ found.push(root);
20
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot !== null) visit(element.shadowRoot);
21
+ };
22
+ visit(ports.root);
23
+ return found;
24
+ };
25
+ const refresh = async () => {
26
+ const current = ++generation;
27
+ const ids = new Set((await ports.pins().catch(() => [])).map((pin) => pin.id));
28
+ if (disposed || current !== generation) return;
29
+ const offline = !ports.online();
30
+ const surfaces = roots();
31
+ observer.disconnect();
32
+ for (const surface of surfaces) observer.observe(surface, {
33
+ childList: true,
34
+ subtree: true
35
+ });
36
+ for (const label of surfaces.flatMap((surface) => [...surface.querySelectorAll("jy-offline-availability[game-id]")])) {
37
+ const id = label.getAttribute("game-id");
38
+ if (!isGameId(id)) continue;
39
+ const pinned = ids.has(id);
40
+ const message = pinned ? "Available offline" : offline ? "Unavailable offline" : "";
41
+ if (label.textContent !== message) label.textContent = message;
42
+ label.hidden = message === "";
43
+ label.dataset["available"] = String(pinned);
44
+ const link = (label.closest("article, li, tr") ?? label.parentElement)?.querySelector(`a[href="/games/${id}"]`);
45
+ if (link !== null && link !== void 0) {
46
+ if (offline && !pinned) link.setAttribute("aria-disabled", "true");
47
+ else link.removeAttribute("aria-disabled");
48
+ }
49
+ }
50
+ };
51
+ const schedule = () => {
52
+ if (disposed || scheduled) return;
53
+ scheduled = true;
54
+ queueMicrotask(() => {
55
+ scheduled = false;
56
+ if (!disposed) refresh();
57
+ });
58
+ };
59
+ const onClick = (event) => {
60
+ if (event.composedPath().find((target) => target instanceof Element && target.matches("a[aria-disabled=\"true\"]")) !== void 0 && !ports.online()) {
61
+ event.preventDefault();
62
+ event.stopImmediatePropagation();
63
+ }
64
+ };
65
+ const observer = new MutationObserver((changes) => {
66
+ if (changes.some((change) => [...change.addedNodes].some((node) => node instanceof Element))) schedule();
67
+ });
68
+ observer.observe(ports.root, {
69
+ childList: true,
70
+ subtree: true
71
+ });
72
+ for (const name of [
73
+ "online",
74
+ "offline",
75
+ PIN_CHANGED
76
+ ]) addEventListener(name, schedule);
77
+ ports.root.addEventListener("click", onClick, true);
78
+ schedule();
79
+ return () => {
80
+ disposed = true;
81
+ generation += 1;
82
+ observer.disconnect();
83
+ for (const name of [
84
+ "online",
85
+ "offline",
86
+ PIN_CHANGED
87
+ ]) removeEventListener(name, schedule);
88
+ ports.root.removeEventListener("click", onClick, true);
89
+ };
90
+ };
91
+ //#endregion
92
+ export { PIN_CHANGED, watchOfflineAvailability };
93
+
94
+ //# sourceMappingURL=availability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"availability.js","names":[],"sources":["../../src/client/availability.ts"],"sourcesContent":["import { isGameId } from '@joystyk/contracts';\nimport { pinnedGames } from './pinned.ts';\n\nexport const PIN_CHANGED = 'joystyk:pin-changed';\nexport type AvailabilityPorts = {\n readonly root: Document;\n readonly online: () => boolean;\n readonly pins: () => Promise<readonly { readonly id: string }[]>;\n};\n\n/** Keep catalog labels accurate across SPA navigation and pin changes. One cache read per\n * refresh serves the entire listing. Unavailable links explain their state and do not\n * navigate to a broken game while offline. */\nexport const watchOfflineAvailability = (\n ports: AvailabilityPorts = {\n root: document,\n online: () => navigator.onLine,\n pins: () => pinnedGames(),\n },\n): (() => void) => {\n let disposed = false;\n let generation = 0;\n let scheduled = false;\n const roots = (): (Document | ShadowRoot)[] => {\n const found: (Document | ShadowRoot)[] = [];\n const visit = (root: Document | ShadowRoot): void => {\n found.push(root);\n for (const element of root.querySelectorAll('*')) if (element.shadowRoot !== null) visit(element.shadowRoot);\n };\n visit(ports.root);\n return found;\n };\n const refresh = async (): Promise<void> => {\n const current = ++generation;\n const ids = new Set((await ports.pins().catch(() => [])).map((pin) => pin.id));\n if (disposed || current !== generation) return;\n const offline = !ports.online();\n const surfaces = roots();\n observer.disconnect();\n for (const surface of surfaces) observer.observe(surface, { childList: true, subtree: true });\n for (const label of surfaces.flatMap((surface) => [\n ...surface.querySelectorAll<HTMLElement>('jy-offline-availability[game-id]'),\n ])) {\n const id = label.getAttribute('game-id');\n if (!isGameId(id)) continue;\n const pinned = ids.has(id);\n const message = pinned ? 'Available offline' : offline ? 'Unavailable offline' : '';\n if (label.textContent !== message) label.textContent = message;\n label.hidden = message === '';\n label.dataset['available'] = String(pinned);\n const scope = label.closest('article, li, tr') ?? label.parentElement;\n const link = scope?.querySelector<HTMLAnchorElement>(`a[href=\"/games/${id}\"]`);\n if (link !== null && link !== undefined) {\n if (offline && !pinned) link.setAttribute('aria-disabled', 'true');\n else link.removeAttribute('aria-disabled');\n }\n }\n };\n const schedule = (): void => {\n if (disposed || scheduled) return;\n scheduled = true;\n queueMicrotask(() => {\n scheduled = false;\n if (!disposed) void refresh();\n });\n };\n const onClick = (event: Event): void => {\n const link = event\n .composedPath()\n .find((target) => target instanceof Element && target.matches('a[aria-disabled=\"true\"]'));\n if (link !== undefined && !ports.online()) {\n event.preventDefault();\n event.stopImmediatePropagation();\n }\n };\n const observer = new MutationObserver((changes) => {\n // A newly mounted custom element may hold its labels inside a shadow root;\n // querySelector on the added host cannot see them. Text-only label updates\n // do not schedule another refresh.\n if (changes.some((change) => [...change.addedNodes].some((node) => node instanceof Element))) schedule();\n });\n observer.observe(ports.root, { childList: true, subtree: true });\n for (const name of ['online', 'offline', PIN_CHANGED]) addEventListener(name, schedule);\n ports.root.addEventListener('click', onClick, true);\n schedule();\n return () => {\n disposed = true;\n generation += 1;\n observer.disconnect();\n for (const name of ['online', 'offline', PIN_CHANGED]) removeEventListener(name, schedule);\n ports.root.removeEventListener('click', onClick, true);\n };\n};\n"],"mappings":";;;AAGA,IAAa,cAAc;;;;AAU3B,IAAa,4BACX,QAA2B;CACzB,MAAM;CACN,cAAc,UAAU;CACxB,YAAY,YAAY;AAC1B,MACiB;CACjB,IAAI,WAAW;CACf,IAAI,aAAa;CACjB,IAAI,YAAY;CAChB,MAAM,cAAyC;EAC7C,MAAM,QAAmC,CAAC;EAC1C,MAAM,SAAS,SAAsC;GACnD,MAAM,KAAK,IAAI;GACf,KAAK,MAAM,WAAW,KAAK,iBAAiB,GAAG,GAAG,IAAI,QAAQ,eAAe,MAAM,MAAM,QAAQ,UAAU;EAC7G;EACA,MAAM,MAAM,IAAI;EAChB,OAAO;CACT;CACA,MAAM,UAAU,YAA2B;EACzC,MAAM,UAAU,EAAE;EAClB,MAAM,MAAM,IAAI,KAAK,MAAM,MAAM,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,EAAA,CAAG,KAAK,QAAQ,IAAI,EAAE,CAAC;EAC7E,IAAI,YAAY,YAAY,YAAY;EACxC,MAAM,UAAU,CAAC,MAAM,OAAO;EAC9B,MAAM,WAAW,MAAM;EACvB,SAAS,WAAW;EACpB,KAAK,MAAM,WAAW,UAAU,SAAS,QAAQ,SAAS;GAAE,WAAW;GAAM,SAAS;EAAK,CAAC;EAC5F,KAAK,MAAM,SAAS,SAAS,SAAS,YAAY,CAChD,GAAG,QAAQ,iBAA8B,kCAAkC,CAC7E,CAAC,GAAG;GACF,MAAM,KAAK,MAAM,aAAa,SAAS;GACvC,IAAI,CAAC,SAAS,EAAE,GAAG;GACnB,MAAM,SAAS,IAAI,IAAI,EAAE;GACzB,MAAM,UAAU,SAAS,sBAAsB,UAAU,wBAAwB;GACjF,IAAI,MAAM,gBAAgB,SAAS,MAAM,cAAc;GACvD,MAAM,SAAS,YAAY;GAC3B,MAAM,QAAQ,eAAe,OAAO,MAAM;GAE1C,MAAM,QADQ,MAAM,QAAQ,iBAAiB,KAAK,MAAM,cAAA,EACpC,cAAiC,kBAAkB,GAAG,GAAG;GAC7E,IAAI,SAAS,QAAQ,SAAS,KAAA,GAAW;IACvC,IAAI,WAAW,CAAC,QAAQ,KAAK,aAAa,iBAAiB,MAAM;SAC5D,KAAK,gBAAgB,eAAe;GAC3C;EACF;CACF;CACA,MAAM,iBAAuB;EAC3B,IAAI,YAAY,WAAW;EAC3B,YAAY;EACZ,qBAAqB;GACnB,YAAY;GACZ,IAAI,CAAC,UAAU,QAAa;EAC9B,CAAC;CACH;CACA,MAAM,WAAW,UAAuB;EAItC,IAHa,MACV,aAAa,CAAC,CACd,MAAM,WAAW,kBAAkB,WAAW,OAAO,QAAQ,2BAAyB,CACrF,MAAS,KAAA,KAAa,CAAC,MAAM,OAAO,GAAG;GACzC,MAAM,eAAe;GACrB,MAAM,yBAAyB;EACjC;CACF;CACA,MAAM,WAAW,IAAI,kBAAkB,YAAY;EAIjD,IAAI,QAAQ,MAAM,WAAW,CAAC,GAAG,OAAO,UAAU,CAAC,CAAC,MAAM,SAAS,gBAAgB,OAAO,CAAC,GAAG,SAAS;CACzG,CAAC;CACD,SAAS,QAAQ,MAAM,MAAM;EAAE,WAAW;EAAM,SAAS;CAAK,CAAC;CAC/D,KAAK,MAAM,QAAQ;EAAC;EAAU;EAAW;CAAW,GAAG,iBAAiB,MAAM,QAAQ;CACtF,MAAM,KAAK,iBAAiB,SAAS,SAAS,IAAI;CAClD,SAAS;CACT,aAAa;EACX,WAAW;EACX,cAAc;EACd,SAAS,WAAW;EACpB,KAAK,MAAM,QAAQ;GAAC;GAAU;GAAW;EAAW,GAAG,oBAAoB,MAAM,QAAQ;EACzF,MAAM,KAAK,oBAAoB,SAAS,SAAS,IAAI;CACvD;AACF"}
@@ -0,0 +1,3 @@
1
+ import { type Control } from '../control.js';
2
+ /** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */
3
+ export declare const readControl: () => Promise<Control | null>;
@@ -0,0 +1,19 @@
1
+ import { CONTROL_URL } from "../names.js";
2
+ import { parseControl } from "../control.js";
3
+ //#region src/client/control.ts
4
+ /** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */
5
+ var readControl = async () => {
6
+ try {
7
+ const response = await fetch(CONTROL_URL, {
8
+ cache: "no-store",
9
+ credentials: "omit"
10
+ });
11
+ return response.ok ? parseControl(await response.json()) : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+ //#endregion
17
+ export { readControl };
18
+
19
+ //# sourceMappingURL=control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.js","names":[],"sources":["../../src/client/control.ts"],"sourcesContent":["// The page reads the flag file (../control.ts) straight from the network: the worker never\n// caches it, and the request skips the HTTP cache too.\nimport { parseControl, type Control } from '../control.ts';\nimport { CONTROL_URL } from '../names.ts';\n\n/** Reads the flag file. A failure (offline, a bad file) reads as no instruction. */\nexport const readControl = async (): Promise<Control | null> => {\n try {\n const response = await fetch(CONTROL_URL, { cache: 'no-store', credentials: 'omit' });\n return response.ok ? parseControl(await response.json()) : null;\n } catch {\n return null;\n }\n};\n"],"mappings":";;;;AAMA,IAAa,cAAc,YAAqC;CAC9D,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,aAAa;GAAE,OAAO;GAAY,aAAa;EAAO,CAAC;EACpF,OAAO,SAAS,KAAK,aAAa,MAAM,SAAS,KAAK,CAAC,IAAI;CAC7D,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,45 @@
1
+ import { type PinPorts } from './pins.js';
2
+ /** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */
3
+ export declare class UpdateToast extends HTMLElement {
4
+ #private;
5
+ /** Called with 'refresh' or 'later'. */
6
+ onChoice: (choice: 'refresh' | 'later') => void;
7
+ constructor();
8
+ }
9
+ /**
10
+ * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A
11
+ * modal dialog that takes focus and blocks the page: it gives the reason and one action,
12
+ * Update now. Escape doesn't close it; the page reloads instead.
13
+ */
14
+ export declare class SecurityUpdate extends HTMLElement {
15
+ #private;
16
+ /** Called once, when the player chooses Update now. */
17
+ onUpdate: () => void;
18
+ constructor();
19
+ connectedCallback(): void;
20
+ }
21
+ /**
22
+ * `<jy-offline-toggle game-id="nim" game-name="Nim" assets="/games/nim/manifest.json">`: a
23
+ * toggle button that pins the game for offline play or removes it. It stays empty (its box
24
+ * reserved by the page's stylesheet) until a service worker is active, so a player never
25
+ * sees a control that can't work.
26
+ */
27
+ export declare class OfflineToggle extends HTMLElement {
28
+ #private;
29
+ static readonly observedAttributes: string[];
30
+ /** Swapped in tests. */
31
+ ports: () => PinPorts;
32
+ constructor();
33
+ connectedCallback(): void;
34
+ disconnectedCallback(): void;
35
+ attributeChangedCallback(): void;
36
+ }
37
+ /** Registers the elements once. */
38
+ export declare const definePwaElements: () => void;
39
+ declare global {
40
+ interface HTMLElementTagNameMap {
41
+ 'jy-update-toast': UpdateToast;
42
+ 'jy-security-update': SecurityUpdate;
43
+ 'jy-offline-toggle': OfflineToggle;
44
+ }
45
+ }
@@ -0,0 +1,319 @@
1
+ import { formatBytes } from "../asset-manifest.js";
2
+ import { pinStatus, unpinGame } from "./pinned.js";
3
+ import { PIN_CHANGED } from "./availability.js";
4
+ import { browserPinPorts, pinGame } from "./pins.js";
5
+ //#region src/client/elements.ts
6
+ var sheet = (css) => {
7
+ const s = new CSSStyleSheet();
8
+ s.replaceSync(css);
9
+ return s;
10
+ };
11
+ var BUTTON_CSS = `
12
+ button {
13
+ min-block-size: var(--jy-target-min, 2.75rem);
14
+ padding: 0.25rem 1rem;
15
+ border: 2px solid var(--jy-border, GrayText);
16
+ border-radius: var(--jy-radius-pill, 999px);
17
+ background: transparent;
18
+ color: var(--jy-text, CanvasText);
19
+ font: inherit;
20
+ font-weight: 600;
21
+ cursor: pointer;
22
+ }
23
+ button:focus-visible {
24
+ outline: 3px solid var(--jy-focus-ring, Highlight);
25
+ outline-offset: 2px;
26
+ }
27
+ button:disabled {
28
+ cursor: default;
29
+ opacity: 0.6;
30
+ }
31
+ [hidden] {
32
+ display: none !important;
33
+ }
34
+ `;
35
+ var el = (tag, props = {}) => Object.assign(document.createElement(tag), props);
36
+ var TOAST_CSS = sheet(`
37
+ :host {
38
+ position: fixed;
39
+ z-index: 1000;
40
+ inset-block-end: max(1rem, env(safe-area-inset-bottom));
41
+ inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));
42
+ display: flex;
43
+ justify-content: flex-end;
44
+ pointer-events: none;
45
+ }
46
+ .toast {
47
+ pointer-events: auto;
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ align-items: center;
51
+ gap: 0.5rem 0.75rem;
52
+ max-inline-size: 28rem;
53
+ padding: 0.75rem 1rem;
54
+ border: 1px solid var(--jy-border, GrayText);
55
+ border-radius: var(--jy-radius-m, 0.75rem);
56
+ background: var(--jy-surface-raised, Canvas);
57
+ color: var(--jy-text, CanvasText);
58
+ box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));
59
+ }
60
+ p {
61
+ flex: 1 1 14rem;
62
+ margin: 0;
63
+ }
64
+ .actions {
65
+ display: flex;
66
+ gap: 0.5rem;
67
+ }
68
+ .primary {
69
+ border-color: var(--jy-accent, LinkText);
70
+ background: var(--jy-accent, LinkText);
71
+ color: var(--jy-on-accent, Canvas);
72
+ }
73
+ ${BUTTON_CSS}
74
+ `);
75
+ /** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */
76
+ var UpdateToast = class extends HTMLElement {
77
+ #message = el("p");
78
+ #refresh = el("button", {
79
+ type: "button",
80
+ className: "primary",
81
+ textContent: "Refresh"
82
+ });
83
+ #later = el("button", {
84
+ type: "button",
85
+ textContent: "Later"
86
+ });
87
+ /** Called with 'refresh' or 'later'. */
88
+ onChoice = () => void 0;
89
+ constructor() {
90
+ super();
91
+ const root = this.attachShadow({ mode: "open" });
92
+ root.adoptedStyleSheets = [TOAST_CSS];
93
+ const box = el("div", { className: "toast" });
94
+ const status = el("div");
95
+ status.setAttribute("role", "status");
96
+ this.#message.append(el("strong", { textContent: "Update ready." }), " Refresh to get the newest Joystyk.");
97
+ status.append(this.#message);
98
+ const actions = el("div", { className: "actions" });
99
+ actions.append(this.#refresh, this.#later);
100
+ box.append(status, actions);
101
+ root.append(box);
102
+ this.#refresh.addEventListener("click", () => this.onChoice("refresh"));
103
+ this.#later.addEventListener("click", () => this.onChoice("later"));
104
+ }
105
+ };
106
+ var SECURITY_CSS = sheet(`
107
+ dialog {
108
+ box-sizing: border-box;
109
+ max-inline-size: min(28rem, calc(100vw - 2rem));
110
+ padding: 1.25rem 1.5rem;
111
+ border: 1px solid var(--jy-border, GrayText);
112
+ border-radius: var(--jy-radius-m, 0.75rem);
113
+ background: var(--jy-surface-raised, Canvas);
114
+ color: var(--jy-text, CanvasText);
115
+ box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));
116
+ font: inherit;
117
+ }
118
+ dialog::backdrop {
119
+ background: rgb(0 0 0 / 0.6);
120
+ }
121
+ h2 {
122
+ margin: 0 0 0.5rem;
123
+ font-size: var(--jy-step-1, 1.25rem);
124
+ }
125
+ p {
126
+ margin: 0 0 1rem;
127
+ }
128
+ .status:empty {
129
+ display: none;
130
+ }
131
+ .primary {
132
+ border-color: var(--jy-accent, LinkText);
133
+ background: var(--jy-accent, LinkText);
134
+ color: var(--jy-on-accent, Canvas);
135
+ }
136
+ ${BUTTON_CSS}
137
+ `);
138
+ /**
139
+ * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A
140
+ * modal dialog that takes focus and blocks the page: it gives the reason and one action,
141
+ * Update now. Escape doesn't close it; the page reloads instead.
142
+ */
143
+ var SecurityUpdate = class extends HTMLElement {
144
+ #dialog = el("dialog");
145
+ #status = el("p", { className: "status" });
146
+ #button = el("button", {
147
+ type: "button",
148
+ className: "primary",
149
+ textContent: "Update now"
150
+ });
151
+ /** Called once, when the player chooses Update now. */
152
+ onUpdate = () => void 0;
153
+ constructor() {
154
+ super();
155
+ const root = this.attachShadow({ mode: "open" });
156
+ root.adoptedStyleSheets = [SECURITY_CSS];
157
+ const title = el("h2", {
158
+ id: "security-update-title",
159
+ textContent: "Security update"
160
+ });
161
+ const reason = el("p", {
162
+ id: "security-update-reason",
163
+ textContent: "This version of Joystyk has a security problem that a new version fixes. Update to keep playing. Your game is saved first, so you can carry on where you left off."
164
+ });
165
+ this.#dialog.setAttribute("aria-labelledby", title.id);
166
+ this.#dialog.setAttribute("aria-describedby", reason.id);
167
+ this.#dialog.setAttribute("closedby", "none");
168
+ this.#status.setAttribute("role", "status");
169
+ this.#dialog.append(title, reason, this.#status, this.#button);
170
+ root.append(this.#dialog);
171
+ this.#dialog.addEventListener("cancel", (event) => event.preventDefault());
172
+ this.#dialog.addEventListener("close", () => {
173
+ if (this.isConnected) this.#open();
174
+ });
175
+ this.#button.addEventListener("click", () => {
176
+ if (this.#button.disabled) return;
177
+ this.#button.disabled = true;
178
+ this.#status.textContent = "Saving your game, then updating…";
179
+ this.onUpdate();
180
+ });
181
+ }
182
+ connectedCallback() {
183
+ this.#open();
184
+ }
185
+ #open() {
186
+ if (!this.#dialog.open) this.#dialog.showModal();
187
+ this.#button.focus();
188
+ }
189
+ };
190
+ var TOGGLE_CSS = sheet(`
191
+ :host {
192
+ display: block;
193
+ min-block-size: var(--jy-target-min, 2.75rem);
194
+ }
195
+ .wrap {
196
+ display: flex;
197
+ flex-wrap: wrap;
198
+ align-items: center;
199
+ gap: 0.5rem 0.75rem;
200
+ }
201
+ button[aria-pressed='true'] {
202
+ border-color: var(--jy-success, currentColor);
203
+ }
204
+ .status {
205
+ margin: 0;
206
+ font-size: var(--jy-step--1, 0.9rem);
207
+ color: var(--jy-text-muted, inherit);
208
+ }
209
+ ${BUTTON_CSS}
210
+ `);
211
+ /** Whether this browser can pin games at all. */
212
+ var canPin = () => "serviceWorker" in navigator && "caches" in window;
213
+ /**
214
+ * `<jy-offline-toggle game-id="nim" game-name="Nim" assets="/games/nim/manifest.json">`: a
215
+ * toggle button that pins the game for offline play or removes it. It stays empty (its box
216
+ * reserved by the page's stylesheet) until a service worker is active, so a player never
217
+ * sees a control that can't work.
218
+ */
219
+ var OfflineToggle = class extends HTMLElement {
220
+ static observedAttributes = ["game-id", "assets"];
221
+ /** Swapped in tests. */
222
+ ports = browserPinPorts;
223
+ #wrap = el("div", {
224
+ className: "wrap",
225
+ hidden: true
226
+ });
227
+ #button = el("button", { type: "button" });
228
+ #status = el("p", { className: "status" });
229
+ #pinned = false;
230
+ #busy = false;
231
+ #generation = 0;
232
+ #onConnection = () => this.#render();
233
+ constructor() {
234
+ super();
235
+ const root = this.attachShadow({ mode: "open" });
236
+ root.adoptedStyleSheets = [TOGGLE_CSS];
237
+ this.#button.append(el("span", {
238
+ textContent: "⤓ ",
239
+ ariaHidden: "true"
240
+ }), "Make available offline");
241
+ this.#status.setAttribute("role", "status");
242
+ this.#wrap.append(this.#button, this.#status);
243
+ root.append(this.#wrap);
244
+ this.#button.addEventListener("click", () => void this.#toggle());
245
+ }
246
+ get #gameId() {
247
+ return this.getAttribute("game-id") ?? "";
248
+ }
249
+ get #gameName() {
250
+ return this.getAttribute("game-name") ?? "This game";
251
+ }
252
+ connectedCallback() {
253
+ addEventListener("online", this.#onConnection);
254
+ addEventListener("offline", this.#onConnection);
255
+ this.#load();
256
+ }
257
+ disconnectedCallback() {
258
+ removeEventListener("online", this.#onConnection);
259
+ removeEventListener("offline", this.#onConnection);
260
+ this.#generation += 1;
261
+ }
262
+ attributeChangedCallback() {
263
+ if (this.isConnected) this.#load();
264
+ }
265
+ async #load() {
266
+ const generation = ++this.#generation;
267
+ this.#wrap.hidden = true;
268
+ if (!canPin() || this.#gameId === "") return;
269
+ await navigator.serviceWorker.ready;
270
+ const record = await pinStatus(this.#gameId, this.ports()).catch(() => null);
271
+ if (generation !== this.#generation) return;
272
+ this.#pinned = record !== null;
273
+ this.#status.textContent = record === null ? "" : `${this.#gameName} plays offline on this device (${formatBytes(record.totalBytes)}).`;
274
+ this.#wrap.hidden = false;
275
+ this.#render();
276
+ }
277
+ #render() {
278
+ this.#button.setAttribute("aria-pressed", String(this.#pinned));
279
+ const offline = !navigator.onLine && !this.#pinned;
280
+ this.#button.disabled = this.#busy || offline;
281
+ if (offline && !this.#busy) this.#status.textContent = "Connect to the internet to make this game available offline.";
282
+ }
283
+ async #toggle() {
284
+ if (this.#busy) return;
285
+ const generation = this.#generation;
286
+ const id = this.#gameId;
287
+ const name = this.#gameName;
288
+ this.#busy = true;
289
+ this.#render();
290
+ if (this.#pinned) {
291
+ await unpinGame(id, this.ports());
292
+ this.#pinned = false;
293
+ this.#status.textContent = `Removed ${name} from this device.`;
294
+ } else {
295
+ this.#status.textContent = `Saving ${name} for offline play…`;
296
+ const result = await pinGame({
297
+ gameId: id,
298
+ assets: this.getAttribute("assets") ?? `/games/${id}/manifest.json`
299
+ }, this.ports());
300
+ if (result.ok) {
301
+ this.#pinned = true;
302
+ this.#status.textContent = `${name} plays offline on this device (${formatBytes(result.value.record.totalBytes)}).`;
303
+ } else this.#status.textContent = result.error.message;
304
+ }
305
+ this.#busy = false;
306
+ dispatchEvent(new Event(PIN_CHANGED));
307
+ if (generation === this.#generation) this.#render();
308
+ }
309
+ };
310
+ /** Registers the elements once. */
311
+ var definePwaElements = () => {
312
+ if (customElements.get("jy-update-toast") === void 0) customElements.define("jy-update-toast", UpdateToast);
313
+ if (customElements.get("jy-security-update") === void 0) customElements.define("jy-security-update", SecurityUpdate);
314
+ if (customElements.get("jy-offline-toggle") === void 0) customElements.define("jy-offline-toggle", OfflineToggle);
315
+ };
316
+ //#endregion
317
+ export { OfflineToggle, SecurityUpdate, UpdateToast, definePwaElements };
318
+
319
+ //# sourceMappingURL=elements.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elements.js","names":[],"sources":["../../src/client/elements.ts"],"sourcesContent":["// The PWA's frame UI, as small custom elements with no framework: the \"Update ready\" toast,\n// the forced security update's notice and the \"Make available offline\" control. Each renders\n// into its own shadow root and styles itself with a constructed stylesheet, which the site's\n// CSP allows, and reads the frame's design tokens (@joystyk/ui/tokens.css) through\n// inheritance, with plain fallbacks. None renders anything until the page needs it.\nimport { PIN_CHANGED } from './availability.ts';\nimport { formatBytes } from '../asset-manifest.ts';\nimport { pinStatus, unpinGame } from './pinned.ts';\nimport { browserPinPorts, pinGame, type PinPorts } from './pins.ts';\n\nconst sheet = (css: string): CSSStyleSheet => {\n const s = new CSSStyleSheet();\n s.replaceSync(css);\n return s;\n};\n\nconst BUTTON_CSS = `\n button {\n min-block-size: var(--jy-target-min, 2.75rem);\n padding: 0.25rem 1rem;\n border: 2px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-pill, 999px);\n background: transparent;\n color: var(--jy-text, CanvasText);\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n }\n button:focus-visible {\n outline: 3px solid var(--jy-focus-ring, Highlight);\n outline-offset: 2px;\n }\n button:disabled {\n cursor: default;\n opacity: 0.6;\n }\n [hidden] {\n display: none !important;\n }\n`;\n\nconst el = <K extends keyof HTMLElementTagNameMap>(\n tag: K,\n props: Partial<HTMLElementTagNameMap[K]> = {},\n): HTMLElementTagNameMap[K] => Object.assign(document.createElement(tag), props);\n\n// --- The update toast ---------------------------------------------------------------------------\n\nconst TOAST_CSS = sheet(`\n :host {\n position: fixed;\n z-index: 1000;\n inset-block-end: max(1rem, env(safe-area-inset-bottom));\n inset-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));\n display: flex;\n justify-content: flex-end;\n pointer-events: none;\n }\n .toast {\n pointer-events: auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 0.75rem;\n max-inline-size: 28rem;\n padding: 0.75rem 1rem;\n border: 1px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-m, 0.75rem);\n background: var(--jy-surface-raised, Canvas);\n color: var(--jy-text, CanvasText);\n box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));\n }\n p {\n flex: 1 1 14rem;\n margin: 0;\n }\n .actions {\n display: flex;\n gap: 0.5rem;\n }\n .primary {\n border-color: var(--jy-accent, LinkText);\n background: var(--jy-accent, LinkText);\n color: var(--jy-on-accent, Canvas);\n }\n ${BUTTON_CSS}\n`);\n\n/** `<jy-update-toast>`: says an update is ready and offers Refresh or Later. */\nexport class UpdateToast extends HTMLElement {\n #message = el('p');\n #refresh = el('button', { type: 'button', className: 'primary', textContent: 'Refresh' });\n #later = el('button', { type: 'button', textContent: 'Later' });\n /** Called with 'refresh' or 'later'. */\n onChoice: (choice: 'refresh' | 'later') => void = () => undefined;\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [TOAST_CSS];\n const box = el('div', { className: 'toast' });\n // A polite status region: announced once, never stealing focus from a game or a page.\n const status = el('div');\n status.setAttribute('role', 'status');\n this.#message.append(el('strong', { textContent: 'Update ready.' }), ' Refresh to get the newest Joystyk.');\n status.append(this.#message);\n const actions = el('div', { className: 'actions' });\n actions.append(this.#refresh, this.#later);\n box.append(status, actions);\n root.append(box);\n this.#refresh.addEventListener('click', () => this.onChoice('refresh'));\n this.#later.addEventListener('click', () => this.onChoice('later'));\n }\n}\n\n// --- The security update notice -----------------------------------------------------------------\n\nconst SECURITY_CSS = sheet(`\n dialog {\n box-sizing: border-box;\n max-inline-size: min(28rem, calc(100vw - 2rem));\n padding: 1.25rem 1.5rem;\n border: 1px solid var(--jy-border, GrayText);\n border-radius: var(--jy-radius-m, 0.75rem);\n background: var(--jy-surface-raised, Canvas);\n color: var(--jy-text, CanvasText);\n box-shadow: var(--jy-shadow, 0 4px 16px rgb(0 0 0 / 0.2));\n font: inherit;\n }\n dialog::backdrop {\n background: rgb(0 0 0 / 0.6);\n }\n h2 {\n margin: 0 0 0.5rem;\n font-size: var(--jy-step-1, 1.25rem);\n }\n p {\n margin: 0 0 1rem;\n }\n .status:empty {\n display: none;\n }\n .primary {\n border-color: var(--jy-accent, LinkText);\n background: var(--jy-accent, LinkText);\n color: var(--jy-on-accent, Canvas);\n }\n ${BUTTON_CSS}\n`);\n\n/**\n * `<jy-security-update>`: the forced update's notice (offline-and-updates.md, Rule 2). A\n * modal dialog that takes focus and blocks the page: it gives the reason and one action,\n * Update now. Escape doesn't close it; the page reloads instead.\n */\nexport class SecurityUpdate extends HTMLElement {\n #dialog = el('dialog');\n #status = el('p', { className: 'status' });\n #button = el('button', { type: 'button', className: 'primary', textContent: 'Update now' });\n /** Called once, when the player chooses Update now. */\n onUpdate: () => void = () => undefined;\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [SECURITY_CSS];\n const title = el('h2', { id: 'security-update-title', textContent: 'Security update' });\n const reason = el('p', {\n id: 'security-update-reason',\n textContent:\n 'This version of Joystyk has a security problem that a new version fixes. Update to keep playing. Your game is saved first, so you can carry on where you left off.',\n });\n this.#dialog.setAttribute('aria-labelledby', title.id);\n this.#dialog.setAttribute('aria-describedby', reason.id);\n this.#dialog.setAttribute('closedby', 'none');\n this.#status.setAttribute('role', 'status');\n this.#dialog.append(title, reason, this.#status, this.#button);\n root.append(this.#dialog);\n // Blocking means blocking: Escape and close requests leave it open.\n this.#dialog.addEventListener('cancel', (event) => event.preventDefault());\n this.#dialog.addEventListener('close', () => {\n if (this.isConnected) this.#open();\n });\n this.#button.addEventListener('click', () => {\n if (this.#button.disabled) return;\n this.#button.disabled = true;\n this.#status.textContent = 'Saving your game, then updating…';\n this.onUpdate();\n });\n }\n\n connectedCallback(): void {\n this.#open();\n }\n\n #open(): void {\n if (!this.#dialog.open) this.#dialog.showModal();\n this.#button.focus();\n }\n}\n\n// --- The offline control ------------------------------------------------------------------------\n\nconst TOGGLE_CSS = sheet(`\n :host {\n display: block;\n min-block-size: var(--jy-target-min, 2.75rem);\n }\n .wrap {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 0.75rem;\n }\n button[aria-pressed='true'] {\n border-color: var(--jy-success, currentColor);\n }\n .status {\n margin: 0;\n font-size: var(--jy-step--1, 0.9rem);\n color: var(--jy-text-muted, inherit);\n }\n ${BUTTON_CSS}\n`);\n\n/** Whether this browser can pin games at all. */\nconst canPin = (): boolean => 'serviceWorker' in navigator && 'caches' in window;\n\n/**\n * `<jy-offline-toggle game-id=\"nim\" game-name=\"Nim\" assets=\"/games/nim/manifest.json\">`: a\n * toggle button that pins the game for offline play or removes it. It stays empty (its box\n * reserved by the page's stylesheet) until a service worker is active, so a player never\n * sees a control that can't work.\n */\nexport class OfflineToggle extends HTMLElement {\n static readonly observedAttributes = ['game-id', 'assets'];\n /** Swapped in tests. */\n ports: () => PinPorts = browserPinPorts;\n #wrap = el('div', { className: 'wrap', hidden: true });\n #button = el('button', { type: 'button' });\n #status = el('p', { className: 'status' });\n #pinned = false;\n #busy = false;\n #generation = 0;\n #onConnection = (): void => this.#render();\n\n constructor() {\n super();\n const root = this.attachShadow({ mode: 'open' });\n root.adoptedStyleSheets = [TOGGLE_CSS];\n this.#button.append(el('span', { textContent: '⤓ ', ariaHidden: 'true' }), 'Make available offline');\n this.#status.setAttribute('role', 'status');\n this.#wrap.append(this.#button, this.#status);\n root.append(this.#wrap);\n this.#button.addEventListener('click', () => void this.#toggle());\n }\n\n get #gameId(): string {\n return this.getAttribute('game-id') ?? '';\n }\n\n get #gameName(): string {\n return this.getAttribute('game-name') ?? 'This game';\n }\n\n connectedCallback(): void {\n addEventListener('online', this.#onConnection);\n addEventListener('offline', this.#onConnection);\n void this.#load();\n }\n\n disconnectedCallback(): void {\n removeEventListener('online', this.#onConnection);\n removeEventListener('offline', this.#onConnection);\n this.#generation += 1;\n }\n\n attributeChangedCallback(): void {\n if (this.isConnected) void this.#load();\n }\n\n async #load(): Promise<void> {\n const generation = ++this.#generation;\n this.#wrap.hidden = true;\n if (!canPin() || this.#gameId === '') return;\n // Resolves once a worker is active; never, when the kill switch kept it from registering.\n await navigator.serviceWorker.ready;\n const record = await pinStatus(this.#gameId, this.ports()).catch(() => null);\n if (generation !== this.#generation) return;\n this.#pinned = record !== null;\n this.#status.textContent =\n record === null ? '' : `${this.#gameName} plays offline on this device (${formatBytes(record.totalBytes)}).`;\n this.#wrap.hidden = false;\n this.#render();\n }\n\n #render(): void {\n this.#button.setAttribute('aria-pressed', String(this.#pinned));\n const offline = !navigator.onLine && !this.#pinned;\n this.#button.disabled = this.#busy || offline;\n if (offline && !this.#busy)\n this.#status.textContent = 'Connect to the internet to make this game available offline.';\n }\n\n async #toggle(): Promise<void> {\n if (this.#busy) return;\n const generation = this.#generation;\n const id = this.#gameId;\n const name = this.#gameName;\n this.#busy = true;\n this.#render();\n if (this.#pinned) {\n await unpinGame(id, this.ports());\n this.#pinned = false;\n this.#status.textContent = `Removed ${name} from this device.`;\n } else {\n this.#status.textContent = `Saving ${name} for offline play…`;\n const result = await pinGame(\n { gameId: id, assets: this.getAttribute('assets') ?? `/games/${id}/manifest.json` },\n this.ports(),\n );\n if (result.ok) {\n this.#pinned = true;\n this.#status.textContent = `${name} plays offline on this device (${formatBytes(result.value.record.totalBytes)}).`;\n } else {\n this.#status.textContent = result.error.message;\n }\n }\n this.#busy = false;\n dispatchEvent(new Event(PIN_CHANGED));\n if (generation === this.#generation) this.#render();\n }\n}\n\n/** Registers the elements once. */\nexport const definePwaElements = (): void => {\n if (customElements.get('jy-update-toast') === undefined) customElements.define('jy-update-toast', UpdateToast);\n if (customElements.get('jy-security-update') === undefined)\n customElements.define('jy-security-update', SecurityUpdate);\n if (customElements.get('jy-offline-toggle') === undefined) customElements.define('jy-offline-toggle', OfflineToggle);\n};\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'jy-update-toast': UpdateToast;\n 'jy-security-update': SecurityUpdate;\n 'jy-offline-toggle': OfflineToggle;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import { type Control } from '../control.js';
2
+ /** How the page shows the notice. The default is `<jy-security-update>`. */
3
+ export interface SecurityNotice {
4
+ /** Shows the blocking notice; `update` runs when the player chooses Update now. */
5
+ show(update: () => void): void;
6
+ }
7
+ export type ForcedUpdateOptions = {
8
+ /** This page's release time (`__JOYSTYK_RELEASE_TIME__`). */
9
+ readonly releaseTime: string;
10
+ /** Saves the game in play: the launcher's `requestSave`. Without it, nothing is saved. */
11
+ readonly saveGame?: () => Promise<void>;
12
+ /** Brings in the new release's worker before the reload, when there is one. */
13
+ readonly prepare?: () => Promise<void>;
14
+ /** How often to read the flag file. Default 15 minutes. */
15
+ readonly checkMs?: number;
16
+ readonly notice?: SecurityNotice;
17
+ /** Reads the flag file; swapped in tests. */
18
+ readonly read?: () => Promise<Control | null>;
19
+ /** Reloads the page; swapped in tests. */
20
+ readonly reload?: () => void;
21
+ };
22
+ export interface ForcedUpdates {
23
+ /** Reads the flag file now and acts on it; resolves with what it said. */
24
+ check(): Promise<Control | null>;
25
+ /** Acts on a flag file read elsewhere. */
26
+ observe(control: Control | null): void;
27
+ /** True once the notice is up. */
28
+ readonly forcing: boolean;
29
+ dispose(): void;
30
+ }
31
+ /** The default notice: `<jy-security-update>` at the end of the body. */
32
+ export declare const securityNotice: () => SecurityNotice;
33
+ export declare const watchForcedUpdates: (options: ForcedUpdateOptions) => ForcedUpdates;