fuzzyworld

bubbles / commit

Initial commit

dcc4757ffe4b9ba17660107e3fd63ddfa55ed70e · Pavel Safronov · 2026-04-08 17:20:13 -0700

Initial commit
 .github/copilot-instructions.md |   50 ++
 .gitignore                      |    6 +
 .gitlab-ci.yml                  |   19 +
 README.md                       |   91 +++
 index.html                      |  101 ++++
 package.json                    |   20 +
 pnpm-lock.yaml                  | 1270 +++++++++++++++++++++++++++++++++++++++
 scripts/generate-assets.ts      |   96 +++
 src/App.ts                      |   78 +++
 src/icons/registry.ts           |   20 +
 src/input/TiltInput.ts          |   93 +++
 src/input/TouchInput.ts         |   62 ++
 src/main.ts                     |    7 +
 src/physics/RigidBody.ts        |   38 ++
 src/physics/Vector2.ts          |   53 ++
 src/physics/Water.ts            |  137 +++++
 src/physics/World.ts            |  170 ++++++
 src/rendering/GlassRenderer.ts  |   71 +++
 src/rendering/IconRenderer.ts   |   54 ++
 src/rendering/Renderer.ts       |  108 ++++
 src/rendering/WaterRenderer.ts  |  122 ++++
 tsconfig.json                   |   22 +
 vite.config.ts                  |   10 +
 wind.png                        |  Bin 0 -> 11612 bytes
 24 files changed, 2698 insertions(+)

diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..2baa8d7
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,50 @@
+---
+applyTo: "**"
+---
+
+# Bubbles — Copilot Instructions
+
+## Project Overview
+Silly web app: phone becomes a water glass. Canvas 2D physics, no framework, no server.
+Hosted on GitLab Pages. TypeScript + Vite + pnpm.
+
+## Hard Rules
+- **No UI framework** (no React, Vue, Svelte, etc.) — vanilla TypeScript only.
+- **Canvas 2D only** — no WebGL, no Three.js, no PixiJS.
+- **pnpm only** — never suggest npm or yarn commands.
+- **No server** — everything must work as a static site.
+- **`strict: true`** TypeScript — no `any` without explicit justification.
+
+## Architecture
+```
+src/physics/    — pure simulation (no DOM, no canvas)
+src/rendering/  — Canvas 2D drawing only (reads from physics, never writes)
+src/input/      — DOM event adapters that produce values (no side effects on world directly)
+src/icons/      — static data (icon registry)
+src/App.ts      — game loop: wires physics ← input, renderer ← physics
+src/main.ts     — DOM entry point only
+scripts/        — Node.js tooling (sharp asset pipeline)
+```
+
+## Physics Conventions
+- Coordinates: canvas space, origin top-left, +Y downward.
+- Units: pixels for distance, seconds for time, px/s for velocity, px/s² for acceleration.
+- `g ≈ 980 px/s²` (treats 100 px ≈ 1 metre).
+- `Water` is a 1-D column simulation — never replace with SPH or particles.
+- max 12 `RigidBody` objects; `World.addBody()` enforces this by dropping oldest.
+- Fixed physics timestep: `1/60` s (set in `App.ts`).
+
+## Rendering Conventions
+- `Renderer` always draws in this order: clear → glass clip → water → icons → glass outline.
+- Shimmer and refraction are animated via `WaterRenderer.update(dt)` — call every frame.
+- Emoji rendered with `ctx.fillText` — never rasterise to offscreen canvas unless profiling shows it's needed.
+
+## Input Conventions
+- `TiltInput`: pure producer of a `gravity: Vector2` — no direct world mutation.
+- `TouchInput`: spawns `RigidBody` via `World.addBody()` — the only input that mutates world state.
+- iOS orientation permission must be requested inside a user-gesture handler.
+
+## File Conventions
+- One class per file, filename matches class name.
+- Physics files import from `./Vector2.js` etc. (`.js` extension required for ESM bundler resolution).
+- No barrel (`index.ts`) files — import directly.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..59ede54
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.DS_Store
+*.local
+static/favicon.ico
+static/og-image.png
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..45afd92
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,19 @@
+image: node:22-alpine
+
+stages:
+  - deploy
+
+pages:
+  stage: deploy
+  before_script:
+    - npm install -g pnpm
+    - pnpm install --frozen-lockfile
+    - pnpm gen-assets
+  script:
+    - pnpm build
+    - mv dist public
+  artifacts:
+    paths:
+      - public
+  rules:
+    - if: $CI_COMMIT_BRANCH == "main"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..49036c1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+# Bubbles
+
+Your phone is a glass of water.
+
+Tilt to slosh. Tap to drop things in. Some float. Some sink.
+
+**Live demo:** https://pavelsafronov.gitlab.io/bubbles
+
+**Repository:** https://gitlab.com/PavelSafronov/bubbles
+
+---
+
+## What it does
+
+| Action                | Result                                       |
+| --------------------- | -------------------------------------------- |
+| Tilt phone left/right | Water sloshes toward the low side            |
+| Tilt past ~45°        | Water spills over the rim (permanently lost) |
+| Tap anywhere          | A random object drops into the water         |
+| Desktop               | Move mouse left/right to simulate tilt       |
+
+### Objects
+
+| Icon        | Density | Behaviour                  |
+| ----------- | ------- | -------------------------- |
+| 🏓 Ping pong | 0.10    | Bobs wildly on the surface |
+| 🦆 Duck      | 0.50    | Floats high                |
+| 🍎 Apple     | 0.80    | Floats                     |
+| 🍋 Lemon     | 0.85    | Floats                     |
+| 🧊 Ice       | 0.92    | Barely floats              |
+| 🐟 Fish      | 1.05    | Barely sinks               |
+| 🪨 Rock      | 2.60    | Sinks                      |
+| 💎 Gem       | 3.50    | Sinks                      |
+| ⚓ Anchor    | 7.50    | Sinks fast                 |
+| 🔩 Bolt      | 7.80    | Sinks fast                 |
+
+Max 12 objects at a time — the oldest is removed when you add a 13th.
+
+---
+
+## Tech
+
+- TypeScript + Canvas 2D API (no framework, no WebGL)
+- Vite for bundling
+- pnpm for package management
+- [GitLab Pages](https://docs.gitlab.com/ee/user/project/pages/) for hosting (static, no server)
+
+Physics:
+- 1-D column wave simulation (80 columns, spring-coupled)
+- Buoyancy via submerged fraction of circle cross-section
+- Elastic damped circle vs. circle + wall collisions
+- `DeviceOrientationEvent` for real tilt; mouse fallback on desktop
+
+---
+
+## Development
+
+```sh
+# Install dependencies
+pnpm install
+
+# Generate favicons and og-image from wind.png
+pnpm gen-assets
+
+# Start dev server
+pnpm dev
+
+# Type-check + production build
+pnpm build
+```
+
+Output goes to `dist/`. GitLab CI moves it to `public/` for Pages.
+
+---
+
+## Deploy
+
+Push to `main` on GitLab. The CI pipeline (`.gitlab-ci.yml`) runs automatically:
+
+```
+pnpm install → pnpm gen-assets → pnpm build → mv dist public
+```
+
+The `public/` artifact is served by GitLab Pages at  
+https://pavelsafronov.gitlab.io/bubbles
+
+---
+
+## Attribution
+
+Wind icon by [Icongeek26](https://www.flaticon.com/authors/icongeek26) from [Flaticon](https://www.flaticon.com/).
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5d05ecc
--- /dev/null
+++ b/index.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <meta name="mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Bubbles" />
+    <meta name="theme-color" content="#0ea5e9" />
+    <meta name="description" content="Your phone is a glass of water. Tilt to slosh. Tap to drop things in." />
+    <meta property="og:title" content="Bubbles" />
+    <meta property="og:description" content="Your phone is a glass of water. Tilt to slosh. Tap to drop things in." />
+    <meta property="og:image" content="og-image.png" />
+    <meta property="og:url" content="https://pavelsafronov.gitlab.io/bubbles" />
+    <meta property="og:type" content="website" />
+    <title>Bubbles</title>
+    <link rel="icon" type="image/x-icon" href="favicon.ico" />
+    <style>
+        *,
+        *::before,
+        *::after {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+        }
+
+        html,
+        body {
+            width: 100%;
+            height: 100%;
+            overflow: hidden;
+            background: #0f172a;
+            font-family: system-ui, sans-serif;
+            color: #e2e8f0;
+            touch-action: none;
+        }
+
+        #app {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            width: 100%;
+            height: 100%;
+            position: relative;
+        }
+
+        canvas {
+            display: block;
+            touch-action: none;
+        }
+
+        #hint {
+            position: absolute;
+            bottom: 48px;
+            left: 50%;
+            transform: translateX(-50%);
+            font-size: 13px;
+            color: rgba(226, 232, 240, 0.45);
+            white-space: nowrap;
+            pointer-events: none;
+            user-select: none;
+        }
+
+        footer {
+            position: absolute;
+            bottom: 8px;
+            left: 50%;
+            transform: translateX(-50%);
+            font-size: 11px;
+            color: rgba(226, 232, 240, 0.3);
+            white-space: nowrap;
+            text-align: center;
+        }
+
+        footer a {
+            color: rgba(226, 232, 240, 0.45);
+            text-decoration: underline;
+            text-underline-offset: 2px;
+        }
+    </style>
+</head>
+
+<body>
+    <div id="app">
+        <canvas id="canvas"></canvas>
+        <p id="hint">tap to drop · tilt to slosh</p>
+        <footer>
+            <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a>
+            &nbsp;·&nbsp;
+            wind icon by <a href="https://www.flaticon.com/authors/icongeek26" target="_blank"
+                rel="noopener">Icongeek26</a>
+            from <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a>
+        </footer>
+    </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..c53eae4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+    "name": "bubbles",
+    "version": "0.1.0",
+    "private": true,
+    "description": "Silly water glass web app — tilt your phone to slosh water, tap to drop things in",
+    "scripts": {
+        "dev": "vite",
+        "build": "tsc --noEmit && vite build",
+        "preview": "vite preview",
+        "gen-assets": "tsx scripts/generate-assets.ts"
+    },
+    "dependencies": {},
+    "devDependencies": {
+        "@types/node": "^22.0.0",
+        "sharp": "^0.33.5",
+        "tsx": "^4.19.2",
+        "typescript": "^5.7.3",
+        "vite": "^6.2.5"
+    }
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..94a0232
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,1270 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    devDependencies:
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.17
+      sharp:
+        specifier: ^0.33.5
+        version: 0.33.5
+      tsx:
+        specifier: ^4.19.2
+        version: 4.21.0
+      typescript:
+        specifier: ^5.7.3
+        version: 5.9.3
+      vite:
+        specifier: ^6.2.5
+        version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0)
+
+packages:
+
+  '@emnapi/runtime@1.9.2':
+    resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
+  '@esbuild/aix-ppc64@0.25.12':
+    resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.12':
+    resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.12':
+    resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.12':
+    resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+    engines: {node: '>=18'}
+    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.25.12':
+    resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.27.7':
+    resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@img/sharp-darwin-arm64@0.33.5':
+    resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@img/sharp-darwin-x64@0.33.5':
+    resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [darwin]
+
+  '@img/sharp-libvips-darwin-arm64@1.0.4':
+    resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@img/sharp-libvips-darwin-x64@1.0.4':
+    resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@img/sharp-libvips-linux-arm64@1.0.4':
+    resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-libvips-linux-arm@1.0.5':
+    resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-libvips-linux-s390x@1.0.4':
+    resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-libvips-linux-x64@1.0.4':
+    resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+    resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+    resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@img/sharp-linux-arm64@0.33.5':
+    resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-linux-arm@0.33.5':
+    resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-linux-s390x@0.33.5':
+    resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-linux-x64@0.33.5':
+    resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@img/sharp-linuxmusl-arm64@0.33.5':
+    resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@img/sharp-linuxmusl-x64@0.33.5':
+    resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@img/sharp-wasm32@0.33.5':
+    resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [wasm32]
+
+  '@img/sharp-win32-ia32@0.33.5':
+    resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+    cpu: [ia32]
+    os: [win32]
+
+  '@img/sharp-win32-x64@0.33.5':
+    resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
+    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]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.60.1':
+    resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.60.1':
+    resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.60.1':
+    resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.60.1':
+    resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-loong64-musl@4.60.1':
+    resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.60.1':
+    resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-musl@4.60.1':
+    resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.60.1':
+    resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.60.1':
+    resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.60.1':
+    resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.60.1':
+    resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.60.1':
+    resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@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/node@22.19.17':
+    resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  color-string@1.9.1:
+    resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
+  color@4.2.3:
+    resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
+    engines: {node: '>=12.5.0'}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  esbuild@0.25.12:
+    resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  esbuild@0.27.7:
+    resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: 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==}
+
+  is-arrayish@0.3.4:
+    resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
+  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==}
+
+  picomatch@4.0.4:
+    resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+    engines: {node: '>=12'}
+
+  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.33.5:
+    resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
+    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+  simple-swizzle@0.2.4:
+    resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  tinyglobby@0.2.16:
+    resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+    engines: {node: '>=12.0.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@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+  vite@6.4.2:
+    resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+    engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+      jiti: '>=1.21.0'
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+snapshots:
+
+  '@emnapi/runtime@1.9.2':
+    dependencies:
+      tslib: 2.8.1
+    optional: true
+
+  '@esbuild/aix-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/aix-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm@0.25.12':
+    optional: true
+
+  '@esbuild/android-arm@0.27.7':
+    optional: true
+
+  '@esbuild/android-x64@0.25.12':
+    optional: true
+
+  '@esbuild/android-x64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.12':
+    optional: true
+
+  '@esbuild/darwin-x64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.12':
+    optional: true
+
+  '@esbuild/linux-arm@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-loong64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.12':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.12':
+    optional: true
+
+  '@esbuild/linux-s390x@0.27.7':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.12':
+    optional: true
+
+  '@esbuild/linux-x64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.12':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.12':
+    optional: true
+
+  '@esbuild/sunos-x64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.12':
+    optional: true
+
+  '@esbuild/win32-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.12':
+    optional: true
+
+  '@esbuild/win32-x64@0.27.7':
+    optional: true
+
+  '@img/sharp-darwin-arm64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-arm64': 1.0.4
+    optional: true
+
+  '@img/sharp-darwin-x64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-darwin-x64': 1.0.4
+    optional: true
+
+  '@img/sharp-libvips-darwin-arm64@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-darwin-x64@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-arm64@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-arm@1.0.5':
+    optional: true
+
+  '@img/sharp-libvips-linux-s390x@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-linux-x64@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
+    optional: true
+
+  '@img/sharp-libvips-linuxmusl-x64@1.0.4':
+    optional: true
+
+  '@img/sharp-linux-arm64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm64': 1.0.4
+    optional: true
+
+  '@img/sharp-linux-arm@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-arm': 1.0.5
+    optional: true
+
+  '@img/sharp-linux-s390x@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-s390x': 1.0.4
+    optional: true
+
+  '@img/sharp-linux-x64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linux-x64': 1.0.4
+    optional: true
+
+  '@img/sharp-linuxmusl-arm64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+    optional: true
+
+  '@img/sharp-linuxmusl-x64@0.33.5':
+    optionalDependencies:
+      '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+    optional: true
+
+  '@img/sharp-wasm32@0.33.5':
+    dependencies:
+      '@emnapi/runtime': 1.9.2
+    optional: true
+
+  '@img/sharp-win32-ia32@0.33.5':
+    optional: true
+
+  '@img/sharp-win32-x64@0.33.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/node@22.19.17':
+    dependencies:
+      undici-types: 6.21.0
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  color-string@1.9.1:
+    dependencies:
+      color-name: 1.1.4
+      simple-swizzle: 0.2.4
+
+  color@4.2.3:
+    dependencies:
+      color-convert: 2.0.1
+      color-string: 1.9.1
+
+  detect-libc@2.1.2: {}
+
+  esbuild@0.25.12:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.12
+      '@esbuild/android-arm': 0.25.12
+      '@esbuild/android-arm64': 0.25.12
+      '@esbuild/android-x64': 0.25.12
+      '@esbuild/darwin-arm64': 0.25.12
+      '@esbuild/darwin-x64': 0.25.12
+      '@esbuild/freebsd-arm64': 0.25.12
+      '@esbuild/freebsd-x64': 0.25.12
+      '@esbuild/linux-arm': 0.25.12
+      '@esbuild/linux-arm64': 0.25.12
+      '@esbuild/linux-ia32': 0.25.12
+      '@esbuild/linux-loong64': 0.25.12
+      '@esbuild/linux-mips64el': 0.25.12
+      '@esbuild/linux-ppc64': 0.25.12
+      '@esbuild/linux-riscv64': 0.25.12
+      '@esbuild/linux-s390x': 0.25.12
+      '@esbuild/linux-x64': 0.25.12
+      '@esbuild/netbsd-arm64': 0.25.12
+      '@esbuild/netbsd-x64': 0.25.12
+      '@esbuild/openbsd-arm64': 0.25.12
+      '@esbuild/openbsd-x64': 0.25.12
+      '@esbuild/openharmony-arm64': 0.25.12
+      '@esbuild/sunos-x64': 0.25.12
+      '@esbuild/win32-arm64': 0.25.12
+      '@esbuild/win32-ia32': 0.25.12
+      '@esbuild/win32-x64': 0.25.12
+
+  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
+
+  fdir@6.5.0(picomatch@4.0.4):
+    optionalDependencies:
+      picomatch: 4.0.4
+
+  fsevents@2.3.3:
+    optional: true
+
+  get-tsconfig@4.13.7:
+    dependencies:
+      resolve-pkg-maps: 1.0.0
+
+  is-arrayish@0.3.4: {}
+
+  nanoid@3.3.11: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@4.0.4: {}
+
+  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.33.5:
+    dependencies:
+      color: 4.2.3
+      detect-libc: 2.1.2
+      semver: 7.7.4
+    optionalDependencies:
+      '@img/sharp-darwin-arm64': 0.33.5
+      '@img/sharp-darwin-x64': 0.33.5
+      '@img/sharp-libvips-darwin-arm64': 1.0.4
+      '@img/sharp-libvips-darwin-x64': 1.0.4
+      '@img/sharp-libvips-linux-arm': 1.0.5
+      '@img/sharp-libvips-linux-arm64': 1.0.4
+      '@img/sharp-libvips-linux-s390x': 1.0.4
+      '@img/sharp-libvips-linux-x64': 1.0.4
+      '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
+      '@img/sharp-libvips-linuxmusl-x64': 1.0.4
+      '@img/sharp-linux-arm': 0.33.5
+      '@img/sharp-linux-arm64': 0.33.5
+      '@img/sharp-linux-s390x': 0.33.5
+      '@img/sharp-linux-x64': 0.33.5
+      '@img/sharp-linuxmusl-arm64': 0.33.5
+      '@img/sharp-linuxmusl-x64': 0.33.5
+      '@img/sharp-wasm32': 0.33.5
+      '@img/sharp-win32-ia32': 0.33.5
+      '@img/sharp-win32-x64': 0.33.5
+
+  simple-swizzle@0.2.4:
+    dependencies:
+      is-arrayish: 0.3.4
+
+  source-map-js@1.2.1: {}
+
+  tinyglobby@0.2.16:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+
+  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@6.21.0: {}
+
+  vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0):
+    dependencies:
+      esbuild: 0.25.12
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
+      postcss: 8.5.9
+      rollup: 4.60.1
+      tinyglobby: 0.2.16
+    optionalDependencies:
+      '@types/node': 22.19.17
+      fsevents: 2.3.3
+      tsx: 4.21.0
diff --git a/scripts/generate-assets.ts b/scripts/generate-assets.ts
new file mode 100644
index 0000000..9f44e07
--- /dev/null
+++ b/scripts/generate-assets.ts
@@ -0,0 +1,96 @@
+/**
+ * Asset generation script.
+ * Run with: pnpm gen-assets
+ *
+ * Reads wind.png from the repo root, applies a blue tint, and writes:
+ *   static/favicon.ico  (32×32 ICO via PNG fallback — modern browsers accept PNG as .ico)
+ *   static/og-image.png (1200×630 social card with logo centred on a dark background)
+ */
+
+import sharp from 'sharp';
+import path from 'path';
+import fs from 'fs';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(__dirname, '..');
+const src = path.join(root, 'wind.png');
+const staticDir = path.join(root, 'static');
+
+if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true });
+
+// ── Shared: tinted white→blue version of the logo ─────────────────────────
+
+async function makeTintedLogo(size: number): Promise<Buffer> {
+    // 1. Resize to target size
+    // 2. Ensure RGBA
+    // 3. Tint: the source is black-on-white; we invert so strokes are white,
+    //    then composite a blue colour behind it.
+    const logoAlpha = await sharp(src)
+        .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
+        .negate({ alpha: false })   // invert: black strokes → white strokes
+        .toColorspace('srgb')
+        .ensureAlpha()
+        .raw()
+        .toBuffer({ resolveWithObject: true });
+
+    // Build a solid blue background of the same size
+    const blue = await sharp({
+        create: { width: size, height: size, channels: 4, background: { r: 14, g: 165, b: 233, alpha: 255 } },
+    })
+        .png()
+        .toBuffer();
+
+    // Composite white logo on blue background
+    return sharp(blue)
+        .composite([{ input: logoAlpha.data, raw: { width: size, height: size, channels: 4 }, blend: 'over' }])
+        .png()
+        .toBuffer();
+}
+
+// ── favicon.ico (PNG saved as .ico — accepted by all modern browsers) ──────
+
+async function genFavicon(): Promise<void> {
+    const buf = await makeTintedLogo(32);
+    const out = path.join(staticDir, 'favicon.ico');
+    fs.writeFileSync(out, buf);
+    console.log(`✓ favicon.ico  (32×32)`);
+}
+
+// ── og-image.png (1200×630) ───────────────────────────────────────────────
+
+async function genOgImage(): Promise<void> {
+    const W = 1200, H = 630;
+    const logoSize = 220;
+
+    const logo = await makeTintedLogo(logoSize);
+
+    // Dark-blue background
+    const bg = await sharp({
+        create: { width: W, height: H, channels: 3, background: { r: 15, g: 23, b: 42 } },
+    })
+        .png()
+        .toBuffer();
+
+    const out = await sharp(bg)
+        .composite([{
+            input: logo,
+            left: Math.round((W - logoSize) / 2),
+            top: Math.round((H - logoSize) / 2) - 40,
+        }])
+        .png()
+        .toBuffer();
+
+    fs.writeFileSync(path.join(staticDir, 'og-image.png'), out);
+    console.log(`✓ og-image.png (${W}×${H})`);
+}
+
+// ── run ────────────────────────────────────────────────────────────────────
+
+async function main(): Promise<void> {
+    await genFavicon();
+    await genOgImage();
+    console.log('Asset generation complete.');
+}
+
+main().catch((err: unknown) => { console.error(err); process.exit(1); });
diff --git a/src/App.ts b/src/App.ts
new file mode 100644
index 0000000..3b9ca34
--- /dev/null
+++ b/src/App.ts
@@ -0,0 +1,78 @@
+import { World } from './physics/World.js';
+import { Water } from './physics/Water.js';
+import { Renderer } from './rendering/Renderer.js';
+import { TiltInput } from './input/TiltInput.js';
+import { TouchInput } from './input/TouchInput.js';
+
+const FIXED_STEP = 1 / 60; // 60 Hz physics
+
+export class App {
+    private readonly world: World;
+    private readonly renderer: Renderer;
+    private readonly tilt: TiltInput;
+    private readonly touch: TouchInput;
+
+    private rafId = 0;
+    private lastTime = 0;
+    private accumulator = 0;
+    private permissionRequested = false;
+
+    constructor(canvas: HTMLCanvasElement) {
+        // Glass dimensions are set properly by Renderer.resize() via the initial
+        // call in its constructor, but we need placeholder values for World first.
+        const W = window.innerWidth;
+        const H = window.innerHeight;
+        const glassW = Math.min(W * 0.82, 420);
+        const glassH = H * 0.72;
+        const glassX = (W - glassW) / 2;
+        const glassY = (H - glassH) / 2 - H * 0.02;
+
+        const water = new Water(80, glassW, glassH, 0.55);
+        this.world = new World(water, glassX, glassY);
+
+        // Renderer sets up canvas sizing and overwrites world.glassX/Y on first resize
+        this.renderer = new Renderer(canvas, this.world);
+
+        this.tilt = new TiltInput(canvas);
+        this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce());
+    }
+
+    start(): void {
+        this.lastTime = performance.now();
+        this.rafId = requestAnimationFrame(this.loop);
+    }
+
+    stop(): void {
+        cancelAnimationFrame(this.rafId);
+        this.renderer.destroy();
+        this.tilt.destroy();
+        this.touch.destroy();
+    }
+
+    private readonly loop = (now: number): void => {
+        const rawDt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms
+        this.lastTime = now;
+        this.accumulator += rawDt;
+
+        // Update world gravity from tilt
+        this.world.gravity = this.tilt.gravity;
+
+        // Fixed-step physics
+        while (this.accumulator >= FIXED_STEP) {
+            this.world.step(FIXED_STEP);
+            this.accumulator -= FIXED_STEP;
+        }
+
+        // Render
+        this.renderer.update(rawDt);
+        this.renderer.draw();
+
+        this.rafId = requestAnimationFrame(this.loop);
+    };
+
+    private async requestPermissionOnce(): Promise<void> {
+        if (this.permissionRequested) return;
+        this.permissionRequested = true;
+        await this.tilt.requestPermission();
+    }
+}
diff --git a/src/icons/registry.ts b/src/icons/registry.ts
new file mode 100644
index 0000000..a953619
--- /dev/null
+++ b/src/icons/registry.ts
@@ -0,0 +1,20 @@
+export interface IconDef {
+    label: string;
+    /** Density relative to water (1.0). < 1 floats, > 1 sinks. */
+    density: number;
+    /** Collision radius in pixels */
+    radius: number;
+}
+
+export const ICONS: IconDef[] = [
+    { label: '🏓', density: 0.10, radius: 22 }, // ping pong — bobs wildly
+    { label: '🦆', density: 0.50, radius: 28 }, // duck — floats high
+    { label: '🍎', density: 0.80, radius: 26 }, // apple — floats
+    { label: '🍋', density: 0.85, radius: 24 }, // lemon — floats
+    { label: '🧊', density: 0.92, radius: 26 }, // ice — barely floats
+    { label: '🐟', density: 1.05, radius: 26 }, // fish — barely sinks
+    { label: '🪨', density: 2.60, radius: 24 }, // rock — sinks
+    { label: '💎', density: 3.50, radius: 22 }, // gem — sinks
+    { label: '⚓', density: 7.50, radius: 26 }, // anchor — sinks fast
+    { label: '🔩', density: 7.80, radius: 20 }, // bolt — sinks fast
+];
diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts
new file mode 100644
index 0000000..6d50c26
--- /dev/null
+++ b/src/input/TiltInput.ts
@@ -0,0 +1,93 @@
+import { Vector2 } from '../physics/Vector2.js';
+
+type OrientationPermission = 'granted' | 'denied' | 'pending';
+
+/**
+ * Reads device tilt and exposes it as a normalised gravity Vector2
+ * (px/s² scale, ~980 = 1 g).  Falls back to mouse position on desktop.
+ */
+export class TiltInput {
+    private _gravity: Vector2 = new Vector2(0, 980);
+    private _permission: OrientationPermission = 'pending';
+    private _usingMouse = false;
+
+    constructor(private readonly canvas: HTMLCanvasElement) {
+        this.tryAttachOrientation();
+        this.attachMouseFallback();
+    }
+
+    get gravity(): Vector2 {
+        return this._gravity.clone();
+    }
+
+    /**
+     * Call this from a user-gesture handler (e.g. first touch) to trigger
+     * the iOS DeviceOrientationEvent permission dialog.
+     */
+    async requestPermission(): Promise<void> {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const DevOrEvent = (DeviceOrientationEvent as any);
+        if (typeof DevOrEvent.requestPermission === 'function') {
+            try {
+                const result: string = await DevOrEvent.requestPermission();
+                if (result === 'granted') {
+                    this._permission = 'granted';
+                    this._usingMouse = false;
+                    window.addEventListener('deviceorientation', this.handleOrientation);
+                } else {
+                    this._permission = 'denied';
+                }
+            } catch {
+                this._permission = 'denied';
+            }
+        }
+    }
+
+    private tryAttachOrientation(): void {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const DevOrEvent = (DeviceOrientationEvent as any);
+        if (typeof DevOrEvent.requestPermission === 'function') {
+            // iOS 13+ — need explicit permission, will be requested on first touch
+            this._usingMouse = true;
+        } else if ('DeviceOrientationEvent' in window) {
+            window.addEventListener('deviceorientation', this.handleOrientation);
+            this._permission = 'granted';
+        } else {
+            this._usingMouse = true;
+        }
+    }
+
+    private readonly handleOrientation = (e: DeviceOrientationEvent): void => {
+        if (e.gamma === null || e.beta === null) return;
+        // gamma: left/right tilt (-90…+90), beta: front/back (-180…+180)
+        // We use gamma as lateral tilt (maps to x gravity) and clamp to ±45°
+        const gammaRad = (Math.min(45, Math.max(-45, e.gamma)) * Math.PI) / 180;
+        const betaRad = (Math.min(90, Math.max(-90, e.beta)) * Math.PI) / 180;
+
+        // Project onto x/y gravity components
+        this._gravity = new Vector2(
+            Math.sin(gammaRad) * 980,
+            Math.cos(betaRad) * 980,
+        );
+    };
+
+    private attachMouseFallback(): void {
+        window.addEventListener('mousemove', (e: MouseEvent) => {
+            if (!this._usingMouse) return;
+            const rect = this.canvas.getBoundingClientRect();
+            const cx = rect.left + rect.width / 2;
+            const cy = rect.top + rect.height / 2;
+            const dx = (e.clientX - cx) / (rect.width / 2); // -1…+1
+            const dy = (e.clientY - cy) / (rect.height / 2); // -1…+1
+            const tiltX = Math.max(-1, Math.min(1, dx)) * 45 * (Math.PI / 180);
+            this._gravity = new Vector2(
+                Math.sin(tiltX) * 980,
+                Math.cos(tiltX) * 980 * (1 - Math.abs(dy) * 0.2),
+            );
+        });
+    }
+
+    destroy(): void {
+        window.removeEventListener('deviceorientation', this.handleOrientation);
+    }
+}
diff --git a/src/input/TouchInput.ts b/src/input/TouchInput.ts
new file mode 100644
index 0000000..46c9288
--- /dev/null
+++ b/src/input/TouchInput.ts
@@ -0,0 +1,62 @@
+import { RigidBody } from '../physics/RigidBody.js';
+import { Vector2 } from '../physics/Vector2.js';
+import { World } from '../physics/World.js';
+import { ICONS } from '../icons/registry.js';
+
+/**
+ * Listens for pointer-down events on the canvas and spawns a random icon
+ * body near the water surface at the tap position.
+ */
+export class TouchInput {
+    constructor(
+        private readonly canvas: HTMLCanvasElement,
+        private readonly world: World,
+        private readonly onRequestPermission: () => Promise<void>,
+    ) {
+        this.canvas.addEventListener('pointerdown', this.handlePointer);
+    }
+
+    private readonly handlePointer = async (e: PointerEvent): Promise<void> => {
+        e.preventDefault();
+        // Request iOS orientation permission on very first tap
+        await this.onRequestPermission();
+        this.spawnAt(e.clientX, e.clientY);
+    };
+
+    private spawnAt(clientX: number, clientY: number): void {
+        const rect = this.canvas.getBoundingClientRect();
+        const canvasX = (clientX - rect.left) * (this.canvas.width / rect.width);
+        const canvasY = (clientY - rect.top) * (this.canvas.height / rect.height);
+
+        // Clamp to glass interior
+        const gx = this.world.glassX;
+        const gy = this.world.glassY;
+        const gw = this.world.glassW;
+
+        const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!;
+        const r = icon.radius;
+
+        const spawnX = Math.max(gx + r, Math.min(gx + gw - r, canvasX));
+        // Spawn near top of glass (just below the surface or at touch Y if that's lower)
+        const topOfGlass = gy + r;
+        const spawnY = Math.min(canvasY, topOfGlass + 20);
+
+        const body = new RigidBody({
+            position: new Vector2(spawnX, spawnY),
+            radius: r,
+            density: icon.density,
+            label: icon.label,
+        });
+
+        // Give a small random horizontal nudge for fun
+        body.velocity.x = (Math.random() - 0.5) * 120;
+        body.velocity.y = 60;
+        body.angularVelocity = (Math.random() - 0.5) * 4;
+
+        this.world.addBody(body);
+    }
+
+    destroy(): void {
+        this.canvas.removeEventListener('pointerdown', this.handlePointer);
+    }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..2f834e3
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,7 @@
+import { App } from './App.js';
+
+const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
+if (!canvas) throw new Error('No canvas element found');
+
+const app = new App(canvas);
+app.start();
diff --git a/src/physics/RigidBody.ts b/src/physics/RigidBody.ts
new file mode 100644
index 0000000..bd12b4b
--- /dev/null
+++ b/src/physics/RigidBody.ts
@@ -0,0 +1,38 @@
+import { Vector2 } from './Vector2.js';
+
+export interface RigidBodyOptions {
+    position: Vector2;
+    radius: number;
+    /** g/cm³ equivalent — water = 1.0; < 1 floats, > 1 sinks */
+    density: number;
+    /** Display label — emoji or short string */
+    label: string;
+}
+
+export class RigidBody {
+    position: Vector2;
+    velocity: Vector2;
+    readonly radius: number;
+    readonly density: number;
+    readonly label: string;
+    /** Rotation angle in radians, for visual wobble */
+    angle: number = 0;
+    angularVelocity: number = 0;
+    /** Unique id for removal tracking */
+    readonly id: number;
+
+    private static _nextId = 0;
+
+    constructor(opts: RigidBodyOptions) {
+        this.position = opts.position.clone();
+        this.velocity = Vector2.zero();
+        this.radius = opts.radius;
+        this.density = opts.density;
+        this.label = opts.label;
+        this.id = RigidBody._nextId++;
+    }
+
+    get mass(): number {
+        return this.density * Math.PI * this.radius * this.radius;
+    }
+}
diff --git a/src/physics/Vector2.ts b/src/physics/Vector2.ts
new file mode 100644
index 0000000..c8b913b
--- /dev/null
+++ b/src/physics/Vector2.ts
@@ -0,0 +1,53 @@
+export class Vector2 {
+    constructor(public x: number, public y: number) { }
+
+    static zero(): Vector2 {
+        return new Vector2(0, 0);
+    }
+
+    static from(x: number, y: number): Vector2 {
+        return new Vector2(x, y);
+    }
+
+    add(v: Vector2): Vector2 {
+        return new Vector2(this.x + v.x, this.y + v.y);
+    }
+
+    sub(v: Vector2): Vector2 {
+        return new Vector2(this.x - v.x, this.y - v.y);
+    }
+
+    scale(s: number): Vector2 {
+        return new Vector2(this.x * s, this.y * s);
+    }
+
+    dot(v: Vector2): number {
+        return this.x * v.x + this.y * v.y;
+    }
+
+    magnitude(): number {
+        return Math.sqrt(this.x * this.x + this.y * this.y);
+    }
+
+    normalize(): Vector2 {
+        const m = this.magnitude();
+        if (m === 0) return Vector2.zero();
+        return this.scale(1 / m);
+    }
+
+    clone(): Vector2 {
+        return new Vector2(this.x, this.y);
+    }
+
+    addMut(v: Vector2): this {
+        this.x += v.x;
+        this.y += v.y;
+        return this;
+    }
+
+    scaleMut(s: number): this {
+        this.x *= s;
+        this.y *= s;
+        return this;
+    }
+}
diff --git a/src/physics/Water.ts b/src/physics/Water.ts
new file mode 100644
index 0000000..e78bc26
--- /dev/null
+++ b/src/physics/Water.ts
@@ -0,0 +1,137 @@
+/**
+ * 1-D column-based shallow-water simulation.
+ *
+ * The glass interior is divided into N vertical columns.  Each column tracks
+ * its current height (above the glass floor) and a vertical velocity.
+ * Adjacent columns are spring-coupled, producing wave propagation.
+ * A lateral gravity component shifts water mass toward the low side over time.
+ * If any column height exceeds the glass rim, that water is permanently lost
+ * (spills).
+ */
+export class Water {
+    /** Column height array (metres — maps 1:1 to canvas pixels via scale) */
+    readonly heights: Float32Array;
+    /** Column velocity array */
+    private readonly velocities: Float32Array;
+    /** Total water volume (sum of all column heights * column width) — conserved unless spilling */
+    private volume: number;
+
+    /**
+     * @param columns   Number of simulation columns (default 80)
+     * @param glassW    Glass interior width in px
+     * @param glassH    Glass interior height in px
+     * @param fillFrac  Initial fill fraction (0–1), default 0.55
+     */
+    constructor(
+        public columns: number = 80,
+        public glassW: number = 300,
+        public glassH: number = 500,
+        fillFrac: number = 0.55,
+    ) {
+        this.heights = new Float32Array(columns).fill(glassH * fillFrac);
+        this.velocities = new Float32Array(columns).fill(0);
+        this.volume = this.computeVolume();
+    }
+
+    get columnWidth(): number {
+        return this.glassW / this.columns;
+    }
+
+    /**
+     * Advance the simulation by dt seconds.
+     * @param dt            Time delta in seconds (typically ~0.016)
+     * @param lateralG      Lateral gravity component (px/s²) — positive = tilt right
+     * @param verticalG     Vertical gravity magnitude (px/s²) — positive downward
+     */
+    step(dt: number, lateralG: number, verticalG: number): void {
+        const n = this.columns;
+        const k = 280;         // spring stiffness between columns
+        const damping = 0.985; // velocity damping per step
+        const lateralK = 0.08; // how strongly lateral gravity pushes water
+
+        // Compute target water level shift due to lateral gravity
+        // A positive lateralG means gravity pulls right → right columns get more water
+        const shift = lateralG * lateralK * dt;
+
+        // Apply spring forces + damping
+        for (let i = 0; i < n; i++) {
+            const left = i > 0 ? this.heights[i - 1]! : this.heights[i]!;
+            const right = i < n - 1 ? this.heights[i + 1]! : this.heights[i]!;
+            const cur = this.heights[i]!;
+            this.velocities[i] = (this.velocities[i]! + k * (left + right - 2 * cur) * dt) * damping;
+        }
+
+        // Apply lateral gravity as a linear tilt on velocities
+        for (let i = 0; i < n; i++) {
+            // Columns on the right get a nudge upward, left get downward — net mass shift
+            const normalised = (i / (n - 1)) - 0.5; // -0.5 … +0.5
+            this.velocities[i] = (this.velocities[i]! + normalised * shift * verticalG);
+        }
+
+        // Integrate heights
+        for (let i = 0; i < n; i++) {
+            this.heights[i] = Math.max(0, this.heights[i]! + this.velocities[i]! * dt);
+        }
+
+        // Spill: clamp columns that exceed rim, lose the volume
+        let spilled = false;
+        for (let i = 0; i < n; i++) {
+            if (this.heights[i]! > this.glassH) {
+                this.heights[i] = this.glassH;
+                this.velocities[i] = 0;
+                spilled = true;
+            }
+        }
+
+        if (spilled) {
+            this.volume = this.computeVolume();
+        } else {
+            // Re-distribute to conserve volume
+            this.conserveVolume();
+        }
+    }
+
+    /**
+     * Returns the surface Y coordinate (from TOP of glass, i.e. small = high up)
+     * at a given X pixel position relative to the glass interior left edge.
+     */
+    getSurfaceY(x: number): number {
+        const col = Math.min(this.columns - 1, Math.max(0, Math.floor(x / this.columnWidth)));
+        return this.glassH - this.heights[col]!;
+    }
+
+    /**
+     * Returns average surface height (px from floor) — useful for spawning objects.
+     */
+    get averageHeight(): number {
+        let sum = 0;
+        for (let i = 0; i < this.columns; i++) sum += this.heights[i]!;
+        return sum / this.columns;
+    }
+
+    /** Resize glass dimensions (e.g. on canvas resize), scaling heights proportionally. */
+    resize(newGlassW: number, newGlassH: number): void {
+        const scaleH = newGlassH / this.glassH;
+        for (let i = 0; i < this.columns; i++) {
+            this.heights[i] = this.heights[i]! * scaleH;
+        }
+        this.glassW = newGlassW;
+        this.glassH = newGlassH;
+        this.volume = this.computeVolume();
+    }
+
+    private computeVolume(): number {
+        let v = 0;
+        for (let i = 0; i < this.columns; i++) v += this.heights[i]!;
+        return v;
+    }
+
+    private conserveVolume(): void {
+        const cur = this.computeVolume();
+        if (cur <= 0) return;
+        const factor = this.volume / cur;
+        for (let i = 0; i < this.columns; i++) {
+            this.heights[i] = this.heights[i]! * factor;
+        }
+    }
+}
diff --git a/src/physics/World.ts b/src/physics/World.ts
new file mode 100644
index 0000000..b5a08fa
--- /dev/null
+++ b/src/physics/World.ts
@@ -0,0 +1,170 @@
+import { Vector2 } from './Vector2.js';
+import { Water } from './Water.js';
+import { RigidBody } from './RigidBody.js';
+
+const WATER_DENSITY = 1.0;
+const RESTITUTION = 0.35; // bounciness for wall/floor collisions
+const FRICTION = 0.72; // tangential damping on wall bounce
+const BODY_RESTITUTION = 0.4;
+const MAX_BODIES = 12;
+const ANGULAR_DAMPING = 0.96;
+
+export class World {
+    bodies: RigidBody[] = [];
+    /** Gravity vector (px/s²). +y is DOWN. Magnitude ~980 (≈ real 9.8 m/s² at 100px/m). */
+    gravity: Vector2 = new Vector2(0, 980);
+
+    constructor(
+        public water: Water,
+        /** Left edge X of glass interior in canvas space */
+        public glassX: number,
+        /** Top edge Y of glass interior in canvas space */
+        public glassY: number,
+    ) { }
+
+    get glassW(): number { return this.water.glassW; }
+    get glassH(): number { return this.water.glassH; }
+
+    addBody(body: RigidBody): void {
+        this.bodies.push(body);
+        if (this.bodies.length > MAX_BODIES) {
+            this.bodies.shift(); // remove oldest
+        }
+    }
+
+    step(dt: number): void {
+        const g = this.gravity;
+
+        // Advance water simulation
+        this.water.step(dt, g.x, Math.abs(g.y));
+
+        for (const body of this.bodies) {
+            // --- Buoyancy ---
+            const submergedFrac = this.submergedFraction(body);
+            const bodyArea = Math.PI * body.radius * body.radius;
+            const buoyancyMag = WATER_DENSITY * Math.abs(g.y) * bodyArea * submergedFrac;
+
+            // Net force = gravity - buoyancy (both along Y axis primarily)
+            const gravityForce = g.scale(body.mass);
+            // Buoyancy always opposes gravity direction
+            const gDir = g.magnitude() > 0 ? g.normalize() : new Vector2(0, 1);
+            const buoyancyForce = gDir.scale(-buoyancyMag);
+
+            // Drag when submerged — opposes velocity
+            const dragCoeff = submergedFrac * 3.5;
+            const drag = body.velocity.scale(-dragCoeff * body.mass);
+
+            const netForce = gravityForce.add(buoyancyForce).add(drag);
+            const acceleration = netForce.scale(1 / body.mass);
+
+            body.velocity.addMut(acceleration.scale(dt));
+
+            // Integrate position
+            body.position.addMut(body.velocity.scale(dt));
+
+            // Angular
+            body.angularVelocity *= ANGULAR_DAMPING;
+            body.angle += body.angularVelocity * dt;
+
+            // --- Glass wall / floor constraints (in local glass space) ---
+            this.constrainToGlass(body);
+        }
+
+        // --- Body-body collisions ---
+        for (let i = 0; i < this.bodies.length; i++) {
+            for (let j = i + 1; j < this.bodies.length; j++) {
+                this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!);
+            }
+        }
+    }
+
+    private submergedFraction(body: RigidBody): number {
+        // Body position is in canvas space; glass interior origin is (glassX, glassY)
+        const localX = body.position.x - this.glassX;
+        const localY = body.position.y - this.glassY;
+
+        const r = body.radius;
+        const centerDepthFromFloor = this.glassH - localY; // how far above floor center is
+        const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX)));
+        // surfaceY is distance from top of glass → convert to distance from floor
+        const surfaceHeightFromFloor = this.glassH - surfaceY;
+
+        // Distance from center to surface (positive = below surface)
+        const d = surfaceHeightFromFloor - centerDepthFromFloor;
+
+        if (d >= r) return 1.0; // fully submerged
+        if (d <= -r) return 0.0; // fully above water
+
+        // Partial submersion: circular segment area / total area
+        const h = d + r; // submerged depth (0 … 2r)
+        const segmentArea = r * r * Math.acos((r - h) / r) - (r - h) * Math.sqrt(2 * r * h - h * h);
+        return segmentArea / (Math.PI * r * r);
+    }
+
+    private constrainToGlass(body: RigidBody): void {
+        const r = body.radius;
+        // Convert to local glass coords
+        let lx = body.position.x - this.glassX;
+        let ly = body.position.y - this.glassY;
+
+        // Left wall
+        if (lx - r < 0) {
+            lx = r;
+            body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION;
+            body.velocity.y *= FRICTION;
+            body.angularVelocity += body.velocity.y * 0.01;
+        }
+        // Right wall
+        if (lx + r > this.glassW) {
+            lx = this.glassW - r;
+            body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION;
+            body.velocity.y *= FRICTION;
+            body.angularVelocity -= body.velocity.y * 0.01;
+        }
+        // Floor
+        if (ly + r > this.glassH) {
+            ly = this.glassH - r;
+            body.velocity.y = -Math.abs(body.velocity.y) * RESTITUTION;
+            body.velocity.x *= FRICTION;
+        }
+        // Ceiling (shouldn't normally happen, but keep objects inside)
+        if (ly - r < 0) {
+            ly = r;
+            body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION;
+        }
+
+        body.position.x = lx + this.glassX;
+        body.position.y = ly + this.glassY;
+    }
+
+    private resolveBodyCollision(a: RigidBody, b: RigidBody): void {
+        const delta = b.position.sub(a.position);
+        const dist = delta.magnitude();
+        const minDist = a.radius + b.radius;
+
+        if (dist >= minDist || dist === 0) return;
+
+        const normal = delta.scale(1 / dist);
+        const overlap = minDist - dist;
+
+        // Positional correction — split by mass
+        const totalMass = a.mass + b.mass;
+        a.position.addMut(normal.scale(-overlap * (b.mass / totalMass)));
+        b.position.addMut(normal.scale(overlap * (a.mass / totalMass)));
+
+        // Velocity resolution
+        const relVel = b.velocity.sub(a.velocity);
+        const velAlongNormal = relVel.dot(normal);
+        if (velAlongNormal > 0) return; // already separating
+
+        const e = BODY_RESTITUTION;
+        const j = -(1 + e) * velAlongNormal / (1 / a.mass + 1 / b.mass);
+
+        a.velocity.addMut(normal.scale(-j / a.mass));
+        b.velocity.addMut(normal.scale(j / b.mass));
+
+        // Small angular kick on collision
+        a.angularVelocity += (Math.random() - 0.5) * 0.8;
+        b.angularVelocity += (Math.random() - 0.5) * 0.8;
+    }
+}
diff --git a/src/rendering/GlassRenderer.ts b/src/rendering/GlassRenderer.ts
new file mode 100644
index 0000000..ff6c945
--- /dev/null
+++ b/src/rendering/GlassRenderer.ts
@@ -0,0 +1,71 @@
+/**
+ * Draws the glass cup outline and establishes the clip region for the
+ * glass interior.  Call beginClip() / endClip() around interior drawing.
+ *
+ * Glass geometry (all in canvas px):
+ *   x, y         — top-left of interior
+ *   w, h         — interior dimensions
+ *   wallThick    — wall / bottom thickness
+ *   rimOverhang  — extra px the rim extends above the interior top
+ */
+export class GlassRenderer {
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        public x: number,
+        public y: number,
+        public w: number,
+        public h: number,
+        public wallThick: number = 8,
+        public rimOverhang: number = 4,
+    ) { }
+
+    /** Set a rectangular clip path matching the glass interior. */
+    beginClip(): void {
+        this.ctx.save();
+        this.ctx.beginPath();
+        this.ctx.rect(this.x, this.y, this.w, this.h);
+        this.ctx.clip();
+    }
+
+    endClip(): void {
+        this.ctx.restore();
+    }
+
+    /** Draw the glass cup outline over everything. */
+    draw(): void {
+        const ctx = this.ctx;
+        const { x, y, w, h, wallThick: wt, rimOverhang: ro } = this;
+
+        ctx.save();
+
+        // Glass tint fill (very subtle, mostly transparent)
+        ctx.fillStyle = 'rgba(186, 230, 253, 0.07)';
+        ctx.fillRect(x, y, w, h);
+
+        // Left wall
+        ctx.fillStyle = 'rgba(186, 230, 253, 0.55)';
+        ctx.fillRect(x - wt, y - ro, wt, h + ro);
+
+        // Right wall
+        ctx.fillRect(x + w, y - ro, wt, h + ro);
+
+        // Bottom
+        ctx.fillRect(x - wt, y + h, w + wt * 2, wt);
+
+        // Inner highlight on left wall edge
+        ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
+        ctx.fillRect(x - wt, y - ro, 3, h + ro);
+
+        // Inner highlight on right wall edge
+        ctx.fillRect(x + w + wt - 3, y - ro, 3, h + ro);
+
+        // Rim top glow
+        const rimGrad = ctx.createLinearGradient(x - wt, y - ro, x - wt, y);
+        rimGrad.addColorStop(0, 'rgba(255,255,255,0.32)');
+        rimGrad.addColorStop(1, 'rgba(255,255,255,0.0)');
+        ctx.fillStyle = rimGrad;
+        ctx.fillRect(x - wt, y - ro, w + wt * 2, ro + 4);
+
+        ctx.restore();
+    }
+}
diff --git a/src/rendering/IconRenderer.ts b/src/rendering/IconRenderer.ts
new file mode 100644
index 0000000..ba8d967
--- /dev/null
+++ b/src/rendering/IconRenderer.ts
@@ -0,0 +1,54 @@
+import { RigidBody } from '../physics/RigidBody.js';
+import { Water } from '../physics/Water.js';
+
+const FONT_SCALE = 1.55; // emoji size relative to body radius
+
+export class IconRenderer {
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        private readonly water: Water,
+        public glassX: number,
+        public glassY: number,
+    ) { }
+
+    draw(bodies: RigidBody[], getRefractionDx: (x: number) => number): void {
+        const ctx = this.ctx;
+
+        for (const body of bodies) {
+            const r = body.radius;
+            const size = r * FONT_SCALE * 2;
+            const bx = body.position.x;
+            const by = body.position.y;
+
+            // Determine if center is below water surface
+            const localX = bx - this.glassX;
+            const surfaceY = this.glassY + this.water.getSurfaceY(Math.max(0, localX));
+            const submerged = by > surfaceY;
+
+            ctx.save();
+            ctx.translate(bx + (submerged ? getRefractionDx(bx) : 0), by);
+            ctx.rotate(body.angle);
+
+            // Draw shadow / glow ring
+            ctx.beginPath();
+            ctx.arc(0, 0, r, 0, Math.PI * 2);
+            if (submerged) {
+                ctx.fillStyle = 'rgba(0, 80, 120, 0.25)';
+            } else {
+                ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
+            }
+            ctx.fill();
+
+            // Draw emoji
+            ctx.font = `${size}px serif`;
+            ctx.textAlign = 'center';
+            ctx.textBaseline = 'middle';
+            if (submerged) {
+                ctx.globalAlpha = 0.82;
+            }
+            ctx.fillText(body.label, 0, 0);
+
+            ctx.restore();
+        }
+    }
+}
diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts
new file mode 100644
index 0000000..dcffbcc
--- /dev/null
+++ b/src/rendering/Renderer.ts
@@ -0,0 +1,108 @@
+import { GlassRenderer } from './GlassRenderer.js';
+import { WaterRenderer } from './WaterRenderer.js';
+import { IconRenderer } from './IconRenderer.js';
+import { World } from '../physics/World.js';
+
+/**
+ * Top-level rendering coordinator.
+ * Manages canvas sizing and the draw order:
+ *   1. Clear
+ *   2. Glass clip → Water fill
+ *   3. Icons (with refraction for submerged)
+ *   4. Glass outline (drawn on top to mask overflow)
+ */
+export class Renderer {
+    readonly canvas: HTMLCanvasElement;
+    readonly ctx: CanvasRenderingContext2D;
+
+    private glass: GlassRenderer;
+    private waterRend: WaterRenderer;
+    private iconRend: IconRenderer;
+
+    constructor(canvas: HTMLCanvasElement, private world: World) {
+        this.canvas = canvas;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) throw new Error('Could not get 2D context');
+        this.ctx = ctx;
+
+        this.glass = this.makeGlassRenderer();
+        this.waterRend = new WaterRenderer(this.ctx, world.water, world.glassX, world.glassY);
+        this.iconRend = new IconRenderer(this.ctx, world.water, world.glassX, world.glassY);
+
+        this.resize();
+        window.addEventListener('resize', this.resize);
+    }
+
+    private readonly resize = (): void => {
+        const dpr = window.devicePixelRatio || 1;
+        const W = window.innerWidth;
+        const H = window.innerHeight;
+
+        this.canvas.width = W * dpr;
+        this.canvas.height = H * dpr;
+        this.canvas.style.width = `${W}px`;
+        this.canvas.style.height = `${H}px`;
+        this.ctx.scale(dpr, dpr);
+
+        // Recompute glass geometry
+        const { gx, gy, gw, gh } = this.computeGlassDims(W, H);
+
+        this.world.glassX = gx;
+        this.world.glassY = gy;
+        this.world.water.resize(gw, gh);
+
+        this.glass = this.makeGlassRenderer();
+        this.waterRend.glassX = gx;
+        this.waterRend.glassY = gy;
+        this.iconRend.glassX = gx;
+        this.iconRend.glassY = gy;
+    };
+
+    private computeGlassDims(W: number, H: number): { gx: number; gy: number; gw: number; gh: number } {
+        // Glass occupies most of the viewport, centred, portrait-friendly
+        const glassW = Math.min(W * 0.82, 420);
+        const glassH = H * 0.72;
+        const gx = (W - glassW) / 2;
+        const gy = (H - glassH) / 2 - H * 0.02;
+        return { gx, gy, gw: glassW, gh: glassH };
+    }
+
+    private makeGlassRenderer(): GlassRenderer {
+        const W = window.innerWidth;
+        const H = window.innerHeight;
+        const { gx, gy, gw, gh } = this.computeGlassDims(W, H);
+        return new GlassRenderer(this.ctx, gx, gy, gw, gh);
+    }
+
+    update(dt: number): void {
+        this.waterRend.update(dt);
+    }
+
+    draw(): void {
+        const ctx = this.ctx;
+        const W = this.canvas.width / (window.devicePixelRatio || 1);
+        const H = this.canvas.height / (window.devicePixelRatio || 1);
+
+        // Clear
+        ctx.clearRect(0, 0, W, H);
+
+        // Background
+        ctx.fillStyle = '#0f172a';
+        ctx.fillRect(0, 0, W, H);
+
+        // Clip to glass interior → draw water
+        this.glass.beginClip();
+        this.waterRend.draw();
+
+        // Draw icons (refraction handled inside)
+        this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x));
+        this.glass.endClip();
+
+        // Glass outline on top
+        this.glass.draw();
+    }
+
+    destroy(): void {
+        window.removeEventListener('resize', this.resize);
+    }
+}
diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts
new file mode 100644
index 0000000..8862cd7
--- /dev/null
+++ b/src/rendering/WaterRenderer.ts
@@ -0,0 +1,122 @@
+import { Water } from '../physics/Water.js';
+
+export class WaterRenderer {
+    private shimmerOffset = 0;
+    private refractOffset = 0;
+
+    constructor(
+        private readonly ctx: CanvasRenderingContext2D,
+        private readonly water: Water,
+        /** Canvas X of glass interior left edge */
+        public glassX: number,
+        /** Canvas Y of glass interior top edge */
+        public glassY: number,
+    ) { }
+
+    update(dt: number): void {
+        this.shimmerOffset = (this.shimmerOffset + dt * 0.4) % (Math.PI * 2);
+        this.refractOffset = (this.refractOffset + dt * 1.2) % (Math.PI * 2);
+    }
+
+    draw(): void {
+        const ctx = this.ctx;
+        const w = this.water;
+        const n = w.columns;
+        const colW = w.columnWidth;
+        const gx = this.glassX;
+        const gy = this.glassY;
+        const gh = w.glassH;
+
+        // Build wave polygon path (top surface + four corners closing the polygon)
+        ctx.beginPath();
+        // Start at bottom-left
+        ctx.moveTo(gx, gy + gh);
+
+        for (let i = 0; i < n; i++) {
+            const x = gx + i * colW + colW / 2;
+            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
+
+            if (i === 0) {
+                ctx.lineTo(gx, gy + w.getSurfaceY(0));
+                ctx.lineTo(x, sy);
+            } else {
+                // Smooth bezier between columns
+                const prevX = gx + (i - 1) * colW + colW / 2;
+                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
+                const cpX = (prevX + x) / 2;
+                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
+            }
+        }
+
+        // Close to bottom-right then bottom-left
+        ctx.lineTo(gx + w.glassW, gy + gh);
+        ctx.closePath();
+
+        // --- Main fill gradient ---
+        const grad = ctx.createLinearGradient(gx, gy, gx, gy + gh);
+        grad.addColorStop(0.0, 'rgba(56, 189, 248, 0.72)');   // surface — a light sky blue
+        grad.addColorStop(0.5, 'rgba(14, 116, 144, 0.85)');   // mid
+        grad.addColorStop(1.0, 'rgba(8,  51,  68, 0.95)');    // deep
+        ctx.fillStyle = grad;
+        ctx.fill();
+
+        // --- Shimmer band (animated diagonal highlight) ---
+        const shimmerY = gy + gh * 0.25 + Math.sin(this.shimmerOffset) * gh * 0.12;
+        const shimGrad = ctx.createLinearGradient(gx, shimmerY - 30, gx, shimmerY + 30);
+        shimGrad.addColorStop(0, 'rgba(255,255,255,0.0)');
+        shimGrad.addColorStop(0.5, 'rgba(255,255,255,0.12)');
+        shimGrad.addColorStop(1, 'rgba(255,255,255,0.0)');
+
+        ctx.save();
+        ctx.beginPath();
+        // Re-clip to the same wave shape so shimmer stays inside water
+        ctx.moveTo(gx, gy + gh);
+        for (let i = 0; i < n; i++) {
+            const x = gx + i * colW + colW / 2;
+            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
+            if (i === 0) {
+                ctx.lineTo(gx, gy + w.getSurfaceY(0));
+                ctx.lineTo(x, sy);
+            } else {
+                const prevX = gx + (i - 1) * colW + colW / 2;
+                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
+                const cpX = (prevX + x) / 2;
+                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
+            }
+        }
+        ctx.lineTo(gx + w.glassW, gy + gh);
+        ctx.closePath();
+        ctx.clip();
+
+        ctx.fillStyle = shimGrad;
+        ctx.fillRect(gx, shimmerY - 30, w.glassW, 60);
+        ctx.restore();
+
+        // --- Surface highlight line ---
+        ctx.beginPath();
+        for (let i = 0; i < n; i++) {
+            const x = gx + i * colW + colW / 2;
+            const sy = gy + w.getSurfaceY(i * colW + colW / 2);
+            if (i === 0) {
+                ctx.moveTo(gx, gy + w.getSurfaceY(0));
+                ctx.lineTo(x, sy);
+            } else {
+                const prevX = gx + (i - 1) * colW + colW / 2;
+                const prevSY = gy + w.getSurfaceY((i - 1) * colW + colW / 2);
+                const cpX = (prevX + x) / 2;
+                ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy);
+            }
+        }
+        ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)';
+        ctx.lineWidth = 2;
+        ctx.stroke();
+    }
+
+    /**
+     * Returns the refraction horizontal offset (px) to apply when rendering
+     * objects below the water surface, as a function of their x position and time.
+     */
+    getRefractionDx(canvasX: number): number {
+        return Math.sin(this.refractOffset + canvasX * 0.04) * 3;
+    }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..84b2580
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+    "compilerOptions": {
+        "target": "ES2022",
+        "module": "ESNext",
+        "moduleResolution": "bundler",
+        "strict": true,
+        "noUncheckedIndexedAccess": true,
+        "noImplicitOverride": true,
+        "lib": [
+            "ES2022",
+            "DOM",
+            "DOM.Iterable"
+        ],
+        "outDir": "dist",
+        "rootDir": ".",
+        "skipLibCheck": true
+    },
+    "include": [
+        "src",
+        "scripts"
+    ]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..0d60b09
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+    publicDir: 'static',
+    build: {
+        outDir: 'dist',
+        target: 'esnext',
+        sourcemap: true,
+    },
+});
diff --git a/wind.png b/wind.png
new file mode 100644
index 0000000..4df5d0a
Binary files /dev/null and b/wind.png differ