fuzzyworld

nuts / commit

added snapshots

88e406aa9bb7978c63c317a5d2e09f075de14eec · Pavel Safronov · 2026-04-12 22:57:43 -0700 · parents 1f6f4741d372

added snapshots
 .gitignore                       |   3 +
 package.json                     |   9 ++-
 pnpm-lock.yaml                   |  71 +++++++++++++++++
 screenshots/golden/composite.png | Bin 0 -> 817415 bytes
 screenshots/golden/gallery.png   | Bin 0 -> 445950 bytes
 screenshots/golden/settings.png  | Bin 0 -> 38270 bytes
 screenshots/golden/ui.png        | Bin 0 -> 23930 bytes
 scripts/composite.ts             | 114 +++++++++++++++++++++++++++
 scripts/diff.ts                  | 111 +++++++++++++++++++++++++++
 scripts/screenshot.ts            | 133 ++++++++++++++++++++++++++++++++
 src/gallery.ts                   |  26 +++++++
 src/main.ts                      |  10 ++-
 src/renderer.ts                  | 162 +++++++++++++++++++++++++++++++++++++++
 13 files changed, 637 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3d79ca0..970eb37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
 node_modules
 dist
 kenney_impact-sounds
+screenshots/*
+!screenshots/golden/
+!screenshots/golden/**
diff --git a/package.json b/package.json
index 4748406..541c631 100644
--- a/package.json
+++ b/package.json
@@ -8,18 +8,25 @@
         "dev": "vite",
         "build": "tsc && vite build",
         "preview": "vite preview",
-        "generate-icons": "tsx scripts/generate-icons.ts"
+        "generate-icons": "tsx scripts/generate-icons.ts",
+        "screenshot": "tsx scripts/screenshot.ts",
+        "diff-screenshots": "tsx scripts/diff.ts"
     },
     "dependencies": {
         "matter-js": "^0.19.0",
         "qrcode": "^1.5.4"
     },
     "devDependencies": {
+        "@playwright/test": "^1.59.1",
         "@types/matter-js": "^0.19.6",
         "@types/node": "^25.6.0",
+        "@types/pngjs": "^6.0.5",
         "@types/qrcode": "^1.5.6",
         "@vitejs/plugin-basic-ssl": "^2.3.0",
         "ffmpeg-static": "^5.3.0",
+        "pixelmatch": "^7.1.0",
+        "playwright": "^1.59.1",
+        "pngjs": "^7.0.0",
         "sharp": "^0.34.5",
         "tsx": "^4.21.0",
         "typescript": "^5.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fdca09a..36f88cb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,12 +15,18 @@ importers:
         specifier: ^1.5.4
         version: 1.5.4
     devDependencies:
+      '@playwright/test':
+        specifier: ^1.59.1
+        version: 1.59.1
       '@types/matter-js':
         specifier: ^0.19.6
         version: 0.19.8
       '@types/node':
         specifier: ^25.6.0
         version: 25.6.0
+      '@types/pngjs':
+        specifier: ^6.0.5
+        version: 6.0.5
       '@types/qrcode':
         specifier: ^1.5.6
         version: 1.5.6
@@ -30,6 +36,15 @@ importers:
       ffmpeg-static:
         specifier: ^5.3.0
         version: 5.3.0
+      pixelmatch:
+        specifier: ^7.1.0
+        version: 7.1.0
+      playwright:
+        specifier: ^1.59.1
+        version: 1.59.1
+      pngjs:
+        specifier: ^7.0.0
+        version: 7.0.0
       sharp:
         specifier: ^0.34.5
         version: 0.34.5
@@ -486,6 +501,11 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@playwright/test@1.59.1':
+    resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   '@rollup/rollup-android-arm-eabi@4.60.1':
     resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
     cpu: [arm]
@@ -623,6 +643,9 @@ packages:
   '@types/node@25.6.0':
     resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
 
+  '@types/pngjs@6.0.5':
+    resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
+
   '@types/qrcode@1.5.6':
     resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
 
@@ -713,6 +736,11 @@ packages:
     resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
     engines: {node: '>=8'}
 
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -776,10 +804,28 @@ packages:
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
+  pixelmatch@7.1.0:
+    resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
+    hasBin: true
+
+  playwright-core@1.59.1:
+    resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.59.1:
+    resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   pngjs@5.0.0:
     resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
     engines: {node: '>=10.13.0'}
 
+  pngjs@7.0.0:
+    resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+    engines: {node: '>=14.19.0'}
+
   postcss@8.5.9:
     resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
     engines: {node: ^10 || ^12 || >=14}
@@ -1178,6 +1224,10 @@ snapshots:
   '@img/sharp-win32-x64@0.34.5':
     optional: true
 
+  '@playwright/test@1.59.1':
+    dependencies:
+      playwright: 1.59.1
+
   '@rollup/rollup-android-arm-eabi@4.60.1':
     optional: true
 
@@ -1263,6 +1313,10 @@ snapshots:
     dependencies:
       undici-types: 7.19.2
 
+  '@types/pngjs@6.0.5':
+    dependencies:
+      '@types/node': 25.6.0
+
   '@types/qrcode@1.5.6':
     dependencies:
       '@types/node': 25.6.0
@@ -1391,6 +1445,9 @@ snapshots:
       locate-path: 5.0.0
       path-exists: 4.0.0
 
+  fsevents@2.3.2:
+    optional: true
+
   fsevents@2.3.3:
     optional: true
 
@@ -1441,8 +1498,22 @@ snapshots:
 
   picocolors@1.1.1: {}
 
+  pixelmatch@7.1.0:
+    dependencies:
+      pngjs: 7.0.0
+
+  playwright-core@1.59.1: {}
+
+  playwright@1.59.1:
+    dependencies:
+      playwright-core: 1.59.1
+    optionalDependencies:
+      fsevents: 2.3.2
+
   pngjs@5.0.0: {}
 
+  pngjs@7.0.0: {}
+
   postcss@8.5.9:
     dependencies:
       nanoid: 3.3.11
diff --git a/screenshots/golden/composite.png b/screenshots/golden/composite.png
new file mode 100644
index 0000000..82fc269
Binary files /dev/null and b/screenshots/golden/composite.png differ
diff --git a/screenshots/golden/gallery.png b/screenshots/golden/gallery.png
new file mode 100644
index 0000000..c0f9a6b
Binary files /dev/null and b/screenshots/golden/gallery.png differ
diff --git a/screenshots/golden/settings.png b/screenshots/golden/settings.png
new file mode 100644
index 0000000..ea09240
Binary files /dev/null and b/screenshots/golden/settings.png differ
diff --git a/screenshots/golden/ui.png b/screenshots/golden/ui.png
new file mode 100644
index 0000000..a822623
Binary files /dev/null and b/screenshots/golden/ui.png differ
diff --git a/scripts/composite.ts b/scripts/composite.ts
new file mode 100644
index 0000000..bafb5ee
--- /dev/null
+++ b/scripts/composite.ts
@@ -0,0 +1,114 @@
+/**
+ * scripts/composite.ts
+ *
+ * Stitches gallery.png + ui.png + settings.png into a single annotated
+ * composite sheet saved as screenshots/composite.png.
+ *
+ * Layout:
+ *   ┌─────────── gallery ──────────────┐
+ *   │                                  │
+ *   ├──── ui ─────┬──── settings ──────┤
+ *   │             │                    │
+ *   └─────────────┴────────────────────┘
+ *
+ * Also optionally copies to screenshots/golden/composite.png.
+ */
+
+import sharp from 'sharp'
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url))
+const OUT_DIR = join(ROOT, 'screenshots')
+const GOLDEN_DIR = join(OUT_DIR, 'golden')
+
+const BG = { r: 13, g: 13, b: 13 }   // #0d0d0d
+const PAD = 28                         // outer padding
+const GAP = 16                         // gap between sections / columns
+const LABEL_H = 32                     // height of each section label bar
+
+function labelSvg(w: number, text: string): Buffer {
+    const svg = `<svg width="${w}" height="${LABEL_H}" xmlns="http://www.w3.org/2000/svg">
+  <rect width="${w}" height="${LABEL_H}" fill="#1e1e1e" rx="4"/>
+  <text x="${w / 2}" y="${Math.round(LABEL_H * 0.72)}"
+        text-anchor="middle"
+        font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
+        font-size="14" fill="#888888">${text}</text>
+</svg>`
+    return Buffer.from(svg)
+}
+
+async function meta(file: string): Promise<{ w: number; h: number }> {
+    const m = await sharp(file).metadata()
+    return { w: m.width!, h: m.height! }
+}
+
+export async function makeComposite(update = false): Promise<string> {
+    const galleryFile = join(OUT_DIR, 'gallery.png')
+    const uiFile = join(OUT_DIR, 'ui.png')
+    const settingsFile = join(OUT_DIR, 'settings.png')
+
+    for (const f of [galleryFile, uiFile, settingsFile]) {
+        if (!existsSync(f)) throw new Error(`Missing screenshot: ${f}`)
+    }
+
+    const [g, u, s] = await Promise.all([
+        meta(galleryFile),
+        meta(uiFile),
+        meta(settingsFile),
+    ])
+
+    // ── Compute layout ────────────────────────────────────────────────────
+    // Both sections (gallery row, pair row) may have different widths.
+    // The composite width is the wider of the two, plus outer padding.
+    const pairInnerW = u.w + GAP + s.w
+    const innerW = Math.max(g.w, pairInnerW)
+    const totalW = innerW + PAD * 2
+
+    // Horizontal offsets (center each section within innerW)
+    const gLeft = PAD + Math.floor((innerW - g.w) / 2)
+    const pairLeft = PAD + Math.floor((innerW - pairInnerW) / 2)
+    const uLeft = pairLeft
+    const sLeft = pairLeft + u.w + GAP
+
+    // Vertical positions
+    let y = PAD
+    const gLabelTop = y; y += LABEL_H
+    const gTop = y; y += g.h + GAP
+    const pairLabelTopU = y
+    const pairLabelTopS = y; y += LABEL_H
+    const pairTop = y; y += Math.max(u.h, s.h)
+    const totalH = y + PAD
+
+    // ── Build composite layers ────────────────────────────────────────────
+    const layers: sharp.OverlayOptions[] = [
+        // Gallery label
+        { input: labelSvg(g.w, 'gallery'), left: gLeft, top: gLabelTop },
+        // Gallery image
+        { input: galleryFile, left: gLeft, top: gTop },
+        // UI label
+        { input: labelSvg(u.w, 'ui'), left: uLeft, top: pairLabelTopU },
+        // Settings label
+        { input: labelSvg(s.w, 'settings'), left: sLeft, top: pairLabelTopS },
+        // UI image
+        { input: uiFile, left: uLeft, top: pairTop },
+        // Settings image
+        { input: settingsFile, left: sLeft, top: pairTop },
+    ]
+
+    const outFile = join(OUT_DIR, 'composite.png')
+    await sharp({
+        create: { width: totalW, height: totalH, channels: 3, background: BG },
+    })
+        .composite(layers)
+        .png({ compressionLevel: 6 })
+        .toFile(outFile)
+
+    if (update) {
+        mkdirSync(GOLDEN_DIR, { recursive: true })
+        writeFileSync(join(GOLDEN_DIR, 'composite.png'), readFileSync(outFile))
+    }
+
+    return outFile
+}
diff --git a/scripts/diff.ts b/scripts/diff.ts
new file mode 100644
index 0000000..8244f4c
--- /dev/null
+++ b/scripts/diff.ts
@@ -0,0 +1,111 @@
+/**
+ * scripts/diff.ts
+ *
+ * Compares screenshots/composite.png against screenshots/golden/composite.png
+ * using pixelmatch.
+ *
+ * Exit codes:
+ *   0 — images match (within threshold)
+ *   1 — images differ (above threshold) — diff saved to screenshots/diff.png
+ *   2 — golden file missing (run `pnpm screenshot --update` first)
+ *
+ * Usage (standalone):
+ *   pnpm diff-screenshots
+ *
+ * The screenshot.ts script calls compareWithGolden() directly.
+ */
+
+import pixelmatch from 'pixelmatch'
+import { PNG } from 'pngjs'
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url))
+const OUT_DIR = join(ROOT, 'screenshots')
+const DIFF_FILE = join(OUT_DIR, 'diff.png')
+
+/** Fraction of pixels that must change before we consider the images different. */
+const THRESHOLD = 0.005   // 0.5 %
+
+/**
+ * Compare `composite.png` against the golden copy.
+ * Returns `null` when there is no golden to compare against.
+ * Otherwise returns an object with the diff result.
+ */
+export async function compareWithGolden(): Promise<{
+    changed: number
+    total: number
+    pct: number
+    pass: boolean
+    diffFile: string | null
+} | null> {
+    const current = join(OUT_DIR, 'composite.png')
+    const golden = join(OUT_DIR, 'golden', 'composite.png')
+
+    if (!existsSync(golden)) return null
+    if (!existsSync(current)) throw new Error(`Missing composite: ${current}`)
+
+    const imgA = PNG.sync.read(readFileSync(golden))
+    const imgB = PNG.sync.read(readFileSync(current))
+
+    // If dimensions differ, treat every pixel as changed
+    if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
+        const total = Math.max(imgA.width * imgA.height, imgB.width * imgB.height)
+        return { changed: total, total, pct: 1, pass: false, diffFile: null }
+    }
+
+    const { width, height } = imgA
+    const diff = new PNG({ width, height })
+
+    const changed = pixelmatch(
+        imgA.data, imgB.data, diff.data,
+        width, height,
+        { threshold: 0.1, alpha: 0.3 },
+    )
+
+    const total = width * height
+    const pct = changed / total
+    const pass = pct <= THRESHOLD
+
+    let diffFile: string | null = null
+    if (!pass) {
+        writeFileSync(DIFF_FILE, PNG.sync.write(diff))
+        diffFile = DIFF_FILE
+    }
+
+    return { changed, total, pct, pass, diffFile }
+}
+
+// ── Run standalone ────────────────────────────────────────────────────────
+// Only execute when this file is the entry point, not when imported as a module.
+const isMain = process.argv[1] &&
+    (process.argv[1].endsWith('diff.ts') || process.argv[1].endsWith('diff.js'))
+
+if (!isMain) { /* imported — skip standalone logic */ }
+else {
+
+    const result = await compareWithGolden()
+
+    if (result === null) {
+        console.log('No golden composite found.')
+        console.log('Run `pnpm screenshot --update` to create one.')
+        process.exit(2)
+    }
+
+    const pctStr = (result.pct * 100).toFixed(2)
+    const thStr = (THRESHOLD * 100).toFixed(1)
+
+    if (result.pass) {
+        console.log(`✓ Images match (${pctStr}% changed, threshold ${thStr}%)`)
+        process.exit(0)
+    } else {
+        console.error(`✗ Images differ: ${pctStr}% of pixels changed (threshold ${thStr}%)`)
+        if (result.diffFile) {
+            console.error(`  Diff saved to ${result.diffFile}`)
+        }
+        console.error('  Run `pnpm screenshot --update` to accept the new output as golden.')
+        process.exit(1)
+    }
+
+} // end isMain
diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts
new file mode 100644
index 0000000..310c6cf
--- /dev/null
+++ b/scripts/screenshot.ts
@@ -0,0 +1,133 @@
+/**
+ * scripts/screenshot.ts
+ *
+ * Takes screenshots of the running Vite dev server and saves them to
+ * screenshots/  (gitignored by default — golden files are checked in separately).
+ *
+ * Usage:
+ *   pnpm screenshot          # takes screenshots, composites, diffs against golden
+ *   pnpm screenshot --update # promote existing screenshots/composite to golden (no re-run)
+ *
+ * Shots taken:
+ *   gallery.png  — full gallery canvas (?gallery mode)
+ *   ui.png       — default app UI, no items
+ *   settings.png — settings panel open
+ */
+
+import { chromium } from 'playwright'
+import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { createServer } from 'vite'
+import { fileURLToPath } from 'node:url'
+import { makeComposite } from './composite.ts'
+import { compareWithGolden } from './diff.ts'
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url))
+const OUT_DIR = join(ROOT, 'screenshots')
+const GOLDEN_DIR = join(ROOT, 'screenshots', 'golden')
+const UPDATE_GOLDEN = process.argv.includes('--update')
+
+mkdirSync(OUT_DIR, { recursive: true })
+mkdirSync(GOLDEN_DIR, { recursive: true })
+
+// ── --update: promote existing screenshots to golden without re-running ───
+if (UPDATE_GOLDEN) {
+    const names = ['gallery', 'ui', 'settings']
+    for (const name of names) {
+        const src = join(OUT_DIR, `${name}.png`)
+        if (!existsSync(src)) {
+            console.error(`Missing ${name}.png — run \`pnpm screenshot\` first.`)
+            process.exit(1)
+        }
+        writeFileSync(join(GOLDEN_DIR, `${name}.png`), readFileSync(src))
+        console.log(`  ✓ ${name}.png → golden`)
+    }
+    console.log('\nBuilding composite…')
+    await makeComposite(true)
+    console.log('  ✓ composite.png → golden')
+    process.exit(0)
+}
+
+// ── Start Vite in-process ─────────────────────────────────────────────────
+console.log('Starting Vite dev server…')
+const server = await createServer({ root: ROOT, server: { port: 5174, strictPort: true } })
+await server.listen()
+// Detect https vs http (basicSsl plugin makes it https)
+const protocol = (server.config.server.https) ? 'https' : 'http'
+const BASE = `${protocol}://localhost:5174`
+console.log(`  ${BASE}`)
+
+// Give the dev server a moment to fully warm up (transforms, etc.)
+await new Promise(r => setTimeout(r, 1500))
+
+// ── Launch Chromium ───────────────────────────────────────────────────────
+const browser = await chromium.launch()
+const context = await browser.newContext({ ignoreHTTPSErrors: true })
+
+async function shot(name: string, url: string, setup?: (page: import('playwright').Page) => Promise<void>): Promise<void> {
+    const page = await context.newPage()
+    await page.setViewportSize({ width: 390, height: 844 })  // iPhone 14 size
+    await page.goto(url)
+    if (setup) await setup(page)
+    const dest = join(OUT_DIR, `${name}.png`)
+    await page.screenshot({ path: dest, fullPage: true })
+    console.log(`  ✓ ${name}.png`)
+    await page.close()
+}
+
+// ── Gallery ───────────────────────────────────────────────────────────────
+await shot('gallery', `${BASE}/?gallery`, async (page) => {
+    // Wait for the gallery to finish rendering
+    await page.waitForSelector('[data-gallery-ready="true"]', { timeout: 10_000 })
+    // Fit the viewport to the canvas size
+    const size = await page.evaluate(() => {
+        const c = document.getElementById('canvas') as HTMLCanvasElement
+        return { width: c.offsetWidth, height: c.offsetHeight }
+    })
+    await page.setViewportSize({ width: size.width, height: size.height })
+})
+
+// ── Default UI (no items) ─────────────────────────────────────────────────
+await shot('ui', `${BASE}/?screenshot`, async (page) => {
+    // On desktop/headless, the app auto-starts after 400 ms (no touch)
+    await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 })
+})
+
+// ── Settings panel ────────────────────────────────────────────────────────
+await shot('settings', `${BASE}/?screenshot`, async (page) => {
+    await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 })
+    await page.locator('#settings-btn').click()
+    await page.waitForSelector('#settings-panel:not([hidden])', { timeout: 5_000 })
+})
+
+// ── Composite ────────────────────────────────────────────────────────────
+console.log('\nBuilding composite…')
+await makeComposite(false)
+console.log('  ✓ composite.png')
+
+// ── Diff against golden ───────────────────────────────────────────────────
+{
+    const diff = await compareWithGolden()
+    if (diff === null) {
+        console.log('\n  No golden yet — run with --update to create one.')
+    } else {
+        const pct = (diff.pct * 100).toFixed(2)
+        if (diff.pass) {
+            console.log(`\n  ✓ Matches golden (${pct}% changed)`)
+        } else {
+            console.error(`\n  ✗ Differs from golden: ${pct}% changed`)
+            if (diff.diffFile) console.error(`    Diff image: ${diff.diffFile}`)
+            console.error('    Run with --update to accept as new golden.')
+            // Don't exit(1) here — screenshots are still saved.
+            // CI can run `pnpm diff-screenshots` separately for a hard fail.
+        }
+    }
+}
+
+// ── Cleanup ───────────────────────────────────────────────────────────────
+await context.close()
+await browser.close()
+await server.close()
+
+console.log(`\nScreenshots saved to ${OUT_DIR}`)
+console.log('Run with --update to promote to golden.')
diff --git a/src/gallery.ts b/src/gallery.ts
new file mode 100644
index 0000000..0e44234
--- /dev/null
+++ b/src/gallery.ts
@@ -0,0 +1,26 @@
+// Gallery mode — rendered when ?gallery is present in the URL.
+// Mounts a single <canvas> with all item variants drawn statically.
+// No physics, no UI. Used by the screenshot script in scripts/screenshot.ts.
+
+import { renderGallery } from './renderer'
+
+export function runGallery(): void {
+    document.body.style.cssText = 'margin:0;padding:0;background:#1a1a1a;overflow:auto'
+
+    // Hide all UI overlays so only the canvas shows
+    for (const id of ['start-overlay', 'fullscreen-overlay', 'toolbar', 'settings-panel']) {
+        const el = document.getElementById(id)
+        if (el) { el.hidden = true; (el as HTMLElement).style.display = 'none' }
+    }
+
+    const canvas = document.getElementById('canvas') as HTMLCanvasElement
+    renderGallery(canvas)
+
+    // Stretch the <canvas> element to its natural pixel size so the screenshot
+    // tool captures it without scaling artifacts
+    canvas.style.width = `${canvas.width}px`
+    canvas.style.height = `${canvas.height}px`
+
+    // Signal to Playwright that the gallery is ready
+    document.body.setAttribute('data-gallery-ready', 'true')
+}
diff --git a/src/main.ts b/src/main.ts
index 1542fff..44a683e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -13,11 +13,19 @@ import {
 } from './persistence'
 import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share'
 
