feat(icons): render the app icons from the Movie Loop mark
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 7s
frontend-ci / fs-scan (push) Successful in 14s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / lint (push) Successful in 26s
frontend-ci / test (push) Successful in 32s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / lint (pull_request) Successful in 24s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / secrets-scan (pull_request) Successful in 5s
frontend-ci / sast (pull_request) Successful in 8s
frontend-ci / fs-scan (pull_request) Successful in 15s
frontend-ci / typecheck (pull_request) Successful in 14s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped
Safari, the iOS home screen, Android install and push notifications still showed the old film-reel artwork beside the new theme colour. The PNGs are now rendered from the SVG mark by `npm run icons` (Inkscape, no image library in node_modules): the bare mark for the 32px favicon, the mark on the stage colour for app icons, and a maskable variant with the extra margin Android's adaptive crop needs.
No files matched your search
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.3.0] - 2026-09-12
|
||||||
|
|
||||||
A look-and-feel pass. The app now has one visual identity - Movie Loop - with
|
A look-and-feel pass. The app now has one visual identity - Movie Loop - with
|
||||||
|
|||||||
@@ -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 test:cov # what CI runs: fails below 90% lines/branches/functions/statements
|
||||||
npm run build # tsc -b && vite build -> dist/
|
npm run build # tsc -b && vite build -> dist/
|
||||||
npm run preview # serve dist/ locally
|
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`.
|
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
|
└── App.tsx routes (lazy) + Suspense
|
||||||
public/
|
public/
|
||||||
├── sw.js service worker: push display + notification click
|
├── 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/
|
└── 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.
|
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
|
### 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.
|
`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.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-loop-frontend",
|
"name": "movie-loop-frontend",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "movie-loop-frontend",
|
"name": "movie-loop-frontend",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "movie-loop-frontend",
|
"name": "movie-loop-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Kevin Riehl",
|
"author": "Kevin Riehl",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:cov": "vitest run --coverage",
|
"test:cov": "vitest run --coverage",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"icons": "node scripts/make-icons.mjs",
|
||||||
"release": "commit-and-tag-version",
|
"release": "commit-and-tag-version",
|
||||||
"release:minor": "commit-and-tag-version --release-as minor",
|
"release:minor": "commit-and-tag-version --release-as minor",
|
||||||
"release:major": "commit-and-tag-version --release-as major",
|
"release:major": "commit-and-tag-version --release-as major",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 12 KiB |
@@ -15,6 +15,12 @@
|
|||||||
"src": "/icon-512.png",
|
"src": "/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#111524"/>
|
||||||
|
<g transform="translate(138.00 138.00) scale(9.8333)" fill="none" stroke="#da950b" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M20.5 12a8.5 8.5 0 1 1-3.63-6.96"/>
|
||||||
|
<circle cx="19.54" cy="8.08" r="1.75" fill="#da950b" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 402 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#111524"/>
|
||||||
|
<g transform="translate(106.00 106.00) scale(12.5000)" fill="none" stroke="#da950b" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M20.5 12a8.5 8.5 0 1 1-3.63-6.96"/>
|
||||||
|
<circle cx="19.54" cy="8.08" r="1.75" fill="#da950b" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||