diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa024f0..adf3a14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.4.0] - 2026-09-12
+
+### Added
+
+- The app icon: Safari, the iOS home screen, Android install and push
+ notifications now show the Movie Loop mark on the stage colour instead of
+ the old film-reel artwork, with a maskable variant so Android no longer
+ letterboxes it. `npm run icons` re-renders every PNG from the SVG sources
+ with Inkscape.
+
## [2.3.0] - 2026-09-12
A look-and-feel pass. The app now has one visual identity - Movie Loop - with
diff --git a/README.md b/README.md
index 011bb62..49f157a 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ npm test # vitest + testing-library, jsdom
npm run test:cov # what CI runs: fails below 90% lines/branches/functions/statements
npm run build # tsc -b && vite build -> dist/
npm run preview # serve dist/ locally
+npm run icons # re-render public/*.png from the SVG mark (needs Inkscape)
```
Coverage thresholds live in `vite.config.ts`; every source file is above them individually. The suite mocks the axios client and fails loudly on any request it did not route, and mocks the socket factory with a controllable fake, so no test can reach a real backend. Pages are rendered with real Zustand stores seeded via `setState`, so the stores' selectors and actions are covered by the page tests as well as their own. One toolchain note: `vi.useFakeTimers()` combined with `@testing-library/user-event` deadlocks under this vitest/jsdom combination — tests that need both use real timers and `waitFor`.
@@ -89,7 +90,7 @@ src/
└── App.tsx routes (lazy) + Suspense
public/
├── sw.js service worker: push display + notification click
-├── manifest.json, favicon.svg (the mark), icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png
+├── manifest.json, favicon.svg (the mark), and the PNG icons rendered from it by scripts/make-icons.mjs
└── sounds/
```
@@ -105,6 +106,8 @@ The look is one system, defined in `src/index.css` and nowhere else.
To add a colour, add a token to both theme blocks and a `--color-*` alias in `@theme inline`; do not reach for a stock Tailwind palette colour in a component.
+The mark lives once as `public/favicon.svg`. The PNGs Safari, iOS, Android and push notifications need are rendered from it and from `scripts/icons/app-icon*.svg` (the mark on the stage colour, inside the maskable safe zone) by `npm run icons`, which drives Inkscape - on PATH, at `INKSCAPE`, or in its default install folder. Edit the SVGs, run it, commit the PNGs.
+
### How a game works, client side
`game-store` owns the session: it creates one on the server, sends each link to `POST /games/:id/validate-link`, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked.
diff --git a/package-lock.json b/package-lock.json
index 50dfaf7..3d0c742 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "movie-loop-frontend",
- "version": "2.3.0",
+ "version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "movie-loop-frontend",
- "version": "2.3.0",
+ "version": "2.4.0",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.2.0",
diff --git a/package.json b/package.json
index 3a83c36..5570ad8 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "movie-loop-frontend",
"private": true,
- "version": "2.3.0",
+ "version": "2.4.0",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
@@ -13,6 +13,7 @@
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"preview": "vite preview",
+ "icons": "node scripts/make-icons.mjs",
"release": "commit-and-tag-version",
"release:minor": "commit-and-tag-version --release-as minor",
"release:major": "commit-and-tag-version --release-as major",
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index f052974..ef8dde1 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
index 0bd1330..30c5d99 100644
Binary files a/public/favicon-32.png and b/public/favicon-32.png differ
diff --git a/public/icon-192.png b/public/icon-192.png
index d120159..cb34605 100644
Binary files a/public/icon-192.png and b/public/icon-192.png differ
diff --git a/public/icon-512-maskable.png b/public/icon-512-maskable.png
new file mode 100644
index 0000000..402b509
Binary files /dev/null and b/public/icon-512-maskable.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
index a45d875..c3f2938 100644
Binary files a/public/icon-512.png and b/public/icon-512.png differ
diff --git a/public/manifest.json b/public/manifest.json
index b42c581..f14a870 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -15,6 +15,12 @@
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
+ },
+ {
+ "src": "/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
}
]
}
diff --git a/scripts/icons/app-icon-maskable.svg b/scripts/icons/app-icon-maskable.svg
new file mode 100644
index 0000000..7b0daed
--- /dev/null
+++ b/scripts/icons/app-icon-maskable.svg
@@ -0,0 +1,7 @@
+
diff --git a/scripts/icons/app-icon.svg b/scripts/icons/app-icon.svg
new file mode 100644
index 0000000..45b7a13
--- /dev/null
+++ b/scripts/icons/app-icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs
new file mode 100644
index 0000000..83b429b
--- /dev/null
+++ b/scripts/make-icons.mjs
@@ -0,0 +1,79 @@
+// Renders the raster icons in public/ from their SVG sources with Inkscape.
+//
+// The browser tab uses public/favicon.svg directly, but Safari, the iOS home
+// screen, Android's PWA install and push notifications all want PNGs, and
+// those can't be transparent (iOS fills a transparent icon with black) or
+// edge-to-edge (Android crops adaptive icons to a circle or squircle). So
+// there are two sources: the bare mark for the 32px favicon, and the mark on
+// the app's stage colour for everything else, sized to sit inside the
+// maskable safe zone.
+//
+// Inkscape is used because it is already a fixture of the design workflow;
+// rendering with it means no image library in node_modules. Its installer
+// does not put it on PATH on Windows, so the standard install folders are
+// tried after PATH and an explicit INKSCAPE environment variable.
+//
+// Usage: node scripts/make-icons.mjs
+// INKSCAPE="C:\path\to\inkscape.com" node scripts/make-icons.mjs
+
+import { execFileSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** Every PNG the app ships, with the SVG it is rendered from. */
+export const ICONS = [
+ { source: 'public/favicon.svg', out: 'public/favicon-32.png', size: 32 },
+ { source: 'scripts/icons/app-icon.svg', out: 'public/apple-touch-icon.png', size: 180 },
+ { source: 'scripts/icons/app-icon.svg', out: 'public/icon-192.png', size: 192 },
+ { source: 'scripts/icons/app-icon.svg', out: 'public/icon-512.png', size: 512 },
+ { source: 'scripts/icons/app-icon-maskable.svg', out: 'public/icon-512-maskable.png', size: 512 },
+];
+
+/** Where Inkscape's installer puts the console binary when it is not on PATH. */
+export const INKSCAPE_CANDIDATES = [
+ 'C:\\Program Files\\Inkscape\\bin\\inkscape.com',
+ 'C:\\Program Files (x86)\\Inkscape\\bin\\inkscape.com',
+ '/Applications/Inkscape.app/Contents/MacOS/inkscape',
+ '/usr/bin/inkscape',
+ '/usr/local/bin/inkscape',
+];
+
+/**
+ * The Inkscape binary to run: the INKSCAPE variable if set, otherwise the
+ * first standard location that exists, otherwise the bare name and PATH.
+ */
+export function findInkscape(env = process.env, exists = existsSync) {
+ if (env.INKSCAPE) return env.INKSCAPE;
+ const found = INKSCAPE_CANDIDATES.find((p) => exists(p));
+ return found ?? 'inkscape';
+}
+
+/** The Inkscape 1.x command line for one PNG. */
+export function exportArgs({ source, out, size }) {
+ return [
+ source,
+ '--export-type=png',
+ `--export-filename=${out}`,
+ `--export-width=${size}`,
+ `--export-height=${size}`,
+ ];
+}
+
+export function makeIcons(root, run = execFileSync) {
+ const inkscape = findInkscape();
+ for (const icon of ICONS) {
+ const args = exportArgs({
+ source: resolve(root, icon.source),
+ out: resolve(root, icon.out),
+ size: icon.size,
+ });
+ run(inkscape, args, { stdio: 'inherit' });
+ console.log(`[icons] ${icon.out} (${icon.size}px) <- ${icon.source}`);
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+ makeIcons(root);
+}
diff --git a/scripts/make-icons.test.ts b/scripts/make-icons.test.ts
new file mode 100644
index 0000000..6da36ca
--- /dev/null
+++ b/scripts/make-icons.test.ts
@@ -0,0 +1,96 @@
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ ICONS,
+ INKSCAPE_CANDIDATES,
+ exportArgs,
+ findInkscape,
+ makeIcons,
+} from './make-icons.mjs';
+
+/**
+ * The PNG icons are rendered from SVG by Inkscape rather than checked in by
+ * hand, so the manifest, the service worker and the HTML can keep pointing at
+ * the same filenames while the mark changes. These tests pin the plan (which
+ * file comes from which source, at what size) and the Inkscape invocation;
+ * they never run Inkscape.
+ */
+
+const ROOT = resolve(__dirname, '..');
+
+describe('ICONS', () => {
+ it('covers every raster icon the app references, from a source that exists', () => {
+ const outs = ICONS.map((i) => i.out);
+ expect(outs).toEqual(
+ expect.arrayContaining([
+ 'public/favicon-32.png',
+ 'public/apple-touch-icon.png',
+ 'public/icon-192.png',
+ 'public/icon-512.png',
+ 'public/icon-512-maskable.png',
+ ]),
+ );
+ for (const icon of ICONS) {
+ expect(existsSync(resolve(ROOT, icon.source)), icon.source).toBe(true);
+ }
+ });
+
+ it('renders the tab favicon from the bare mark and the app icons from the opaque source', () => {
+ const favicon = ICONS.find((i) => i.out === 'public/favicon-32.png');
+ expect(favicon?.source).toBe('public/favicon.svg');
+ expect(favicon?.size).toBe(32);
+ for (const out of ['public/apple-touch-icon.png', 'public/icon-192.png', 'public/icon-512.png']) {
+ expect(ICONS.find((i) => i.out === out)?.source).toBe('scripts/icons/app-icon.svg');
+ }
+ expect(ICONS.find((i) => i.out === 'public/icon-512-maskable.png')?.source).toBe(
+ 'scripts/icons/app-icon-maskable.svg',
+ );
+ });
+});
+
+describe('findInkscape', () => {
+ it('prefers an explicit INKSCAPE variable', () => {
+ expect(findInkscape({ INKSCAPE: 'D:\\tools\\inkscape.com' }, () => true)).toBe(
+ 'D:\\tools\\inkscape.com',
+ );
+ });
+
+ it('falls back to the first standard install location that exists', () => {
+ const second = INKSCAPE_CANDIDATES[1]!;
+ expect(findInkscape({}, (p: string) => p === second)).toBe(second);
+ });
+
+ it('falls back to PATH lookup when nothing is installed in a standard place', () => {
+ expect(findInkscape({}, () => false)).toBe('inkscape');
+ });
+});
+
+describe('exportArgs', () => {
+ it('builds an Inkscape 1.x PNG export at the requested square size', () => {
+ expect(exportArgs({ source: 'in.svg', out: 'out.png', size: 180 })).toEqual([
+ 'in.svg',
+ '--export-type=png',
+ '--export-filename=out.png',
+ '--export-width=180',
+ '--export-height=180',
+ ]);
+ });
+});
+
+describe('makeIcons', () => {
+ it('runs one export per icon, with paths resolved against the project root', () => {
+ const run = vi.fn();
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
+ try {
+ makeIcons(ROOT, run);
+ } finally {
+ log.mockRestore();
+ }
+ expect(run).toHaveBeenCalledTimes(ICONS.length);
+ const [, args, opts] = run.mock.calls[0]!;
+ expect(args[0]).toBe(resolve(ROOT, ICONS[0]!.source));
+ expect(args).toContain(`--export-filename=${resolve(ROOT, ICONS[0]!.out)}`);
+ expect(opts).toEqual({ stdio: 'inherit' });
+ });
+});