+// ── Gallery shortcut ───────────────────────────────────────────────────────
+if (new URLSearchParams(window.location.search).has('gallery')) {
+    const { runGallery } = await import('./gallery')
+    runGallery()
+    throw new Error('gallery mode — halting normal init')
+}
+
 // ── Bootstrap ──────────────────────────────────────────────────────────────
 
 async function main(): Promise<void> {
+    const params = new URLSearchParams(window.location.search)
     const compat = checkCompat()
-    if (!compat.ok) {
+    if (!compat.ok && !params.has('screenshot')) {
         showUnsupportedWall(compat.reason)
         return
     }
diff --git a/src/renderer.ts b/src/renderer.ts
index cb1c34f..3cc0d21 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1165,3 +1165,165 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
 
     return { render, setShareURL, resize }
 }
+
+// ── Gallery renderer ───────────────────────────────────────────────────────
+// Draws a static grid of every item/variant onto a <canvas> with labels.
+// Called by gallery.ts when ?gallery is in the URL.
+
+import {
+    GEM_VARIANT_COUNT, BALLOON_COLOR_COUNT, STRESSBALL_COLOR_COUNT,
+    COIN_MATERIAL_COUNT,
+} from './physics'
+
+const GALLERY_RADIUS = 52
+const GALLERY_CELL = GALLERY_RADIUS * 2 + 32   // padding around each cell
+const GALLERY_LABEL_H = 18
+const GALLERY_ROW_H = GALLERY_CELL + GALLERY_LABEL_H + 8
+const GALLERY_BG = '#1a1a1a'
+const GALLERY_FG = '#e0e0e0'
+
+interface GalleryRow {
+    kind: string
+    label: string
+    count: number
+    /** Draw variant i at centre (cx,cy) on ctx. */
+    draw: (ctx: CanvasRenderingContext2D, cx: number, cy: number, variant: number) => void
+}
+
+function galleryRows(): GalleryRow[] {
+    const r = GALLERY_RADIUS
+    return [
+        {
+            kind: 'nut', label: 'Nut', count: PALETTE.length,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getSprite(r, i, 'nut', 0)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+            },
+        },
+        {
+            kind: 'gem', label: 'Gem', count: GEM_VARIANT_COUNT,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getSprite(r, 0, 'gem', i)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+            },
+        },
+        {
+            kind: 'metalnut', label: 'Metal Nut', count: METAL_PALETTE.length,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getSprite(r, i, 'metalnut', 0)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+            },
+        },
+        {
+            kind: 'balloon', label: 'Balloon', count: BALLOON_COLOR_COUNT,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getBalloonBodySprite(r, i)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+                // knot + string
+                const bc = BALLOON_COLORS[i % BALLOON_COLORS.length]
+                const bknotH = Math.max(4, r * 0.13)
+                const bstringLen = r * 0.9
+                ctx.save()
+                ctx.translate(cx, cy)
+                ctx.beginPath()
+                ctx.moveTo(0, r + bknotH * 1.6)
+                ctx.lineTo(-bknotH * 0.55, r)
+                ctx.lineTo(bknotH * 0.55, r)
+                ctx.closePath()
+                ctx.fillStyle = bc.rim; ctx.fill()
+                ctx.beginPath()
+                ctx.moveTo(0, r + bknotH * 1.6)
+                ctx.quadraticCurveTo(r * 0.18, r + bstringLen * 0.5, r * 0.06, r + bstringLen + bknotH * 1.6)
+                ctx.strokeStyle = bc.string; ctx.lineWidth = Math.max(1, r * 0.025)
+                ctx.globalAlpha *= 0.75; ctx.stroke()
+                ctx.restore()
+            },
+        },
+        {
+            kind: 'stressball', label: 'Stress Ball', count: STRESSBALL_COLOR_COUNT,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getStressBallSprite(r, i)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+            },
+        },
+        {
+            kind: 'rock', label: 'Rock', count: ROCK_TEMPLATES.length,
+            draw: (ctx, cx, cy, i) => {
+                const tmpl = ROCK_TEMPLATES[i % ROCK_TEMPLATES.length]
+                // Build a fake Matter.Body from normVerts scaled to radius
+                const verts = tmpl.normVerts.map(v => ({ x: cx + v.x * r, y: cy + v.y * r }))
+                const fakeBody = {
+                    position: { x: cx, y: cy },
+                    angle: 0,
+                    vertices: verts,
+                    speed: 0,
+                } as unknown as Matter.Body
+                drawRock(ctx, fakeBody, r, i % 6, 0, i % ROCK_TEMPLATES.length)
+            },
+        },
+        {
+            kind: 'coin', label: 'Coin', count: COIN_MATERIAL_COUNT,
+            draw: (ctx, cx, cy, i) => {
+                const sp = getCoinHeadsSprite(r, i)
+                ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
+            },
+        },
+    ]
+}
+
+export function renderGallery(canvas: HTMLCanvasElement): void {
+    const rows = galleryRows()
+    const maxCount = Math.max(...rows.map(r => r.count))
+    const cols = maxCount
+
+    const SECTION_LABEL_H = 28
+    const PAD_LEFT = 110  // space for row labels on the left
+
+    const totalW = PAD_LEFT + cols * GALLERY_CELL + 16
+    const totalH = rows.length * (GALLERY_ROW_H + SECTION_LABEL_H) + 32
+
+    canvas.width = totalW
+    canvas.height = totalH
+    const ctx = canvas.getContext('2d')!
+
+    ctx.fillStyle = GALLERY_BG
+    ctx.fillRect(0, 0, totalW, totalH)
+
+    let y = 16
+
+    for (const row of rows) {
+        // Section divider
+        ctx.fillStyle = '#ffffff18'
+        ctx.fillRect(0, y - 4, totalW, 1)
+
+        // Row label (left column)
+        ctx.fillStyle = GALLERY_FG
+        ctx.font = 'bold 14px system-ui, sans-serif'
+        ctx.textAlign = 'left'
+        ctx.textBaseline = 'middle'
+        ctx.fillText(row.label, 8, y + GALLERY_ROW_H / 2)
+
+        // Variants
+        for (let i = 0; i < row.count; i++) {
+            const cx = PAD_LEFT + i * GALLERY_CELL + GALLERY_CELL / 2
+            const cy = y + GALLERY_ROW_H / 2 - GALLERY_LABEL_H / 2
+
+            // Cell background
+            ctx.fillStyle = '#ffffff08'
+            ctx.beginPath()
+            ctx.roundRect(PAD_LEFT + i * GALLERY_CELL + 4, y + 4, GALLERY_CELL - 8, GALLERY_ROW_H - 8, 6)
+            ctx.fill()
+
+            row.draw(ctx, cx, cy, i)
+
+            // Variant index label below item
+            ctx.fillStyle = '#888'
+            ctx.font = '11px system-ui, sans-serif'
+            ctx.textAlign = 'center'
+            ctx.textBaseline = 'alphabetic'
+            ctx.fillText(String(i), cx, y + GALLERY_ROW_H - 6)
+        }
+
+        y += GALLERY_ROW_H + SECTION_LABEL_H
+    }
+}