fuzzyworld

nuts / commit

Initial commit

44eb58326c20c14ce5868feb936cae70bc4d062c · Pavel Safronov · 2026-04-11 22:25:43 -0700

Initial commit
 .gitignore                  |    2 +
 .gitlab-ci.yml              |   18 +
 README.md                   |   33 ++
 acorn.png                   |  Bin 0 -> 23596 bytes
 index.html                  |   75 +++
 package.json                |   25 +
 pnpm-lock.yaml              | 1225 +++++++++++++++++++++++++++++++++++++++++++
 public/apple-touch-icon.png |  Bin 0 -> 5047 bytes
 public/favicon-16x16.png    |  Bin 0 -> 580 bytes
 public/favicon-32x32.png    |  Bin 0 -> 1560 bytes
 public/icon-192.png         |  Bin 0 -> 5485 bytes
 public/icon-512.png         |  Bin 0 -> 15218 bytes
 public/og-image.png         |  Bin 0 -> 15885 bytes
 public/site.webmanifest     |   21 +
 scripts/generate-icons.ts   |   77 +++
 src/config.ts               |   29 +
 src/main.ts                 |  129 +++++
 src/motion.ts               |   94 ++++
 src/physics.ts              |  131 +++++
 src/renderer.ts             |  166 ++++++
 src/style.css               |  270 ++++++++++
 src/ui.ts                   |   56 ++
 src/vibration.ts            |   21 +
 tsconfig.json               |   22 +
 vite.config.ts              |   13 +
 25 files changed, 2407 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..e0586f0
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,18 @@
+image: node:20-alpine
+
+stages:
+  - deploy
+
+pages:
+  stage: deploy
+  before_script:
+    - npm install -g pnpm
+    - pnpm install --frozen-lockfile
+  script:
+    - pnpm run build
+    - mv dist public
+  artifacts:
+    paths:
+      - public
+  rules:
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4e9a4fc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# nuts
+
+> A phone-focused full-screen toy: a box of hex nuts bouncing around with haptic feedback.
+
+## Features
+
+- **Physics** — [Matter.js](https://brm.io/matter-js/) 2D rigid-body simulation
+- **Haptics** — Web Vibration API fires on every wall impact (intensity scales with collision speed)
+- **Tilt gravity** — Device orientation rotates the gravity vector so nuts roll as you tilt your phone
+- **Shake** — DeviceMotion events detect shakes and punt all nuts with a random impulse + double-vibrate
+- **Configurable** — Slider to set 1–50 nuts; reset positions on demand
+- **Offline-ready** — No server, no data, runs entirely in the browser
+
+## Tech stack
+
+- [pnpm](https://pnpm.io/) + [TypeScript](https://www.typescriptlang.org/)
+- [Vite](https://vitejs.dev/) — dev server and bundler
+- [Matter.js](https://brm.io/matter-js/) — 2D physics engine
+
+## Development
+
+```bash
+pnpm install
+pnpm dev        # http://localhost:5173
+pnpm build      # production bundle → dist/
+pnpm preview    # preview dist/ build
+```
+
+## Deployment
+
+Hosted on GitLab Pages via `.gitlab-ci.yml`. Pushes to the default branch trigger a build and publish `dist/` as the Pages site.
+
+If your project is in a GitLab group/subgroup the Vite `base: './'` config ensures assets resolve correctly under the Pages sub-path.
diff --git a/acorn.png b/acorn.png
new file mode 100644
index 0000000..7eba410
Binary files /dev/null and b/acorn.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f59e007
--- /dev/null
+++ b/index.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="mobile-web-app-capable" content="yes" />
+    <meta name="theme-color" content="#111111" />
+    <title>Nuts</title>
+
+    <!-- Favicons -->
+    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <link rel="manifest" href="/site.webmanifest" />
+
+    <!-- Open Graph -->
+    <meta property="og:title" content="Nuts" />
+    <meta property="og:description" content="A box of nuts bouncing around with haptic feedback." />
+    <meta property="og:image" content="https://pavelsafronov.gitlab.io/nuts/og-image.png" />
+    <meta property="og:url" content="https://pavelsafronov.gitlab.io/nuts/" />
+    <meta name="twitter:card" content="summary_large_image" />
+
+    <link rel="stylesheet" href="/src/style.css" />
+</head>
+
+<body>
+    <canvas id="canvas"></canvas>
+
+    <!-- Tap-to-start overlay (also handles iOS motion permission) -->
+    <div id="start-overlay">
+        <div class="start-content">
+            <img class="nut-icon" src="/icon-192.png" alt="Acorn icon" width="96" height="96" />
+            <h1>Nuts</h1>
+            <p>Tap to begin</p>
+        </div>
+    </div>
+
+    <!-- Settings button -->
+    <button id="settings-btn" aria-label="Settings" hidden>⚙</button>
+
+    <!-- Settings panel -->
+    <div id="settings-panel" hidden>
+        <div class="panel-backdrop"></div>
+        <div class="panel-sheet">
+            <div class="panel-handle"></div>
+            <h2>Settings</h2>
+
+            <label class="setting-row">
+                <span>Nuts</span>
+                <span id="nut-count-display" class="setting-value">20</span>
+            </label>
+            <input type="range" id="nut-count" min="1" max="50" value="20" step="1" />
+
+            <button id="reset-btn" class="action-btn">Reset positions</button>
+            <button id="close-settings" class="close-btn">Done</button>
+
+            <div class="panel-links">
+                <a href="https://pavelsafronov.gitlab.io/nuts/" target="_blank" rel="noopener">Live app</a>
+                <span aria-hidden="true">·</span>
+                <a href="https://gitlab.com/PavelSafronov/nuts" target="_blank" rel="noopener">Source</a>
+            </div>
+            <p class="attribution">
+                Icon by <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard"
+                    target="_blank" rel="noopener">Freepik</a> via Flaticon
+            </p>
+        </div>
+    </div>
+
+    <script type="module" src="/src/main.ts"></script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1dafd7a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+    "name": "nuts",
+    "version": "1.0.0",
+    "private": true,
+    "type": "module",
+    "packageManager": "pnpm@9.15.0",
+    "scripts": {
+        "dev": "vite",
+        "build": "tsc && vite build",
+        "preview": "vite preview",
+        "generate-icons": "tsx scripts/generate-icons.ts"
+    },
+    "dependencies": {
+        "matter-js": "^0.19.0"
+    },
+    "devDependencies": {
+        "@types/matter-js": "^0.19.6",
+        "@types/node": "^25.6.0",
+        "sharp": "^0.34.5",
+        "tsx": "^4.21.0",
+        "typescript": "^5.4.0",
+        "vite": "^5.2.0",
+        "vite-plugin-qrcode": "^0.4.1"
+    }
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..b76ef08
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,1225 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      matter-js:
+        specifier: ^0.19.0
+        version: 0.19.0
+    devDependencies:
+      '@types/matter-js':
+        specifier: ^0.19.6
+        version: 0.19.8
+      '@types/node':
+        specifier: ^25.6.0
+        version: 25.6.0
+      sharp:
+        specifier: ^0.34.5
+        version: 0.34.5
+      tsx:
+        specifier: ^4.21.0
+        version: 4.21.0
+      typescript:
+        specifier: ^5.4.0
+        version: 5.9.3
+      vite:
+        specifier: ^5.2.0
+        version: 5.4.21(@types/node@25.6.0)
+      vite-plugin-qrcode:
+        specifier: ^0.4.1
+        version: 0.4.1(vite@5.4.21(@types/node@25.6.0))
+
+packages:
+
+  '@emnapi/runtime@1.9.2':
+    resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
+  '@esbuild/aix-ppc64@0.21.5':
+    resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/aix-ppc64@0.27.7':
+    resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.21.5':
+    resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm64@0.27.7':
+    resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.21.5':
+    resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-arm@0.27.7':
+    resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.21.5':
+    resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/android-x64@0.27.7':
+    resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.21.5':
+    resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-arm64@0.27.7':
+    resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.21.5':
+    resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.27.7':
+    resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-arm64@0.27.7':
+    resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.21.5':
+    resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.27.7':
+    resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.21.5':
+    resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm64@0.27.7':
+    resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.21.5':
+    resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.27.7':
+    resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.21.5':
+    resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.27.7':
+    resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.21.5':
+    resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+    engines: {node: '>=12'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.27.7':
+    resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.21.5':
+    resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+    engines: {node: '>=12'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.27.7':
+    resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.21.5':
+    resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.27.7':
+    resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.21.5':
+    resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+    engines: {node: '>=12'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.27.7':
+    resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.21.5':
+    resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+    engines: {node: '>=12'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.27.7':
+    resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.21.5':
+    resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.27.7':
+    resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.21.5':
+    resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.27.7':
+    resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.21.5':
+    resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.27.7':
+    resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.21.5':
+    resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/sunos-x64@0.27.7':
+    resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.21.5':
+    resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-arm64@0.27.7':
+    resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.21.5':
+    resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.27.7':
+    resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.21.5':
+    resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.27.7':
+    resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@img/colour@1.1.0':
+    resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+    engines: {node: '>=18'}
+
+  '@img/sharp-darwin-arm64@0.34.5':
+    resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@img/sharp-darwin-x64@0.34.5':
+    resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [darwin]
+
+  '@img/sharp-libvips-darwin-arm64@1.2.4':
+    resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@img/sharp-libvips-darwin-x64@1.2.4':
+    resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@img/sharp-libvips-linux-arm64@1.2.4':
+    resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-arm@1.2.4':
+    resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+    cpu: [arm]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-ppc64@1.2.4':
+    resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-riscv64@1.2.4':
+    resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-s390x@1.2.4':
+    resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+    cpu: [s390x]
+    os: [linux]
+
+  '@img/sharp-libvips-linux-x64@1.2.4':
+    resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+    cpu: [x64]
+    os: [linux]
+
+  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+    resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+    resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+    cpu: [x64]
+    os: [linux]
+
+  '@img/sharp-linux-arm64@0.34.5':
+    resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+
+  '@img/sharp-linux-arm@0.34.5':
+    resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm]
+    os: [linux]
+
+  '@img/sharp-linux-ppc64@0.34.5':
+    resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@img/sharp-linux-riscv64@0.34.5':
+    resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@img/sharp-linux-s390x@0.34.5':
+    resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [s390x]
+    os: [linux]
+
+  '@img/sharp-linux-x64@0.34.5':
+    resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+
+  '@img/sharp-linuxmusl-arm64@0.34.5':
+    resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+
+  '@img/sharp-linuxmusl-x64@0.34.5':
+    resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+
+  '@img/sharp-wasm32@0.34.5':
+    resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [wasm32]
+
+  '@img/sharp-win32-arm64@0.34.5':
+    resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [win32]
+
+  '@img/sharp-win32-ia32@0.34.5':
+    resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [ia32]
+    os: [win32]
+
+  '@img/sharp-win32-x64@0.34.5':
+    resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-android-arm-eabi@4.60.1':
+    resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.60.1':
+    resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.60.1':
+    resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.60.1':
+    resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.60.1':
+    resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.60.1':
+    resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.60.1':
+    resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
+    cpu: [arm]
+    os: [linux]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.60.1':
+    resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
+    cpu: [arm]
+    os: [linux]
+
+  '@rollup/rollup-linux-arm64-gnu@4.60.1':
+    resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@rollup/rollup-linux-arm64-musl@4.60.1':
+    resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@rollup/rollup-linux-loong64-gnu@4.60.1':
+    resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
+    cpu: [loong64]
+    os: [linux]
+
+  '@rollup/rollup-linux-loong64-musl@4.60.1':
+    resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
+    cpu: [loong64]
+    os: [linux]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.60.1':
+    resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@rollup/rollup-linux-ppc64-musl@4.60.1':
+    resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.60.1':
+    resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@rollup/rollup-linux-riscv64-musl@4.60.1':
+    resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@rollup/rollup-linux-s390x-gnu@4.60.1':
+    resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
+    cpu: [s390x]
+    os: [linux]
+
+  '@rollup/rollup-linux-x64-gnu@4.60.1':
+    resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
+    cpu: [x64]
+    os: [linux]
+
+  '@rollup/rollup-linux-x64-musl@4.60.1':
+    resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
+    cpu: [x64]
+    os: [linux]
+
+  '@rollup/rollup-openbsd-x64@4.60.1':
+    resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@rollup/rollup-openharmony-arm64@4.60.1':
+    resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.60.1':
+    resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.60.1':
+    resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.60.1':
+    resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.60.1':
+    resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/matter-js@0.19.8':
+    resolution: {integrity: sha512-W2ZWG58Lijv/4v768NgpeyFqqiOyslmAU7qqM1Lhz4XBoUgGtZtPz4CjcOKYtqHIak14dvPldslQhltqLTWwsw==}
+
+  '@types/node@25.6.0':
+    resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  esbuild@0.21.5:
+    resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+    engines: {node: '>=12'}
+    hasBin: true
+
+  esbuild@0.27.7:
+    resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  get-tsconfig@4.13.7:
+    resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
+
+  matter-js@0.19.0:
+    resolution: {integrity: sha512-v2huwvQGOHTGOkMqtHd2hercCG3f6QAObTisPPHg8TZqq2lz7eIY/5i/5YUV8Ibf3mEioFEmwibcPUF2/fnKKQ==}
+
+  nanoid@3.3.11:
+    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  postcss@8.5.9:
+    resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  resolve-pkg-maps@1.0.0:
+    resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+  rollup@4.60.1:
+    resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  semver@7.7.4:
+    resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  sharp@0.34.5:
+    resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tsx@4.21.0:
+    resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  typescript@5.9.3:
+    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@7.19.2:
+    resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
+
+  uqr@0.1.3:
+    resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==}
+
+  vite-plugin-qrcode@0.4.1:
+    resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==}
+    peerDependencies:
+      vite: '>=3'
+
+  vite@5.4.21:
+    resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || >=20.0.0
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.4.0
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+
+snapshots:
+
+  '@emnapi/runtime@1.9.2':
+    dependencies:
+      tslib: 2.8.1
+    optional: true
+
+  '@esbuild/aix-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/aix-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm@0.27.7':
+    optional: true
+
+  '@esbuild/android-x64@0.21.5':
+    optional: true
+
+  '@esbuild/android-x64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-x64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-x64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/linux-loong64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-loong64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.21.5':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-s390x@0.21.5':
+    optional: true
+
+  '@esbuild/linux-s390x@0.27.7':
+    optional: true
+
+  '@esbuild/linux-x64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-x64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/sunos-x64@0.21.5':
+    optional: true
+
+  '@esbuild/sunos-x64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/win32-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/win32-x64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-x64@0.27.7':
+    optional: true
+
+  '@img/colour@1.1.0': {}
+
+  '@img/sharp-darwin-arm64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-arm64': 1.2.4
+    optional: true
+
+  '@img/sharp-darwin-x64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-x64': 1.2.4
+    optional: true
+
+  '@img/sharp-libvips-darwin-arm64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-darwin-x64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-arm64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-arm@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-ppc64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-riscv64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-s390x@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-x64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+    optional: true
+
+  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+    optional: true
+
+  '@img/sharp-linux-arm64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm64': 1.2.4
+    optional: true
+
+  '@img/sharp-linux-arm@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm': 1.2.4
+    optional: true
+
+  '@img/sharp-linux-ppc64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-ppc64': 1.2.4
+    optional: true
+
+  '@img/sharp-linux-riscv64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-riscv64': 1.2.4
+    optional: true
+
+  '@img/sharp-linux-s390x@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-s390x': 1.2.4
+    optional: true
+
+  '@img/sharp-linux-x64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-x64': 1.2.4
+    optional: true
+
+  '@img/sharp-linuxmusl-arm64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+    optional: true
+
+  '@img/sharp-linuxmusl-x64@0.34.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+    optional: true
+
+  '@img/sharp-wasm32@0.34.5':
+    dependencies:
+      '@emnapi/runtime': 1.9.2
+    optional: true
+
+  '@img/sharp-win32-arm64@0.34.5':
+    optional: true
+
+  '@img/sharp-win32-ia32@0.34.5':
+    optional: true
+
+  '@img/sharp-win32-x64@0.34.5':
+    optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.60.1':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-musl@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-musl@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.60.1':
+    optional: true
+
+  '@rollup/rollup-openbsd-x64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.60.1':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.60.1':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.60.1':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.60.1':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.60.1':
+    optional: true
+
+  '@types/estree@1.0.8': {}
+
+  '@types/matter-js@0.19.8': {}
+
+  '@types/node@25.6.0':
+    dependencies:
+      undici-types: 7.19.2
+
+  detect-libc@2.1.2: {}
+
+  esbuild@0.21.5:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.21.5
+      '@esbuild/android-arm': 0.21.5
+      '@esbuild/android-arm64': 0.21.5
+      '@esbuild/android-x64': 0.21.5
+      '@esbuild/darwin-arm64': 0.21.5
+      '@esbuild/darwin-x64': 0.21.5
+      '@esbuild/freebsd-arm64': 0.21.5
+      '@esbuild/freebsd-x64': 0.21.5
+      '@esbuild/linux-arm': 0.21.5
+      '@esbuild/linux-arm64': 0.21.5
+      '@esbuild/linux-ia32': 0.21.5
+      '@esbuild/linux-loong64': 0.21.5
+      '@esbuild/linux-mips64el': 0.21.5
+      '@esbuild/linux-ppc64': 0.21.5
+      '@esbuild/linux-riscv64': 0.21.5
+      '@esbuild/linux-s390x': 0.21.5
+      '@esbuild/linux-x64': 0.21.5
+      '@esbuild/netbsd-x64': 0.21.5
+      '@esbuild/openbsd-x64': 0.21.5
+      '@esbuild/sunos-x64': 0.21.5
+      '@esbuild/win32-arm64': 0.21.5
+      '@esbuild/win32-ia32': 0.21.5
+      '@esbuild/win32-x64': 0.21.5
+
+  esbuild@0.27.7:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.27.7
+      '@esbuild/android-arm': 0.27.7
+      '@esbuild/android-arm64': 0.27.7
+      '@esbuild/android-x64': 0.27.7
+      '@esbuild/darwin-arm64': 0.27.7
+      '@esbuild/darwin-x64': 0.27.7
+      '@esbuild/freebsd-arm64': 0.27.7
+      '@esbuild/freebsd-x64': 0.27.7
+      '@esbuild/linux-arm': 0.27.7
+      '@esbuild/linux-arm64': 0.27.7
+      '@esbuild/linux-ia32': 0.27.7
+      '@esbuild/linux-loong64': 0.27.7
+      '@esbuild/linux-mips64el': 0.27.7
+      '@esbuild/linux-ppc64': 0.27.7
+      '@esbuild/linux-riscv64': 0.27.7
+      '@esbuild/linux-s390x': 0.27.7
+      '@esbuild/linux-x64': 0.27.7
+      '@esbuild/netbsd-arm64': 0.27.7
+      '@esbuild/netbsd-x64': 0.27.7
+      '@esbuild/openbsd-arm64': 0.27.7
+      '@esbuild/openbsd-x64': 0.27.7
+      '@esbuild/openharmony-arm64': 0.27.7
+      '@esbuild/sunos-x64': 0.27.7
+      '@esbuild/win32-arm64': 0.27.7
+      '@esbuild/win32-ia32': 0.27.7
+      '@esbuild/win32-x64': 0.27.7
+
+  fsevents@2.3.3:
+    optional: true
+
+  get-tsconfig@4.13.7:
+    dependencies:
+      resolve-pkg-maps: 1.0.0
+
+  matter-js@0.19.0: {}
+
+  nanoid@3.3.11: {}
+
+  picocolors@1.1.1: {}
+
+  postcss@8.5.9:
+    dependencies:
+      nanoid: 3.3.11
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
+  resolve-pkg-maps@1.0.0: {}
+
+  rollup@4.60.1:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.60.1
+      '@rollup/rollup-android-arm64': 4.60.1
+      '@rollup/rollup-darwin-arm64': 4.60.1
+      '@rollup/rollup-darwin-x64': 4.60.1
+      '@rollup/rollup-freebsd-arm64': 4.60.1
+      '@rollup/rollup-freebsd-x64': 4.60.1
+      '@rollup/rollup-linux-arm-gnueabihf': 4.60.1
+      '@rollup/rollup-linux-arm-musleabihf': 4.60.1
+      '@rollup/rollup-linux-arm64-gnu': 4.60.1
+      '@rollup/rollup-linux-arm64-musl': 4.60.1
+      '@rollup/rollup-linux-loong64-gnu': 4.60.1
+      '@rollup/rollup-linux-loong64-musl': 4.60.1
+      '@rollup/rollup-linux-ppc64-gnu': 4.60.1
+      '@rollup/rollup-linux-ppc64-musl': 4.60.1
+      '@rollup/rollup-linux-riscv64-gnu': 4.60.1
+      '@rollup/rollup-linux-riscv64-musl': 4.60.1
+      '@rollup/rollup-linux-s390x-gnu': 4.60.1
+      '@rollup/rollup-linux-x64-gnu': 4.60.1
+      '@rollup/rollup-linux-x64-musl': 4.60.1
+      '@rollup/rollup-openbsd-x64': 4.60.1
+      '@rollup/rollup-openharmony-arm64': 4.60.1
+      '@rollup/rollup-win32-arm64-msvc': 4.60.1
+      '@rollup/rollup-win32-ia32-msvc': 4.60.1
+      '@rollup/rollup-win32-x64-gnu': 4.60.1
+      '@rollup/rollup-win32-x64-msvc': 4.60.1
+      fsevents: 2.3.3
+
+  semver@7.7.4: {}
+
+  sharp@0.34.5:
+    dependencies:
+      '@img/colour': 1.1.0
+      detect-libc: 2.1.2
+      semver: 7.7.4
+    optionalDependencies:
+      '@img/sharp-darwin-arm64': 0.34.5
+      '@img/sharp-darwin-x64': 0.34.5
+      '@img/sharp-libvips-darwin-arm64': 1.2.4
+      '@img/sharp-libvips-darwin-x64': 1.2.4
+      '@img/sharp-libvips-linux-arm': 1.2.4
+      '@img/sharp-libvips-linux-arm64': 1.2.4
+      '@img/sharp-libvips-linux-ppc64': 1.2.4
+      '@img/sharp-libvips-linux-riscv64': 1.2.4
+      '@img/sharp-libvips-linux-s390x': 1.2.4
+      '@img/sharp-libvips-linux-x64': 1.2.4
+      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+      '@img/sharp-linux-arm': 0.34.5
+      '@img/sharp-linux-arm64': 0.34.5
+      '@img/sharp-linux-ppc64': 0.34.5
+      '@img/sharp-linux-riscv64': 0.34.5
+      '@img/sharp-linux-s390x': 0.34.5
+      '@img/sharp-linux-x64': 0.34.5
+      '@img/sharp-linuxmusl-arm64': 0.34.5
+      '@img/sharp-linuxmusl-x64': 0.34.5
+      '@img/sharp-wasm32': 0.34.5
+      '@img/sharp-win32-arm64': 0.34.5
+      '@img/sharp-win32-ia32': 0.34.5
+      '@img/sharp-win32-x64': 0.34.5
+
+  source-map-js@1.2.1: {}
+
+  tslib@2.8.1:
+    optional: true
+
+  tsx@4.21.0:
+    dependencies:
+      esbuild: 0.27.7
+      get-tsconfig: 4.13.7
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  typescript@5.9.3: {}
+
+  undici-types@7.19.2: {}
+
+  uqr@0.1.3: {}
+
+  vite-plugin-qrcode@0.4.1(vite@5.4.21(@types/node@25.6.0)):
+    dependencies:
+      uqr: 0.1.3
+      vite: 5.4.21(@types/node@25.6.0)
+
+  vite@5.4.21(@types/node@25.6.0):
+    dependencies:
+      esbuild: 0.21.5
+      postcss: 8.5.9
+      rollup: 4.60.1
+    optionalDependencies:
+      '@types/node': 25.6.0
+      fsevents: 2.3.3
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..bc5ee6d
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 0000000..8a2eec6
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..80d1fca
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 0000000..a29ce34
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..5503f7d
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/og-image.png b/public/og-image.png
new file mode 100644
index 0000000..7499312
Binary files /dev/null and b/public/og-image.png differ
diff --git a/public/site.webmanifest b/public/site.webmanifest
new file mode 100644
index 0000000..2298244
--- /dev/null
+++ b/public/site.webmanifest
@@ -0,0 +1,21 @@
+{
+    "name": "Nuts",
+    "short_name": "Nuts",
+    "description": "A box of nuts bouncing around with haptic feedback.",
+    "start_url": "./",
+    "display": "fullscreen",
+    "background_color": "#111111",
+    "theme_color": "#111111",
+    "icons": [
+        {
+            "src": "icon-192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "icon-512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts
new file mode 100644
index 0000000..3d62af7
--- /dev/null
+++ b/scripts/generate-icons.ts
@@ -0,0 +1,77 @@
+/**
+ * Generates all icon / favicon assets from acorn.png into public/.
+ * Run with:  pnpm generate-icons
+ *
+ * Icon by Freepik – Flaticon
+ * https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard
+ */
+
+import sharp from 'sharp'
+import { mkdir } from 'node:fs/promises'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const root = resolve(__dirname, '..')
+const src = resolve(root, 'acorn.png')
+const out = resolve(root, 'public')
+
+await mkdir(out, { recursive: true })
+
+// ── Size definitions ───────────────────────────────────────────────────────
+interface Color {
+    r: number
+    g: number
+    b: number
+    alpha: number
+}
+
+interface Task {
+    file: string
+    size: number
+    bg: Color
+    padFraction?: number
+}
+
+const DARK_BG: Color = { r: 17, g: 17, b: 17, alpha: 1 }
+const WHITE_BG: Color = { r: 255, g: 255, b: 255, alpha: 1 }
+
+const tasks: Task[] = [
+    // Standard favicons (white background – legible at tiny sizes on all UIs)
+    { file: 'favicon-16x16.png', size: 16, bg: WHITE_BG },
+    { file: 'favicon-32x32.png', size: 32, bg: WHITE_BG },
+    // Apple Touch Icon (dark background to match the app)
+    { file: 'apple-touch-icon.png', size: 180, bg: DARK_BG },
+    // PWA / Android icons
+    { file: 'icon-192.png', size: 192, bg: DARK_BG },
+    { file: 'icon-512.png', size: 512, bg: DARK_BG },
+    // OG / share image (larger, dark bg, the icon centred with padding)
+    { file: 'og-image.png', size: 512, bg: DARK_BG, padFraction: 0.15 },
+]
+
+for (const { file, size, bg, padFraction = 0 } of tasks) {
+    const iconSize = Math.round(size * (1 - padFraction * 2))
+    const dest = resolve(out, file)
+    const transparent = { r: 0, g: 0, b: 0, alpha: 0 } as const
+
+    let pipeline = sharp(src).resize(iconSize, iconSize, {
+        fit: 'contain',
+        background: transparent,
+    })
+
+    if (padFraction > 0) {
+        const pad = Math.round(size * padFraction)
+        pipeline = pipeline.extend({
+            top: pad,
+            bottom: pad,
+            left: pad,
+            right: pad,
+            background: transparent,
+        })
+    }
+
+    await pipeline.flatten({ background: bg }).png().toFile(dest)
+    console.log(`  ✓  ${file}  (${size}×${size})`)
+}
+
+console.log('\nAll icons written to public/')
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..6e385e4
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,29 @@
+export const CONFIG = {
+    // Nuts
+    defaultNutCount: 20,
+    minNuts: 1,
+    maxNuts: 50,
+    nutBaseRadius: 22,        // px, circumradius of hex polygon
+    nutRadiusVariance: 0.28,  // ±28% random size variance
+
+    // Physics
+    restitution: 0.45,   // bounciness
+    friction: 0.18,      // surface friction
+    frictionAir: 0.012,  // air resistance / damping
+    gravityScale: 1,
+
+    // Walls
+    wallThickness: 60,
+
+    // Collision haptics
+    impactThreshold: 3,       // min speed (px/step) to trigger vibration
+    maxVibrationMs: 80,       // cap on vibration duration
+    vibrationCooldownMs: 40,  // min ms between vibration calls
+
+    // Shake
+    shakeAccelThreshold: 14,  // m/s² delta to count as shake
+    shakeForceScale: 0.007,   // impulse magnitude per unit of shake intensity
+
+    // Orientation gravity
+    orientationMaxTilt: 80,   // degrees before clamping
+} as const
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..89241c7
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,129 @@
+import { CONFIG } from './config'
+import { createPhysicsWorld } from './physics'
+import { createRenderer } from './renderer'
+import { requestMotionPermission, initMotion } from './motion'
+import { initUI, removeStartOverlay } from './ui'
+
+// ── Bootstrap ──────────────────────────────────────────────────────────────
+
+async function main(): Promise<void> {
+    const canvas = document.getElementById('canvas') as HTMLCanvasElement
+
+    // Match canvas to the physical pixel dimensions for crisp rendering on HiDPI
+    const dpr = window.devicePixelRatio || 1
+    let cssWidth = window.innerWidth
+    let cssHeight = window.innerHeight
+
+    function getPixelWidth(): number { return Math.round(cssWidth * dpr) }
+    function getPixelHeight(): number { return Math.round(cssHeight * dpr) }
+
+    canvas.width = getPixelWidth()
+    canvas.height = getPixelHeight()
+    canvas.style.width = `${cssWidth}px`
+    canvas.style.height = `${cssHeight}px`
+
+    // Scale the canvas context so all drawing coordinates are in CSS pixels
+    const ctx = canvas.getContext('2d')!
+    ctx.scale(dpr, dpr)
+
+    // Physics world operates in CSS pixel space
+    const physics = createPhysicsWorld(cssWidth, cssHeight)
+    const renderer = createRenderer(canvas)
+
+    // Correct for the DPR scale we already applied to the context
+    // The renderer draws at CSS px resolution; canvas is scaled by dpr above
+    renderer.resize(getPixelWidth() / dpr, getPixelHeight() / dpr)
+
+    let nutCount: number = CONFIG.defaultNutCount
+    physics.setNutCount(nutCount)
+
+    // ── UI ───────────────────────────────────────────────────────────────────
+    initUI({
+        onNutCountChange: (count) => {
+            nutCount = count
+            physics.setNutCount(count)
+        },
+        onReset: () => {
+            physics.setNutCount(nutCount)
+        },
+    })
+
+    // ── Resize ───────────────────────────────────────────────────────────────
+    window.addEventListener('resize', () => {
+        cssWidth = window.innerWidth
+        cssHeight = window.innerHeight
+        canvas.width = getPixelWidth()
+        canvas.height = getPixelHeight()
+        canvas.style.width = `${cssWidth}px`
+        canvas.style.height = `${cssHeight}px`
+        const c = canvas.getContext('2d')!
+        c.scale(dpr, dpr)
+        renderer.resize(cssWidth, cssHeight)
+        physics.resize(cssWidth, cssHeight)
+    })
+
+    // ── Motion (shake + tilt gravity) ────────────────────────────────────────
+    let motionEnabled = false
+
+    function startMotion(): void {
+        if (motionEnabled) return
+        motionEnabled = true
+        initMotion({
+            onShake: (intensity) => {
+                physics.applyShakeForce(intensity)
+            },
+            onOrientation: (gx, gy) => {
+                physics.setGravity(gx, gy)
+            },
+        })
+    }
+
+    // ── Start overlay / permission ────────────────────────────────────────────
+    const startOverlay = document.getElementById('start-overlay')!
+    startOverlay.addEventListener(
+        'click',
+        async () => {
+            try {
+                await requestMotionPermission()
+            } catch {
+                // Permission denied or unavailable – proceed without motion
+            }
+            startMotion()
+            removeStartOverlay()
+            startLoop()
+        },
+        { once: true }
+    )
+
+    // On non-iOS desktop, start automatically without waiting for tap
+    if (!('ontouchstart' in window)) {
+        // Small delay so the UI paints before we start the loop
+        setTimeout(() => {
+            startMotion()
+            removeStartOverlay()
+            startLoop()
+        }, 400)
+    }
+
+    // ── Game loop ─────────────────────────────────────────────────────────────
+    let lastTimestamp = 0
+    let loopRunning = false
+
+    function startLoop(): void {
+        if (loopRunning) return
+        loopRunning = true
+        requestAnimationFrame(loop)
+    }
+
+    function loop(timestamp: number): void {
+        const delta = lastTimestamp === 0 ? 16.67 : timestamp - lastTimestamp
+        lastTimestamp = timestamp
+
+        physics.step(delta)
+        renderer.render(physics.nuts)
+
+        requestAnimationFrame(loop)
+    }
+}
+
+main().catch(console.error)
diff --git a/src/motion.ts b/src/motion.ts
new file mode 100644
index 0000000..220b35f
--- /dev/null
+++ b/src/motion.ts
@@ -0,0 +1,94 @@
+import { CONFIG } from './config'
+import { vibrateShake } from './vibration'
+
+export interface MotionCallbacks {
+    onShake: (intensity: number) => void
+    onOrientation: (gravX: number, gravY: number) => void
+}
+
+/** Request iOS 13+ permission for DeviceMotion/DeviceOrientation. */
+export async function requestMotionPermission(): Promise<void> {
+    // DeviceMotionEvent
+    if (
+        typeof DeviceMotionEvent !== 'undefined' &&
+        'requestPermission' in DeviceMotionEvent &&
+        typeof (DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> })
+            .requestPermission === 'function'
+    ) {
+        await (
+            DeviceMotionEvent as unknown as { requestPermission: () => Promise<string> }
+        ).requestPermission()
+    }
+
+    // DeviceOrientationEvent
+    if (
+        typeof DeviceOrientationEvent !== 'undefined' &&
+        'requestPermission' in DeviceOrientationEvent &&
+        typeof (DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> })
+            .requestPermission === 'function'
+    ) {
+        await (
+            DeviceOrientationEvent as unknown as { requestPermission: () => Promise<string> }
+        ).requestPermission()
+    }
+}
+
+/** Attach DeviceMotion (shake) and DeviceOrientation (gravity tilt) listeners. */
+export function initMotion(callbacks: MotionCallbacks): () => void {
+    let prevX = 0
+    let prevY = 0
+    let prevZ = 0
+    let lastCheckAt = 0
+
+    function onMotion(event: DeviceMotionEvent): void {
+        const acc = event.accelerationIncludingGravity
+        if (!acc) return
+
+        const now = Date.now()
+        if (now - lastCheckAt < 80) return // limit check rate to ~12 Hz
+        lastCheckAt = now
+
+        const ax = acc.x ?? 0
+        const ay = acc.y ?? 0
+        const az = acc.z ?? 0
+
+        const delta = Math.sqrt(
+            (ax - prevX) ** 2 + (ay - prevY) ** 2 + (az - prevZ) ** 2
+        )
+
+        if (delta > CONFIG.shakeAccelThreshold) {
+            callbacks.onShake(delta)
+            vibrateShake()
+        }
+
+        prevX = ax
+        prevY = ay
+        prevZ = az
+    }
+
+    function onOrientation(event: DeviceOrientationEvent): void {
+        if (event.beta === null || event.gamma === null) return
+
+        // beta  = front/back tilt, 0 = flat, 90 = vertical facing up
+        // gamma = left/right tilt, -90…90
+        const clamp = (v: number, min: number, max: number) =>
+            Math.max(min, Math.min(max, v))
+
+        const beta = clamp(event.beta, -CONFIG.orientationMaxTilt, CONFIG.orientationMaxTilt)
+        const gamma = clamp(event.gamma, -90, 90)
+
+        // Map to gravity vector components. Normalise so magnitude ≤ 1.
+        const gx = Math.sin((gamma * Math.PI) / 180)
+        const gy = Math.sin((beta * Math.PI) / 180)
+
+        callbacks.onOrientation(gx, gy)
+    }
+
+    window.addEventListener('devicemotion', onMotion)
+    window.addEventListener('deviceorientation', onOrientation)
+
+    return () => {
+        window.removeEventListener('devicemotion', onMotion)
+        window.removeEventListener('deviceorientation', onOrientation)
+    }
+}
diff --git a/src/physics.ts b/src/physics.ts
new file mode 100644
index 0000000..a14b787
--- /dev/null
+++ b/src/physics.ts
@@ -0,0 +1,131 @@
+import Matter from 'matter-js'
+import { CONFIG } from './config'
+import { vibrate } from './vibration'
+
+const { Engine, Bodies, Body, Composite, Events } = Matter
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface NutBody {
+    body: Matter.Body
+    radius: number
+    colorIndex: number
+}
+
+export interface PhysicsWorld {
+    engine: Matter.Engine
+    nuts: NutBody[]
+    setNutCount: (count: number) => void
+    resize: (width: number, height: number) => void
+    applyShakeForce: (intensity: number) => void
+    setGravity: (gx: number, gy: number) => void
+    step: (deltaMs: number) => void
+    destroy: () => void
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function buildWalls(width: number, height: number): Matter.Body[] {
+    const t = CONFIG.wallThickness
+    const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.3 }
+    return [
+        Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts), // top
+        Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts), // bottom
+        Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts), // left
+        Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts), // right
+    ]
+}
+
+function spawnNut(width: number, height: number, colorIndex: number): NutBody {
+    const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
+    const radius = CONFIG.nutBaseRadius * variance
+    const margin = radius * 2
+    const x = margin + Math.random() * (width - margin * 2)
+    const y = margin + Math.random() * (height * 0.5)
+    const body = Bodies.polygon(x, y, 6, radius, {
+        restitution: CONFIG.restitution,
+        friction: CONFIG.friction,
+        frictionAir: CONFIG.frictionAir,
+        label: 'nut',
+    })
+    return { body, radius, colorIndex }
+}
+
+// ── Factory ────────────────────────────────────────────────────────────────
+
+export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
+    const engine = Engine.create()
+    engine.gravity.x = 0
+    engine.gravity.y = CONFIG.gravityScale
+
+    let walls = buildWalls(width, height)
+    let nuts: NutBody[] = []
+    let currentWidth = width
+    let currentHeight = height
+
+    Composite.add(engine.world, walls)
+
+    // ── Collision haptics ──────────────────────────────────────────────────
+    Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => {
+        for (const pair of event.pairs) {
+            const aWall = pair.bodyA.label === 'wall'
+            const bWall = pair.bodyB.label === 'wall'
+            if (!aWall && !bWall) continue
+
+            const nutBody = aWall ? pair.bodyB : pair.bodyA
+            if (nutBody.label !== 'nut') continue
+
+            const speed = nutBody.speed
+            if (speed > CONFIG.impactThreshold) {
+                const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                vibrate(Math.max(8, Math.round(t * CONFIG.maxVibrationMs)))
+            }
+        }
+    })
+
+    // ── Public API ─────────────────────────────────────────────────────────
+
+    function setNutCount(count: number): void {
+        for (const { body } of nuts) Composite.remove(engine.world, body)
+        nuts = []
+        for (let i = 0; i < count; i++) {
+            const nut = spawnNut(currentWidth, currentHeight, i)
+            nuts.push(nut)
+            Composite.add(engine.world, nut.body)
+        }
+    }
+
+    function resize(newWidth: number, newHeight: number): void {
+        currentWidth = newWidth
+        currentHeight = newHeight
+        for (const wall of walls) Composite.remove(engine.world, wall)
+        walls = buildWalls(newWidth, newHeight)
+        Composite.add(engine.world, walls)
+    }
+
+    function applyShakeForce(intensity: number): void {
+        const scale = CONFIG.shakeForceScale * (intensity / CONFIG.shakeAccelThreshold)
+        for (const { body } of nuts) {
+            Body.applyForce(body, body.position, {
+                x: (Math.random() - 0.5) * scale * body.mass,
+                y: (Math.random() - 0.5) * scale * body.mass,
+            })
+        }
+    }
+
+    function setGravity(gx: number, gy: number): void {
+        engine.gravity.x = gx
+        engine.gravity.y = gy === 0 ? 1e-4 : gy  // avoid full zero-gravity
+    }
+
+    function step(deltaMs: number): void {
+        Engine.update(engine, Math.min(deltaMs, 50))
+    }
+
+    function destroy(): void {
+        Composite.clear(engine.world, false)
+        Engine.clear(engine)
+    }
+
+    return { engine, nuts, setNutCount, resize, applyShakeForce, setGravity, step, destroy }
+}
diff --git a/src/renderer.ts b/src/renderer.ts
new file mode 100644
index 0000000..e9c232e
--- /dev/null
+++ b/src/renderer.ts
@@ -0,0 +1,166 @@
+import type { NutBody } from './physics'
+
+// ── Palette ────────────────────────────────────────────────────────────────
+// Each entry: [fill, highlight, shadow, stroke]
+const PALETTE: [string, string, string, string][] = [
+    ['#C8A84B', '#E8CC7A', '#7A6218', '#5C4A12'], // brass
+    ['#A0A0A8', '#CDD0D8', '#606068', '#404048'], // steel
+    ['#B87333', '#D8993A', '#6B3F14', '#4A2A0A'], // copper
+    ['#8C6D3F', '#B89060', '#50381A', '#362208'], // bronze
+    ['#C0C0C0', '#E8E8E8', '#707070', '#484848'], // chrome
+    ['#787860', '#A0A088', '#404030', '#282818'], // dark zinc
+]
+
+// ── Cache pre-rendered sprites ─────────────────────────────────────────────
+// Key: "<sizeRounded>_<colorIndex>"
+const spriteCache = new Map<string, HTMLCanvasElement>()
+
+function roundToNearest2(n: number): number {
+    return Math.round(n / 2) * 2
+}
+
+function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const [fill, hi, shadow, stroke] = PALETTE[colorIndex % PALETTE.length]
+    const size = Math.ceil(radius * 2 + 4) // +padding for stroke/shadow
+    const oc = document.createElement('canvas')
+    oc.width = size
+    oc.height = size
+    const ctx = oc.getContext('2d')!
+    const cx = size / 2
+    const cy = size / 2
+
+    // -- Hex path (regular hexagon, flat-top orientation) --
+    ctx.beginPath()
+    for (let i = 0; i < 6; i++) {
+        const a = (Math.PI / 3) * i - Math.PI / 6 // pointy-top
+        const x = cx + radius * Math.cos(a)
+        const y = cy + radius * Math.sin(a)
+        if (i === 0) ctx.moveTo(x, y)
+        else ctx.lineTo(x, y)
+    }
+    ctx.closePath()
+
+    // Center hole sub-path (CCW so evenodd creates a hole)
+    const holeR = radius * 0.36
+    ctx.moveTo(cx + holeR, cy)
+    ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
+    ctx.closePath()
+
+    // Drop shadow
+    ctx.shadowColor = 'rgba(0,0,0,0.55)'
+    ctx.shadowBlur = 6
+    ctx.shadowOffsetY = 3
+
+    // Radial gradient – off-centre light source (top-left)
+    const grad = ctx.createRadialGradient(
+        cx - radius * 0.25, cy - radius * 0.3, radius * 0.05,
+        cx, cy, radius
+    )
+    grad.addColorStop(0, hi)
+    grad.addColorStop(0.45, fill)
+    grad.addColorStop(1, shadow)
+
+    ctx.fillStyle = grad
+    ctx.fill('evenodd')
+
+    // Reset shadow for stroke
+    ctx.shadowColor = 'transparent'
+    ctx.shadowBlur = 0
+    ctx.shadowOffsetY = 0
+
+    // Outer stroke
+    ctx.lineWidth = 1.5
+    ctx.strokeStyle = stroke
+    // Re-build outer hex only for stroke (no hole)
+    ctx.beginPath()
+    for (let i = 0; i < 6; i++) {
+        const a = (Math.PI / 3) * i - Math.PI / 6
+        const x = cx + radius * Math.cos(a)
+        const y = cy + radius * Math.sin(a)
+        if (i === 0) ctx.moveTo(x, y)
+        else ctx.lineTo(x, y)
+    }
+    ctx.closePath()
+    ctx.stroke()
+
+    // Inner hole edge
+    ctx.lineWidth = 1
+    ctx.strokeStyle = shadow
+    ctx.beginPath()
+    ctx.arc(cx, cy, holeR, 0, Math.PI * 2)
+    ctx.stroke()
+
+    // Highlight arc (top-left glint)
+    ctx.save()
+    ctx.clip()  // clip to hex (already closed path is gone – need a fresh clip)
+    ctx.restore()
+
+    return oc
+}
+
+function getSprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const r = roundToNearest2(radius)
+    const key = `${r}_${colorIndex % PALETTE.length}`
+    let sprite = spriteCache.get(key)
+    if (!sprite) {
+        sprite = buildSprite(r, colorIndex)
+        spriteCache.set(key, sprite)
+    }
+    return sprite
+}
+
+// ── Renderer ───────────────────────────────────────────────────────────────
+
+export interface Renderer {
+    render: (nuts: NutBody[]) => void
+    resize: (width: number, height: number) => void
+}
+
+export function createRenderer(canvas: HTMLCanvasElement): Renderer {
+    const ctx = canvas.getContext('2d')!
+
+    function render(nuts: NutBody[]): void {
+        const w = canvas.width
+        const h = canvas.height
+
+        // Background
+        ctx.fillStyle = '#111111'
+        ctx.fillRect(0, 0, w, h)
+
+        // Container border (subtle inner glow suggesting a tube/box)
+        const borderW = 4
+        ctx.strokeStyle = '#2a2a2a'
+        ctx.lineWidth = borderW
+        ctx.strokeRect(borderW / 2, borderW / 2, w - borderW, h - borderW)
+
+        // Nuts
+        for (const { body, radius, colorIndex } of nuts) {
+            const { position, angle, vertices } = body
+            const sprite = getSprite(radius, colorIndex)
+            const size = sprite.width
+
+            ctx.save()
+            ctx.translate(position.x, position.y)
+            ctx.rotate(angle)
+            ctx.drawImage(sprite, -size / 2, -size / 2, size, size)
+            ctx.restore()
+
+            // Debug: visualise physics polygon (uncomment for dev)
+            // ctx.beginPath()
+            // ctx.moveTo(vertices[0].x, vertices[0].y)
+            // for (const v of vertices) ctx.lineTo(v.x, v.y)
+            // ctx.closePath()
+            // ctx.strokeStyle = 'red'
+            // ctx.lineWidth = 1
+            // ctx.stroke()
+            void vertices // suppress unused-var warning
+        }
+    }
+
+    function resize(width: number, height: number): void {
+        canvas.width = width
+        canvas.height = height
+    }
+
+    return { render, resize }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..50ea38a
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,270 @@
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+}
+
+html,
+body {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    background: #111;
+    touch-action: none;
+    -webkit-tap-highlight-color: transparent;
+    user-select: none;
+    -webkit-user-select: none;
+}
+
+#canvas {
+    display: block;
+    position: fixed;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+}
+
+/* ── Start overlay ─────────────────────────────────────────── */
+#start-overlay {
+    position: fixed;
+    inset: 0;
+    background: #111;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 100;
+    cursor: pointer;
+}
+
+.start-content {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    color: #e0e0e0;
+}
+
+.nut-icon {
+    width: 96px;
+    height: 96px;
+    filter: drop-shadow(0 0 24px rgba(200, 168, 75, 0.6)) invert(1) sepia(1) saturate(3) hue-rotate(5deg);
+    animation: bob 2.4s ease-in-out infinite;
+}
+
+@keyframes bob {
+
+    0%,
+    100% {
+        transform: translateY(0) rotate(0deg);
+    }
+
+    50% {
+        transform: translateY(-12px) rotate(15deg);
+    }
+}
+
+.start-content h1 {
+    font-family: system-ui, -apple-system, sans-serif;
+    font-size: 36px;
+    font-weight: 700;
+    letter-spacing: 0.08em;
+    color: #c8a84b;
+    text-transform: uppercase;
+}
+
+.start-content p {
+    font-family: system-ui, -apple-system, sans-serif;
+    font-size: 16px;
+    color: #666;
+    letter-spacing: 0.12em;
+    text-transform: uppercase;
+}
+
+/* ── Settings button ────────────────────────────────────────── */
+#settings-btn {
+    position: fixed;
+    top: max(16px, env(safe-area-inset-top, 16px));
+    right: max(16px, env(safe-area-inset-right, 16px));
+    width: 44px;
+    height: 44px;
+    border-radius: 50%;
+    border: none;
+    background: rgba(255, 255, 255, 0.1);
+    color: #aaa;
+    font-size: 22px;
+    cursor: pointer;
+    z-index: 50;
+    backdrop-filter: blur(8px);
+    -webkit-backdrop-filter: blur(8px);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: background 0.2s;
+}
+
+#settings-btn:not([hidden]) {
+    display: flex;
+}
+
+#settings-btn:active {
+    background: rgba(255, 255, 255, 0.2);
+}
+
+/* ── Settings panel ─────────────────────────────────────────── */
+#settings-panel {
+    position: fixed;
+    inset: 0;
+    z-index: 80;
+}
+
+#settings-panel:not([hidden]) {
+    display: block;
+}
+
+.panel-backdrop {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.6);
+}
+
+.panel-sheet {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background: #1e1e1e;
+    border-radius: 20px 20px 0 0;
+    padding: 12px 24px max(24px, env(safe-area-inset-bottom, 24px));
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    font-family: system-ui, -apple-system, sans-serif;
+    color: #e0e0e0;
+}
+
+.panel-handle {
+    width: 36px;
+    height: 5px;
+    background: #444;
+    border-radius: 3px;
+    align-self: center;
+    margin-bottom: 4px;
+}
+
+.panel-sheet h2 {
+    font-size: 18px;
+    font-weight: 600;
+    color: #fff;
+}
+
+.setting-row {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    font-size: 15px;
+}
+
+.setting-value {
+    font-weight: 600;
+    color: #c8a84b;
+    min-width: 28px;
+    text-align: right;
+}
+
+input[type="range"] {
+    -webkit-appearance: none;
+    width: 100%;
+    height: 6px;
+    border-radius: 3px;
+    background: #333;
+    outline: none;
+    margin-top: -8px;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    width: 26px;
+    height: 26px;
+    border-radius: 50%;
+    background: #c8a84b;
+    cursor: pointer;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
+}
+
+input[type="range"]::-moz-range-thumb {
+    width: 26px;
+    height: 26px;
+    border-radius: 50%;
+    background: #c8a84b;
+    cursor: pointer;
+    border: none;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
+}
+
+.action-btn {
+    background: #2a2a2a;
+    border: 1px solid #3a3a3a;
+    color: #ccc;
+    font-size: 15px;
+    padding: 12px 16px;
+    border-radius: 10px;
+    cursor: pointer;
+    font-family: inherit;
+    text-align: center;
+    transition: background 0.15s;
+}
+
+.action-btn:active {
+    background: #333;
+}
+
+.close-btn {
+    background: #c8a84b;
+    border: none;
+    color: #111;
+    font-size: 16px;
+    font-weight: 600;
+    padding: 14px 16px;
+    border-radius: 12px;
+    cursor: pointer;
+    font-family: inherit;
+    transition: opacity 0.15s;
+}
+
+.close-btn:active {
+    opacity: 0.85;
+}
+
+.panel-links {
+    display: flex;
+    justify-content: center;
+    gap: 12px;
+    font-size: 14px;
+}
+
+.panel-links a {
+    color: #c8a84b;
+    text-decoration: none;
+}
+
+.panel-links a:active {
+    opacity: 0.7;
+}
+
+.panel-links span {
+    color: #444;
+}
+
+.attribution {
+    font-size: 12px;
+    color: #555;
+    text-align: center;
+}
+
+.attribution a {
+    color: #666;
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
\ No newline at end of file
diff --git a/src/ui.ts b/src/ui.ts
new file mode 100644
index 0000000..63dd3a3
--- /dev/null
+++ b/src/ui.ts
@@ -0,0 +1,56 @@
+import { CONFIG } from './config'
+
+export interface UICallbacks {
+    onNutCountChange: (count: number) => void
+    onReset: () => void
+}
+
+export function initUI(callbacks: UICallbacks): void {
+    const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
+    const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
+    const nutCountInput = document.getElementById('nut-count') as HTMLInputElement
+    const nutCountDisplay = document.getElementById('nut-count-display') as HTMLSpanElement
+    const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement
+    const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
+    const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
+
+    // Sync slider bounds from config
+    nutCountInput.min = String(CONFIG.minNuts)
+    nutCountInput.max = String(CONFIG.maxNuts)
+    nutCountInput.value = String(CONFIG.defaultNutCount)
+    nutCountDisplay.textContent = String(CONFIG.defaultNutCount)
+
+    // Show settings button
+    settingsBtn.hidden = false
+
+    settingsBtn.addEventListener('click', () => {
+        settingsPanel.hidden = false
+    })
+
+    closeBtn.addEventListener('click', closePanel)
+    backdrop.addEventListener('click', closePanel)
+
+    nutCountInput.addEventListener('input', () => {
+        const count = parseInt(nutCountInput.value, 10)
+        nutCountDisplay.textContent = String(count)
+        callbacks.onNutCountChange(count)
+    })
+
+    resetBtn.addEventListener('click', () => {
+        callbacks.onReset()
+        closePanel()
+    })
+
+    function closePanel(): void {
+        settingsPanel.hidden = true
+    }
+}
+
+/** Remove the start overlay (call after user interaction / permission grant). */
+export function removeStartOverlay(): void {
+    const overlay = document.getElementById('start-overlay')
+    if (!overlay) return
+    overlay.style.transition = 'opacity 0.4s'
+    overlay.style.opacity = '0'
+    setTimeout(() => overlay.remove(), 420)
+}
diff --git a/src/vibration.ts b/src/vibration.ts
new file mode 100644
index 0000000..cace1f3
--- /dev/null
+++ b/src/vibration.ts
@@ -0,0 +1,21 @@
+import { CONFIG } from './config'
+
+let lastVibrationAt = 0
+
+/** Fire the Web Vibration API, with a minimum interval to avoid spam. */
+export function vibrate(durationMs: number): void {
+    if (!('vibrate' in navigator)) return
+    const now = Date.now()
+    if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
+    lastVibrationAt = now
+    navigator.vibrate(durationMs)
+}
+
+/** Short double-pulse for a shake event. */
+export function vibrateShake(): void {
+    if (!('vibrate' in navigator)) return
+    const now = Date.now()
+    if (now - lastVibrationAt < CONFIG.vibrationCooldownMs) return
+    lastVibrationAt = now
+    navigator.vibrate([25, 15, 25])
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..bff732a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+    "compilerOptions": {
+        "target": "ES2020",
+        "useDefineForClassFields": true,
+        "module": "ESNext",
+        "lib": [
+            "ES2020",
+            "DOM",
+            "DOM.Iterable"
+        ],
+        "skipLibCheck": true,
+        "moduleResolution": "bundler",
+        "allowImportingTsExtensions": true,
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "noEmit": true,
+        "strict": true
+    },
+    "include": [
+        "src"
+    ]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..dd6cb2a
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import { qrcode } from 'vite-plugin-qrcode'
+
+export default defineConfig({
+    base: './',
+    build: {
+        outDir: 'dist',
+    },
+    server: {
+        host: true, // listen on 0.0.0.0 so the phone can reach it
+    },
+    plugins: [qrcode()],
+})