diff --git a/projects/sunwake-a6f3/.gitignore b/projects/sunwake-a6f3/.gitignore
new file mode 100644
index 00000000..46bcbbac
--- /dev/null
+++ b/projects/sunwake-a6f3/.gitignore
@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Local browser review output
+qa/artifacts/
+playwright-report/
+test-results/
diff --git a/projects/sunwake-a6f3/JOURNAL.md b/projects/sunwake-a6f3/JOURNAL.md
new file mode 100644
index 00000000..be8e76b3
--- /dev/null
+++ b/projects/sunwake-a6f3/JOURNAL.md
@@ -0,0 +1,44 @@
+# Sunwake — journal
+
+## Design direction
+
+An original, one-button distance game about a solar skiff surfing an endless dune sea. Its signature is a live, layered landscape: an enormous low sun, painterly dunes, ancient arches, and a tiny turquoise sail leaving a ribbon of light. Dive downhill, release to soar, thread sun rings, and turn graceful landings into a solar burst.
+
+Palette: ink plum #342c40, chalk #faf7f2, sand #f3ba7b, apricot #ef986f, rose #b86783, turquoise #75d8ce. Typography: locally hosted Bricolage Grotesque for the expressive display; DM Sans for controls and reading; monospace for live flight instruments. Layout: restrained warm page chrome, a dominant panoramic playable world, a compact mission strip, and an illustrated journey map. One strong visual moment instead of a dashboard of interchangeable cards.
+
+## Ideas / backlog
+
+- [x] Responsive one-button dune-surfing physics with touch and keyboard controls.
+- [x] Illustrated procedural world with four distance-based regions.
+- [x] Sparks, rings, clean landings, near misses, and solar bursts.
+- [x] Voyage, seeded daily run, and endless practice modes.
+- [x] Local records, missions, and earnable cosmetic skiffs.
+- [x] Synthesized reactive audio, pause, reduced motion, and fullscreen.
+- [x] Browser playtest on desktop and mobile; exact repository CI gate.
+- [x] Original static 16:10 catalog thumbnail.
+- [x] Prepare and verify a single-project release for the automatic PR publishing workflow.
+- [ ] Future: optional shareable ghost replays and more skiff silhouettes.
+
+## Session log
+
+- 2026-09-24 (codex / gpt-6): Started Sunwake from the enforced Vite + React + TypeScript template. Planned an original momentum-driven sky-surfing game and a warm panoramic art direction.
+
+## Shipped behavior and decisions
+
+- A fixed 120 Hz simulation keeps the same seed and input consistent on 30/60/144 Hz displays. Hold accelerates down slopes and dives in air; release launches the skiff, and an open sail floats off dune crests.
+- Sunlight depletes during Voyage/Daily and refills from pickups and clean landings. Rocks and electric storms take sunlight with a grace period. A 65-charge solar burst adds speed and protection for 2.8 seconds.
+- Skilled timing extends runs: sampled simulations averaged roughly 2 km for idle/held play, 5 km for rhythmic dives, 7.5 km with bursts, and 10 km with slope timing. Three seconds of airtime is attainable by bursting near an apex.
+- Region palettes change every 1,000 m and cycle endlessly. Portrait gameplay renders at least 900 logical units of course width so hazards have useful lead time. A pennant marks the player's previous personal horizon.
+- Daily seeds and records use UTC dates captured at run start. Free flight tracks lifetime journeys only, preserving the meaning of earned records and unlocks. The first three incomplete missions are eligible per run; settlement runs exactly once.
+- Progress survives reloads with validated local saves, and blocked storage falls back to the session with honest UI feedback. Skiffs are cosmetic. Fonts are local with OFL licenses; no runtime services or downloaded game assets are required.
+- Sound is gesture-unlocked, speed-reactive, and muted by default. Web Audio resources are bounded and recreated after StrictMode cleanup. Focus loss pauses the run; reduced motion removes shake and speed streaks.
+
+## Validation
+
+- Exact repository gate passes: scope, conformance, frozen pnpm install, ESLint, TypeScript/Vite build, relative deployment assets, and static 16:10 thumbnail.
+- Engine checks pass: input transitions, stable frame rates, pause, seeded layouts, pickup/damage/burst collisions, sunlight depletion, advanced airtime, and bounded four-minute practice simulation.
+- 12 progress checks pass: corrupt saves, invalid numbers, unavailable storage, ownership validation, mission eligibility, once-only mission rewards, daily dates/seeds, and practice isolation.
+- Seven production browser scenarios pass: keyboard/pointer run lifecycle, saved settlement, deterministic daily course, practice isolation, hangar purchases, 320/390 px real touch play, and sound/pause/reload controls. No console errors or horizontal overflow.
+- Visually reviewed desktop/mobile welcome, flight, results, and region palettes; loaded the built game at a nested static-server subpath without failed asset requests. Browser screenshots stay ignored in `qa/artifacts/`.
+
+- 2026-09-24 (codex / gpt-6): Built and balanced the complete Sunwake game, refined mobile lookahead and dark-region contrast, verified persistence and reward isolation, and passed the exact publication gate. Release is ready for the automatic pull-request workflow.
diff --git a/projects/sunwake-a6f3/README.md b/projects/sunwake-a6f3/README.md
new file mode 100644
index 00000000..194dc994
--- /dev/null
+++ b/projects/sunwake-a6f3/README.md
@@ -0,0 +1,67 @@
+# Sunwake
+
+**Ride the last light.** An original, momentum-driven distance game about a solar skiff surfing an endless sea of dunes.
+
+Hold to dive into a downhill slope. Release to launch. Thread sun rings, collect light, and turn clean landings into a solar burst. Four illustrated regions cycle across an endless, seeded landscape.
+
+## Play
+
+| Action | Keyboard | Touch / mouse |
+| --- | --- | --- |
+| Dive | Hold Space or ↓ | Hold the canvas or dive button |
+| Soar | Release | Release |
+| Solar burst | Shift or ↑ | Burst button when charged |
+| Pause / resume | P or Escape | Pause / resume button |
+| Start / retry | Space; R after a run | Let’s fly / One more horizon |
+
+- **Voyage:** a fresh course each run, sunlight management, personal records and challenge rewards.
+- **Daily flight:** the same course for the same UTC date, with a personal daily record. No online leaderboard or account.
+- **Free flight:** unlimited sunlight for practice. Counts toward lifetime flight count and distance, but does not award light, challenges, or competitive records.
+- **The hangar:** spend earned light on Manta and Comet. All three skiffs share the same physics.
+
+Progress is stored on this device with `localStorage`. If browser storage is blocked, the game still works for the current session. Audio starts only after a gesture and can be muted. The game pauses on focus loss and follows the system’s reduced-motion preference.
+
+## Develop
+
+```sh
+pnpm install
+pnpm dev
+```
+
+The production build uses relative asset URLs and needs no external runtime services, font requests, images, or audio downloads. Fonts and their OFL licenses are bundled in `public/fonts`. Artwork is drawn with canvas and static SVG; audio is synthesized with Web Audio.
+
+## Verify
+
+From this folder:
+
+```sh
+pnpm lint
+pnpm build
+node qa/engine-check.mjs
+node --test tests/progress.test.mjs
+```
+
+Engine checks require Node 24+ native TypeScript support. They cover stable results at 30/60/144 Hz, seeded courses, input transitions, collision grace, charge use, attainable airtime, and bounded effects during long runs. Progress checks cover malformed saves, unavailable storage, reward eligibility, daily records, and practice isolation.
+
+For browser checks, install a Playwright Chromium browser (`pnpm exec playwright install chromium`) and serve the built app with `pnpm preview --host 127.0.0.1 --port 4179`. Then:
+
+```sh
+SUNWAKE_URL=http://127.0.0.1:4179 node qa/browser-check.mjs
+```
+
+Set `CHROMIUM_PATH` if using an existing browser executable. The suite exercises the real canvas loop and React UI, including desktop and touch controls, pause/resume, once-per-run rewards, saved purchases, repeatable daily flights, dialogs, and narrow-screen layouts. Local screenshots are ignored under `qa/artifacts/`.
+
+The exact repository publishing gate, from the repository root:
+
+```sh
+node scripts/verify-project.mjs sunwake-a6f3
+```
+
+## Code map
+
+- `src/game/engine.ts`: fixed-timestep physics, terrain, seeded pickups and hazards, collision handling, and scoring.
+- `src/game/renderer.ts`: layered landscapes, skiffs, effects, region transitions, and the personal-best flag.
+- `src/game/progress.ts`: validated saves, challenges, daily seeds, and cosmetic unlocks.
+- `src/game/audio.ts`: gesture-unlocked wind, ambient tones, and event sounds.
+- `src/App.tsx`: menus, input, HUD, results, accessibility, and persistence integration.
+- `JOURNAL.md`: design decisions, shipped work, and future ideas.
diff --git a/projects/sunwake-a6f3/eslint.config.js b/projects/sunwake-a6f3/eslint.config.js
new file mode 100644
index 00000000..ef614d25
--- /dev/null
+++ b/projects/sunwake-a6f3/eslint.config.js
@@ -0,0 +1,22 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/projects/sunwake-a6f3/index.html b/projects/sunwake-a6f3/index.html
new file mode 100644
index 00000000..d5b5cd85
--- /dev/null
+++ b/projects/sunwake-a6f3/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Sunwake — Ride the last light
+
+
+
+
+
+
diff --git a/projects/sunwake-a6f3/package.json b/projects/sunwake-a6f3/package.json
new file mode 100644
index 00000000..0c38f153
--- /dev/null
+++ b/projects/sunwake-a6f3/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "sunwake",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "packageManager": "pnpm@11.5.2",
+ "scripts": {
+ "preinstall": "npx only-allow pnpm",
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.63.0",
+ "@types/node": "^24.12.3",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^10.3.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.6.0",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.59.2",
+ "vite": "^8.0.12"
+ }
+}
diff --git a/projects/sunwake-a6f3/pnpm-lock.yaml b/projects/sunwake-a6f3/pnpm-lock.yaml
new file mode 100644
index 00000000..04f90551
--- /dev/null
+++ b/projects/sunwake-a6f3/pnpm-lock.yaml
@@ -0,0 +1,1746 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ react:
+ specifier: ^19.2.6
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.6
+ version: 19.2.7(react@19.2.7)
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.4.1)
+ '@playwright/test':
+ specifier: ^1.63.0
+ version: 1.63.0
+ '@types/node':
+ specifier: ^24.12.3
+ version: 24.13.2
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.2(vite@8.0.16(@types/node@24.13.2))
+ eslint:
+ specifier: ^10.3.0
+ version: 10.4.1
+ eslint-plugin-react-hooks:
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@10.4.1)
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.2
+ version: 0.5.2(eslint@10.4.1)
+ globals:
+ specifier: ^17.6.0
+ version: 17.6.0
+ typescript:
+ specifier: ~6.0.2
+ version: 6.0.3
+ typescript-eslint:
+ specifier: ^8.59.2
+ version: 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ vite:
+ specifier: ^8.0.12
+ version: 8.0.16(@types/node@24.13.2)
+
+packages:
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.23.5':
+ resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/config-helpers@0.6.0':
+ resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/core@1.2.1':
+ resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
+ '@eslint/object-schema@3.0.5':
+ resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/plugin-kit@0.7.2':
+ resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/wasm-runtime@1.1.5':
+ resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.133.0':
+ resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+
+ '@playwright/test@1.63.0':
+ resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ '@rolldown/binding-android-arm64@1.0.3':
+ resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.3':
+ resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.3':
+ resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.3':
+ resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
+ resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.3':
+ resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.3':
+ resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.3':
+ resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.3':
+ resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.3':
+ resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.3':
+ resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.3':
+ resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.3':
+ resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.3':
+ resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.3':
+ resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@tybys/wasm-util@0.10.2':
+ resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+
+ '@types/esrecurse@4.3.1':
+ resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/node@24.13.2':
+ resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.17':
+ resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
+ '@typescript-eslint/eslint-plugin@8.61.0':
+ resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.61.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.61.0':
+ resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.61.0':
+ resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.61.0':
+ resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.61.0':
+ resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.61.0':
+ resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.61.0':
+ resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.61.0':
+ resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.61.0':
+ resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.61.0':
+ resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@6.0.2':
+ resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.10.36:
+ resolution: {integrity: sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ brace-expansion@5.0.6:
+ resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
+ engines: {node: 18 || 20 || >=22}
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ caniuse-lite@1.0.30001799:
+ resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ electron-to-chromium@1.5.372:
+ resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react-refresh@0.5.2:
+ resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-scope@9.1.2:
+ resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@10.4.1:
+ resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@17.6.0:
+ resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
+ engines: {node: '>=18'}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-releases@2.0.47:
+ resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
+ engines: {node: '>=18'}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ 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'}
+
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.63.0:
+ resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
+ rolldown@1.0.3:
+ resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typescript-eslint@8.61.0:
+ resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ vite@8.0.16:
+ resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.18
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1)':
+ dependencies:
+ eslint: 10.4.1
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.23.5':
+ dependencies:
+ '@eslint/object-schema': 3.0.5
+ debug: 4.4.3
+ minimatch: 10.2.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.6.0':
+ dependencies:
+ '@eslint/core': 1.2.1
+
+ '@eslint/core@1.2.1':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/js@10.0.1(eslint@10.4.1)':
+ optionalDependencies:
+ eslint: 10.4.1
+
+ '@eslint/object-schema@3.0.5': {}
+
+ '@eslint/plugin-kit@0.7.2':
+ dependencies:
+ '@eslint/core': 1.2.1
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.2
+ optional: true
+
+ '@oxc-project/types@0.133.0': {}
+
+ '@playwright/test@1.63.0':
+ dependencies:
+ playwright: 1.63.0
+
+ '@rolldown/binding-android-arm64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.3':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.3':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.3':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.3':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.3':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.3':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@tybys/wasm-util@0.10.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/esrecurse@4.3.1': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@24.13.2':
+ dependencies:
+ undici-types: 7.18.2
+
+ '@types/react-dom@19.2.3(@types/react@19.2.17)':
+ dependencies:
+ '@types/react': 19.2.17
+
+ '@types/react@19.2.17':
+ dependencies:
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
+ eslint: 10.4.1
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.61.0
+ debug: 4.4.3
+ eslint: 10.4.1
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ debug: 4.4.3
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.61.0':
+ dependencies:
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
+
+ '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)':
+ dependencies:
+ typescript: 6.0.3
+
+ '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ debug: 4.4.3
+ eslint: 10.4.1
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.61.0': {}
+
+ '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/visitor-keys': 8.61.0
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.4
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1)
+ '@typescript-eslint/scope-manager': 8.61.0
+ '@typescript-eslint/types': 8.61.0
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ eslint: 10.4.1
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.61.0':
+ dependencies:
+ '@typescript-eslint/types': 8.61.0
+ eslint-visitor-keys: 5.0.1
+
+ '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.0.16(@types/node@24.13.2)
+
+ acorn-jsx@5.3.2(acorn@8.17.0):
+ dependencies:
+ acorn: 8.17.0
+
+ acorn@8.17.0: {}
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.10.36: {}
+
+ brace-expansion@5.0.6:
+ dependencies:
+ balanced-match: 4.0.4
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.36
+ caniuse-lite: 1.0.30001799
+ electron-to-chromium: 1.5.372
+ node-releases: 2.0.47
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ caniuse-lite@1.0.30001799: {}
+
+ convert-source-map@2.0.0: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ detect-libc@2.1.2: {}
+
+ electron-to-chromium@1.5.372: {}
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-react-hooks@7.1.1(eslint@10.4.1):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/parser': 7.29.7
+ eslint: 10.4.1
+ hermes-parser: 0.25.1
+ zod: 4.4.3
+ zod-validation-error: 4.0.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.2(eslint@10.4.1):
+ dependencies:
+ eslint: 10.4.1
+
+ eslint-scope@9.1.2:
+ dependencies:
+ '@types/esrecurse': 4.3.1
+ '@types/estree': 1.0.9
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@10.4.1:
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1)
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.23.5
+ '@eslint/config-helpers': 0.6.0
+ '@eslint/core': 1.2.1
+ '@eslint/plugin-kit': 0.7.2
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.9
+ ajv: 6.15.0
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 9.1.2
+ eslint-visitor-keys: 5.0.1
+ espree: 11.2.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ minimatch: 10.2.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@11.2.0:
+ dependencies:
+ acorn: 8.17.0
+ acorn-jsx: 5.3.2(acorn@8.17.0)
+ eslint-visitor-keys: 5.0.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@17.6.0: {}
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ imurmurhash@0.1.4: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ isexe@2.0.0: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.6
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.12: {}
+
+ natural-compare@1.4.0: {}
+
+ node-releases@2.0.47: {}
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ playwright-core@1.63.0: {}
+
+ playwright@1.63.0:
+ dependencies:
+ playwright-core: 1.63.0
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prelude-ls@1.2.1: {}
+
+ punycode@2.3.1: {}
+
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react@19.2.7: {}
+
+ rolldown@1.0.3:
+ dependencies:
+ '@oxc-project/types': 0.133.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.3
+ '@rolldown/binding-darwin-arm64': 1.0.3
+ '@rolldown/binding-darwin-x64': 1.0.3
+ '@rolldown/binding-freebsd-x64': 1.0.3
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.3
+ '@rolldown/binding-linux-arm64-gnu': 1.0.3
+ '@rolldown/binding-linux-arm64-musl': 1.0.3
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.3
+ '@rolldown/binding-linux-s390x-gnu': 1.0.3
+ '@rolldown/binding-linux-x64-gnu': 1.0.3
+ '@rolldown/binding-linux-x64-musl': 1.0.3
+ '@rolldown/binding-openharmony-arm64': 1.0.3
+ '@rolldown/binding-wasm32-wasi': 1.0.3
+ '@rolldown/binding-win32-arm64-msvc': 1.0.3
+ '@rolldown/binding-win32-x64-msvc': 1.0.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.8.4: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ ts-api-utils@2.5.0(typescript@6.0.3):
+ dependencies:
+ typescript: 6.0.3
+
+ tslib@2.8.1:
+ optional: true
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typescript-eslint@8.61.0(eslint@10.4.1)(typescript@6.0.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3)
+ eslint: 10.4.1
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@6.0.3: {}
+
+ undici-types@7.18.2: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ vite@8.0.16(@types/node@24.13.2):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rolldown: 1.0.3
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 24.13.2
+ fsevents: 2.3.3
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/projects/sunwake-a6f3/project.json b/projects/sunwake-a6f3/project.json
new file mode 100644
index 00000000..6e92c995
--- /dev/null
+++ b/projects/sunwake-a6f3/project.json
@@ -0,0 +1,8 @@
+{
+ "title": "Sunwake",
+ "description": "Ride the last light in a momentum-driven dune-surfing odyssey. Dive, soar, thread sun rings, and chase the horizon through four endless worlds.",
+ "agent": "codex",
+ "model": "gpt-6",
+ "tags": ["game", "arcade", "adventure"],
+ "createdAt": "2026-09-24"
+}
diff --git a/projects/sunwake-a6f3/public/favicon.svg b/projects/sunwake-a6f3/public/favicon.svg
new file mode 100644
index 00000000..9978950b
--- /dev/null
+++ b/projects/sunwake-a6f3/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/projects/sunwake-a6f3/public/fonts/OFL-bricolage.txt b/projects/sunwake-a6f3/public/fonts/OFL-bricolage.txt
new file mode 100644
index 00000000..4dfd4fe0
--- /dev/null
+++ b/projects/sunwake-a6f3/public/fonts/OFL-bricolage.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/projects/sunwake-a6f3/public/fonts/OFL-dm-sans.txt b/projects/sunwake-a6f3/public/fonts/OFL-dm-sans.txt
new file mode 100644
index 00000000..a0eedfbf
--- /dev/null
+++ b/projects/sunwake-a6f3/public/fonts/OFL-dm-sans.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/projects/sunwake-a6f3/public/fonts/bricolage-grotesque-bold.ttf b/projects/sunwake-a6f3/public/fonts/bricolage-grotesque-bold.ttf
new file mode 100644
index 00000000..ea2465b4
Binary files /dev/null and b/projects/sunwake-a6f3/public/fonts/bricolage-grotesque-bold.ttf differ
diff --git a/projects/sunwake-a6f3/public/fonts/dm-sans-regular.ttf b/projects/sunwake-a6f3/public/fonts/dm-sans-regular.ttf
new file mode 100644
index 00000000..6c789ead
Binary files /dev/null and b/projects/sunwake-a6f3/public/fonts/dm-sans-regular.ttf differ
diff --git a/projects/sunwake-a6f3/public/fonts/dm-sans-semibold.ttf b/projects/sunwake-a6f3/public/fonts/dm-sans-semibold.ttf
new file mode 100644
index 00000000..ec5dcf3e
Binary files /dev/null and b/projects/sunwake-a6f3/public/fonts/dm-sans-semibold.ttf differ
diff --git a/projects/sunwake-a6f3/public/thumbnail.svg b/projects/sunwake-a6f3/public/thumbnail.svg
new file mode 100644
index 00000000..c46aad0e
--- /dev/null
+++ b/projects/sunwake-a6f3/public/thumbnail.svg
@@ -0,0 +1,51 @@
+
+ Sunwake — Ride the last light
+ A turquoise solar skiff soars over rose and plum dunes beneath a giant apricot sun.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AN ENDLESS LITTLE ADVENTURE
+ SUNWAKE
+ Ride the last light.
+
+
+ HOLD TO DIVE. RELEASE TO FLY.
+
+
diff --git a/projects/sunwake-a6f3/qa/browser-check.mjs b/projects/sunwake-a6f3/qa/browser-check.mjs
new file mode 100644
index 00000000..b5e2fa16
--- /dev/null
+++ b/projects/sunwake-a6f3/qa/browser-check.mjs
@@ -0,0 +1,276 @@
+// Start `pnpm dev --host 127.0.0.1 --port 4178`, then run `node qa/browser-check.mjs`.
+// Uses the real React UI and requestAnimationFrame loop, with a controllable browser clock.
+// SUNWAKE_URL can target a production preview; CHROMIUM_PATH can select a local browser.
+// SUNWAKE_CHECK optionally runs just scenarios whose names include that text.
+import assert from 'node:assert/strict'
+import { mkdir } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium, expect } from '@playwright/test'
+
+const baseURL = process.env.SUNWAKE_URL ?? 'http://127.0.0.1:4178'
+const executablePath = process.env.CHROMIUM_PATH
+const artifactDirectory = fileURLToPath(new URL('./artifacts/', import.meta.url))
+const SAVE_KEY = 'sunwake.progress.v1'
+const browser = await chromium.launch({ headless: true, executablePath })
+const failures = []
+const browserErrors = []
+let scenariosRun = 0
+await mkdir(artifactDirectory, { recursive: true })
+
+const integer = value => Number(value.replace(/[^0-9]/g, ''))
+const distance = async page => integer(await page.locator('.distance-readout > strong').innerText())
+const progress = page => page.evaluate(key => JSON.parse(localStorage.getItem(key) ?? 'null'), SAVE_KEY)
+const fly = async page => {
+ await page.getByRole('button', { name: 'Let’s fly', exact: true }).click()
+ await page.clock.runFor(64)
+ await expect(page.locator('.flight-stage')).toHaveClass(/phase-running/)
+}
+const finish = async page => {
+ if (await page.getByRole('button', { name: 'Pause flight', exact: true }).count()) {
+ await page.getByRole('button', { name: 'Pause flight', exact: true }).click()
+ }
+ await page.getByRole('button', { name: 'End this flight', exact: true }).click()
+ await page.clock.runFor(64)
+ await expect(page.locator('.results-card')).toBeVisible()
+}
+
+async function scenario(name, viewport, check, mobile = false) {
+ if (process.env.SUNWAKE_CHECK && !name.includes(process.env.SUNWAKE_CHECK)) return
+ scenariosRun++
+ const context = await browser.newContext({ viewport, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: 1 })
+ const page = await context.newPage()
+ page.setDefaultTimeout(6000)
+ page.on('pageerror', error => browserErrors.push(`${name}: ${error.stack ?? error.message}`))
+ page.on('console', message => {
+ if (message.type() === 'error') browserErrors.push(`${name}: ${message.text()}`)
+ })
+ try {
+ await page.clock.install({ time: new Date('2026-09-24T11:00:00Z') })
+ await page.goto(baseURL, { waitUntil: 'networkidle' })
+ await page.clock.pauseAt(new Date('2026-09-24T12:00:00Z'))
+ await expect(page.locator('h1')).toContainText('last light')
+ await check(page)
+ console.log(`PASS ${name}`)
+ } catch (error) {
+ failures.push(`${name}: ${error.stack ?? error}`)
+ console.error(`FAIL ${name}: ${error.message}`)
+ await page.screenshot({ path: `${artifactDirectory}/${name}-failure.png`, fullPage: true }).catch(() => {})
+ } finally {
+ await context.close()
+ }
+}
+
+try {
+ await scenario('desktop-flight-and-save', { width: 1440, height: 1050 }, async page => {
+ await page.getByRole('button', { name: 'How to fly' }).click()
+ await expect(page.getByRole('dialog', { name: 'How to fly' })).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+
+ await fly(page)
+ const initialDistance = await distance(page)
+ await page.keyboard.down('Space')
+ await page.clock.runFor(1600)
+ await page.keyboard.up('Space')
+ await page.clock.runFor(1600)
+ assert.ok(await distance(page) > initialDistance + 50, 'Keyboard play must advance the skiff')
+
+ await page.keyboard.press('KeyP')
+ await expect(page.locator('.pause-card')).toBeVisible()
+ const pausedDistance = await distance(page)
+ await page.clock.runFor(3000)
+ assert.equal(await distance(page), pausedDistance, 'Pause must freeze distance')
+ await page.getByRole('button', { name: 'Keep flying', exact: true }).click()
+ await page.clock.runFor(1200)
+ assert.ok(await distance(page) > pausedDistance + 20, 'Resuming must restore simulation')
+
+ // Pointer capture must release the dive even after the pointer leaves the canvas.
+ const box = await page.locator('canvas').boundingBox()
+ assert.ok(box)
+ await page.mouse.move(box.x + box.width * 0.55, box.y + box.height * 0.5)
+ await page.mouse.down()
+ await page.clock.runFor(1100)
+ await page.mouse.move(box.x + box.width + 10, box.y + box.height + 10)
+ await page.mouse.up()
+ await page.clock.runFor(900)
+ await page.getByRole('button', { name: 'Read the flight guide' }).click()
+ await expect(page.getByRole('dialog', { name: 'How to fly' })).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.locator('.pause-card')).toBeVisible()
+ await page.getByRole('button', { name: 'Keep flying', exact: true }).click()
+ await page.clock.runFor(1000)
+
+ await finish(page)
+ const settled = await progress(page)
+ assert.equal(settled.totalRuns, 1)
+ assert.ok(settled.bank > 0, 'Collected light must reach the saved bank')
+ assert.ok(settled.best > 150)
+ await page.clock.runFor(4000)
+ assert.deepEqual(await progress(page), settled, 'A completed run must settle exactly once')
+ await page.reload({ waitUntil: 'networkidle' })
+ await page.clock.runFor(64)
+ assert.deepEqual(await progress(page), settled, 'Records and currency must survive reload')
+ await expect(page.locator('.bank-pill')).toContainText(String(settled.bank))
+ await expect(page.locator('.personal-best')).toContainText(settled.best.toLocaleString('en-US'))
+ await page.screenshot({ path: `${artifactDirectory}/desktop-verified.png`, fullPage: true })
+ })
+
+ await scenario('daily-course-and-record', { width: 1280, height: 950 }, async page => {
+ await page.getByRole('button', { name: 'Daily flight', exact: true }).click()
+ await expect(page.locator('.daily-best')).toContainText('2026-09-24')
+ const results = []
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await fly(page)
+ await expect(page.locator('.daily-tag')).toHaveText('DAILY')
+ await page.keyboard.down('Space')
+ await page.clock.runFor(2400)
+ await page.keyboard.up('Space')
+ await page.clock.runFor(3200)
+ await page.keyboard.down('Space')
+ await page.clock.runFor(2000)
+ await page.keyboard.up('Space')
+ await page.clock.runFor(2400)
+ await page.getByRole('button', { name: 'Pause flight', exact: true }).click()
+ results.push(await distance(page))
+ await finish(page)
+ await page.getByRole('button', { name: 'Back to shore', exact: true }).click()
+ await page.clock.runFor(64)
+ }
+ assert.ok(Math.abs(results[0] - results[1]) <= 3, `Repeated daily input should follow the same course: ${results}`)
+ const saved = await progress(page)
+ assert.equal(saved.daily.date, '2026-09-24')
+ assert.equal(saved.daily.best, Math.max(...results))
+ assert.equal(saved.totalRuns, 2)
+ })
+
+ await scenario('free-flight-no-competitive-rewards', { width: 1280, height: 950 }, async page => {
+ await page.getByRole('button', { name: 'Free flight', exact: true }).click()
+ await fly(page)
+ await expect(page.locator('.daily-tag')).toHaveText('FREE FLIGHT')
+ await expect(page.locator('.sunlight-readout')).toContainText('∞')
+ await page.keyboard.down('Space')
+ await page.clock.runFor(14000)
+ await page.keyboard.up('Space')
+ assert.ok(await distance(page) > 400)
+ await expect(page.locator('.sunlight-readout')).toContainText('∞')
+ await finish(page)
+ const saved = await progress(page)
+ assert.equal(saved.bank, 0, 'Practice mode must not farm currency')
+ assert.equal(saved.best, 0, 'Practice must not replace competitive distance records')
+ assert.equal(saved.bestScore, 0)
+ assert.equal(saved.completed.length, 0, 'Practice must not complete competitive missions')
+ assert.equal(saved.daily.best, 0)
+ })
+
+ await scenario('hangar-purchases-persist', { width: 1280, height: 950 }, async page => {
+ // Seed a save rather than modifying the engine: purchase behavior is the subject of this check.
+ await page.evaluate(({ key, save }) => localStorage.setItem(key, JSON.stringify(save)), {
+ key: SAVE_KEY,
+ save: {
+ version: 1, bank: 600, best: 1000, bestScore: 7000, totalDistance: 2000, totalRuns: 2,
+ owned: ['sol'], selected: 'sol', completed: [], daily: { date: '2026-09-24', best: 0 }, sound: false,
+ },
+ })
+ await page.reload({ waitUntil: 'networkidle' })
+ await page.clock.runFor(64)
+ await page.getByTitle('Open the hangar', { exact: true }).click()
+ await expect(page.getByRole('dialog', { name: 'The hangar' })).toBeVisible()
+ const manta = page.locator('.ship-card').filter({ has: page.getByRole('heading', { name: 'Manta', exact: true }) })
+ await manta.getByRole('button', { name: /150 light/ }).click()
+ assert.equal((await progress(page)).bank, 450)
+ assert.equal((await progress(page)).selected, 'manta')
+ await expect(manta.getByRole('button', { name: 'Selected', exact: true })).toBeDisabled()
+ const comet = page.locator('.ship-card').filter({ has: page.getByRole('heading', { name: 'Comet', exact: true }) })
+ await comet.getByRole('button', { name: /400 light/ }).click()
+ assert.equal((await progress(page)).bank, 50)
+ assert.equal((await progress(page)).selected, 'comet')
+ await manta.getByRole('button', { name: 'Select skiff', exact: true }).click()
+ assert.equal((await progress(page)).bank, 50, 'Selecting an owned skiff must be free')
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ await page.reload({ waitUntil: 'networkidle' })
+ await page.clock.runFor(64)
+ const saved = await progress(page)
+ assert.equal(saved.selected, 'manta')
+ assert.deepEqual(saved.owned, ['sol', 'manta', 'comet'])
+ assert.equal(saved.bank, 50)
+ await page.getByTitle('Open the hangar', { exact: true }).click()
+ await expect(page.locator('.ship-selected h3')).toHaveText('Manta')
+ await page.getByRole('button', { name: 'Close dialog', exact: true }).click()
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ })
+
+ await scenario('audio-toggle-and-pause', { width: 1280, height: 950 }, async page => {
+ await page.getByRole('button', { name: 'Enable sound', exact: true }).click()
+ await expect(page.getByRole('button', { name: 'Mute sound', exact: true })).toHaveAttribute('aria-pressed', 'true')
+ assert.equal((await progress(page)).sound, true)
+ await fly(page)
+ await page.clock.runFor(1300)
+ await page.getByRole('button', { name: 'Pause flight', exact: true }).click()
+ await page.clock.runFor(250)
+ await expect(page.locator('.pause-card')).toBeVisible()
+ await page.getByRole('button', { name: 'Keep flying', exact: true }).click()
+ await page.clock.runFor(600)
+ await page.getByRole('button', { name: 'Mute sound', exact: true }).click()
+ await expect(page.getByRole('button', { name: 'Enable sound', exact: true })).toHaveAttribute('aria-pressed', 'false')
+ assert.equal((await progress(page)).sound, false)
+ await page.clock.runFor(500)
+ await page.reload({ waitUntil: 'networkidle' })
+ await page.clock.runFor(64)
+ await expect(page.getByRole('button', { name: 'Enable sound', exact: true })).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ for (const width of [320, 390]) {
+ await scenario(`mobile-${width}`, { width, height: 844 }, async page => {
+ const noOverflow = async label => {
+ const dimensions = await page.evaluate(() => ({
+ width: window.innerWidth,
+ document: document.documentElement.scrollWidth,
+ body: document.body.scrollWidth,
+ }))
+ assert.ok(dimensions.document <= dimensions.width + 1, `${label}: document overflow ${JSON.stringify(dimensions)}`)
+ assert.ok(dimensions.body <= dimensions.width + 1, `${label}: body overflow ${JSON.stringify(dimensions)}`)
+ }
+ await noOverflow('Welcome')
+ await fly(page)
+ const initial = await distance(page)
+ const dive = page.getByRole('button', { name: 'Hold to dive, release to soar', exact: true })
+ await expect(dive).toBeVisible()
+ const target = await dive.boundingBox()
+ assert.ok(target)
+ const touch = await page.context().newCDPSession(page)
+ await touch.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{ x: target.x + target.width / 2, y: target.y + target.height / 2, id: 1 }],
+ })
+ await page.clock.runFor(1600)
+ await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
+ await touch.detach()
+ await page.clock.runFor(1600)
+ assert.ok(await distance(page) > initial + 50, 'Touch play must advance distance')
+ await noOverflow('Playing')
+ await page.getByRole('button', { name: 'Pause flight', exact: true }).tap()
+ const paused = await distance(page)
+ await page.clock.runFor(1500)
+ assert.equal(await distance(page), paused)
+ await page.getByRole('button', { name: 'Keep flying', exact: true }).tap()
+ await page.clock.runFor(700)
+ await finish(page)
+ await noOverflow('Results')
+ await page.screenshot({ path: `${artifactDirectory}/mobile-${width}-verified.png`, fullPage: true })
+ }, true)
+ }
+} finally {
+ await browser.close()
+}
+
+if (scenariosRun === 0) failures.push('No browser scenarios matched SUNWAKE_CHECK')
+if (browserErrors.length) failures.push(`Browser console/runtime errors:\n${browserErrors.join('\n')}`)
+if (failures.length) {
+ console.error(`\n${failures.length} browser regression failure(s):\n${failures.join('\n\n')}`)
+ process.exitCode = 1
+} else {
+ console.log(process.env.SUNWAKE_CHECK
+ ? `Selected browser checks passed: ${process.env.SUNWAKE_CHECK}; clean browser console.`
+ : 'Browser checks passed: desktop and touch controls, pause/resume, one-time settlement, persistence, seeded daily flights, practice isolation, hangar, dialogs, audio controls, responsive widths, and clean browser console.')
+}
diff --git a/projects/sunwake-a6f3/qa/engine-check.mjs b/projects/sunwake-a6f3/qa/engine-check.mjs
new file mode 100644
index 00000000..b849f8e7
--- /dev/null
+++ b/projects/sunwake-a6f3/qa/engine-check.mjs
@@ -0,0 +1,128 @@
+// Run with Node 24+: node qa/engine-check.mjs
+// These checks exercise real simulation time, not implementation snapshots.
+import assert from 'node:assert/strict'
+import { SunwakeEngine, terrain, terrainSlope } from '../src/game/engine.ts'
+
+const options = { mode: 'voyage', ship: 'sol', seed: 42 }
+const input = { dive: false, boost: false }
+const run = (engine, seconds, controls = () => input, hz = 60) => {
+ for (let frame = 0; frame < seconds * hz; frame++) engine.step(1 / hz, controls(engine, frame / hz))
+}
+const create = (overrides = {}) => {
+ const engine = new SunwakeEngine({ ...options, ...overrides })
+ engine.start()
+ return engine
+}
+
+// Frame rate must not change a seeded run's outcome.
+const thirty = create()
+const sixty = create()
+const fast = create()
+run(thirty, 15, undefined, 30)
+run(sixty, 15, undefined, 60)
+run(fast, 15, undefined, 144)
+assert.deepEqual(thirty.state, sixty.state)
+assert.deepEqual(sixty.state, fast.state)
+
+// Pause freezes all simulation, while resuming advances normally.
+sixty.pause()
+const paused = structuredClone(sixty.state)
+run(sixty, 10)
+assert.deepEqual(sixty.state, paused)
+sixty.resume()
+sixty.step(1 / 60, input)
+assert.ok(sixty.state.distance > paused.distance)
+
+// Holding catches a dune; releasing is an immediate, responsive launch.
+const launched = create()
+run(launched, 0.1, () => ({ dive: true, boost: false }))
+assert.equal(launched.state.player.grounded, true)
+launched.step(1 / 60, input)
+assert.equal(launched.state.player.grounded, false)
+assert.ok(launched.state.player.vy < -200)
+
+// A charged burst spends exactly one charge per press and grants protection.
+const burst = create()
+burst.state.player.charge = 100
+burst.step(1 / 60, { dive: false, boost: true })
+assert.equal(burst.state.boosts, 1)
+assert.equal(burst.state.player.charge, 35)
+assert.ok(burst.state.player.boostTime > 2.7)
+assert.ok(burst.state.player.invincible > 2.7)
+run(burst, 0.3, () => ({ dive: false, boost: true }))
+assert.equal(burst.state.boosts, 1)
+
+// Forced contacts test pickups, damage grace, and the boost-through-obstacles path.
+const contact = create()
+contact.state.time = 9
+const touching = (kind, id) => ({
+ id, kind, x: contact.state.player.x + 3, y: contact.state.player.y,
+ radius: 24, phase: 0, collected: false,
+})
+contact.state.entities = [touching('rock', -1), touching('storm', -2)]
+contact.step(1 / 120, input)
+assert.ok(contact.state.player.energy > 74 && contact.state.player.energy < 76)
+assert.equal(contact.state.events.filter(event => event.kind === 'hit').length, 1)
+contact.state.entities = [touching('ring', -3)]
+contact.step(1 / 120, input)
+assert.equal(contact.state.rings, 1)
+assert.equal(contact.state.combo, 1)
+contact.state.player.charge = 100
+contact.state.entities = [touching('rock', -4)]
+contact.step(1 / 120, { ...input, boost: true })
+assert.equal(contact.state.entities[0].collected, true)
+assert.equal(contact.state.events.filter(event => event.kind === 'hit').length, 1)
+
+// Layout is independent of particles, input, and device frame rate.
+const courseA = create()
+const courseB = create()
+run(courseA, 10, () => ({ dive: true, boost: false }))
+run(courseB, 10, (_, time) => ({ dive: time % 1.5 < 0.75, boost: false }))
+const commonA = courseA.state.entities.filter(entity => entity.x > 5000 && entity.x < 6000)
+const commonB = courseB.state.entities.filter(entity => entity.x > 5000 && entity.x < 6000)
+assert.ok(commonA.length > 0)
+assert.deepEqual(commonA, commonB)
+
+// Sunlight ends Voyage, but Zen is genuinely endless, including obstacle contacts.
+const empty = create()
+empty.state.player.energy = 0.001
+empty.step(1 / 60, input)
+assert.equal(empty.state.phase, 'ended')
+const zen = create({ mode: 'zen' })
+zen.state.player.energy = 0
+run(zen, 240, engine => ({
+ dive: terrainSlope(engine.state.player.x, engine.state.seed) > 0.02,
+ boost: engine.state.player.charge >= 65,
+}))
+assert.equal(zen.state.phase, 'running')
+assert.equal(zen.state.player.energy, 100)
+assert.ok(zen.state.distance > 10000)
+assert.ok(zen.state.cleanLandings > 10)
+assert.ok(zen.state.boosts > 10)
+assert.ok(zen.state.entities.length < 60)
+assert.ok(zen.state.particles.length <= 240)
+assert.ok(zen.state.player.trail.length <= 50)
+assert.ok(zen.state.events.length <= 6)
+assert.ok(Number.isFinite(zen.state.player.x + zen.state.player.y + zen.state.score))
+
+// The advanced airtime challenge is attainable by bursting near a jump's apex.
+const soaring = create({ mode: 'zen', seed: 127 })
+let lowestY = 720
+run(soaring, 90, engine => {
+ const { player, seed } = engine.state
+ lowestY = Math.min(lowestY, player.y)
+ return {
+ dive: player.grounded && terrainSlope(player.x, seed) > -0.3,
+ boost: player.charge >= 65 && !player.grounded && player.vy > -120,
+ }
+})
+assert.ok(soaring.state.maxAirtime >= 3, 'The three-second flight mission must be achievable')
+assert.ok(lowestY >= 82, 'Keep the mast visible during high flights')
+zen.finish()
+assert.equal(zen.state.phase, 'ended')
+zen.reset(options)
+assert.equal(zen.state.phase, 'ready')
+assert.equal(zen.state.distance, 0)
+assert.equal(zen.state.player.y, terrain(200, options.seed) - 14)
+
+console.log('Engine checks passed: fixed clock, pause, launch, bursts, collision grace, seeded course, sunlight, advanced airtime, and four-minute bounded Zen run.')
diff --git a/projects/sunwake-a6f3/src/App.css b/projects/sunwake-a6f3/src/App.css
new file mode 100644
index 00000000..4a4625b1
--- /dev/null
+++ b/projects/sunwake-a6f3/src/App.css
@@ -0,0 +1,2471 @@
+.app-shell {
+ width: min(1456px, 100%);
+ margin: 0 auto;
+ padding: 0 48px;
+}
+.site-header {
+ height: 110px;
+ display: flex;
+ align-items: center;
+ gap: 31px;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ font-family: Bricolage, sans-serif;
+ font-weight: 700;
+ font-size: 35px;
+ letter-spacing: -1.8px;
+}
+.brand svg {
+ width: 37px;
+ color: #d77e53;
+}
+.brand-period {
+ color: #d77e53;
+}
+.header-tagline {
+ border-left: 1px solid #ded8d2;
+ padding-left: 26px;
+ font-size: 11px;
+ line-height: 1.6;
+ color: #92858c;
+}
+.header-tagline strong {
+ font-weight: 400;
+ color: #6d616b;
+}
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ margin-left: auto;
+}
+.personal-best {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.personal-best > svg {
+ color: #a38c80;
+}
+.personal-best > span {
+ font-size: 8px;
+ letter-spacing: 1.5px;
+ color: #8f838b;
+}
+.personal-best strong {
+ display: block;
+ color: #493d48;
+ font-family: Bricolage, sans-serif;
+ font-size: 20px;
+ letter-spacing: -0.5px;
+ margin-top: 3px;
+}
+.personal-best small {
+ font-size: 12px;
+ font-weight: 400;
+}
+.bank-pill {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 9px 13px;
+ border-radius: 30px;
+ background: #f0e8da;
+ color: #956633;
+ font-size: 12px;
+ font-weight: 600;
+}
+.icon-button {
+ display: grid;
+ place-items: center;
+ width: 37px;
+ height: 37px;
+ border-radius: 50%;
+ background: transparent;
+ transition: background 0.2s;
+}
+.icon-button:hover {
+ background: #eee7de;
+}
+.help-link {
+ background: transparent;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 10px 0 10px 12px;
+ border-left: 1px solid #ded8d2;
+}
+.help-link span {
+ font-size: 17px;
+ margin-left: 4px;
+}
+.preflight-line {
+ display: flex;
+ justify-content: space-between;
+ font-size: 8px;
+ font-weight: 600;
+ letter-spacing: 1.7px;
+ color: #a19796;
+ margin: 4px 3px 13px;
+}
+.preflight-line > span:first-child {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: #8a7d80;
+}
+.preflight-line i {
+ height: 5px;
+ width: 5px;
+ border-radius: 50%;
+ background: #c18256;
+}
+.flight-stage {
+ position: relative;
+ isolation: isolate;
+ height: clamp(510px, 43.5vw, 632px);
+ border-radius: 17px;
+ overflow: hidden;
+ background: #f5c897;
+ box-shadow: 0 2px 1px #8a545409;
+ touch-action: none;
+}
+.world-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+ outline: none;
+}
+.world-canvas:focus-visible {
+ box-shadow: inset 0 0 0 3px #5cb9b0;
+ border-radius: 17px;
+}
+.flight-stage:fullscreen {
+ width: 100vw;
+ height: 100dvh;
+ border-radius: 0;
+}
+.world-topline {
+ position: absolute;
+ left: 30px;
+ right: 22px;
+ top: 21px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ pointer-events: none;
+ z-index: 4;
+}
+.region-label {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: #68414b;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 1.6px;
+ font-weight: 600;
+}
+.region-label svg {
+ opacity: 0.7;
+}
+.daily-tag {
+ padding: 4px 6px;
+ background: #fff4d638;
+ border: 1px solid #76544c30;
+ border-radius: 4px;
+ font-size: 8px;
+ letter-spacing: 1px;
+}
+.world-tools {
+ display: flex;
+ gap: 8px;
+ pointer-events: auto;
+}
+.stage-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: #fff9e638;
+ display: grid;
+ place-items: center;
+ color: #694b53;
+ border: 1px solid #fff5e131;
+ transition: background 0.2s;
+}
+.stage-icon:hover {
+ background: #fff9e6a8;
+}
+.welcome-panel {
+ position: absolute;
+ left: 5%;
+ top: 17%;
+ max-width: 500px;
+ z-index: 2;
+}
+.eyebrow {
+ display: block;
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 1.8px;
+ color: #958489;
+}
+.welcome-eyebrow {
+ color: #805256;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 8px;
+ letter-spacing: 2.1px;
+ margin-bottom: 17px;
+}
+.welcome-eyebrow > span {
+ width: 21px;
+ height: 1px;
+ background: #80525670;
+}
+h1 {
+ font-family: Bricolage, sans-serif;
+ font-size: clamp(66px, 6.6vw, 94px);
+ font-weight: 700;
+ line-height: 0.98;
+ letter-spacing: -5.5px;
+ color: #4e3547;
+}
+h1 > span {
+ color: #754555;
+}
+.welcome-copy {
+ font-size: 13px;
+ color: #7e5057;
+ line-height: 1.75;
+ margin-top: 21px;
+}
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ min-height: 46px;
+ padding: 12px 22px;
+ border-radius: 7px;
+ font-size: 12px;
+ font-weight: 600;
+ transition:
+ transform 0.2s,
+ background 0.2s,
+ box-shadow 0.2s;
+}
+.button:hover:not(:disabled) {
+ transform: translateY(-2px);
+}
+.primary {
+ background: #423546;
+ color: #fff6e8;
+ box-shadow: 0 5px 15px #4e344318;
+}
+.primary:hover {
+ background: #5c4058;
+ box-shadow: 0 6px 16px #4e344326;
+}
+.secondary {
+ background: #eee7df;
+ color: #695265;
+ border: 1px solid #ded2d7;
+}
+.launch-button {
+ margin-top: 23px;
+ min-width: 177px;
+ justify-content: space-between;
+ padding-left: 24px;
+ height: 48px;
+ font-size: 13px;
+}
+.mode-selector {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 18px;
+ margin-left: -6px;
+}
+.mode-selector button {
+ background: transparent;
+ color: #785360;
+ font-size: 10px;
+ padding: 6px 9px;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+.mode-selector button:hover {
+ background: #fff5e627;
+}
+.mode-selector button.selected {
+ background: #fff5e63b;
+ color: #553e4e;
+ font-weight: 600;
+ box-shadow: inset 0 0 0 1px #74505a28;
+}
+.mode-description {
+ font-size: 9px;
+ color: #865e68;
+ margin-top: 6px;
+ max-width: 270px;
+ line-height: 1.5;
+}
+.daily-best {
+ font-size: 9px;
+ margin-top: 5px;
+ color: #553e4e;
+}
+.world-caption {
+ position: absolute;
+ right: 6%;
+ bottom: 12.5%;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ font-size: 10px;
+ line-height: 1.6;
+ color: #fff2d4bd;
+ letter-spacing: 0.3px;
+ pointer-events: none;
+}
+.world-caption strong {
+ font-weight: 400;
+ color: #fff4db;
+}
+.tiny-star {
+ font-size: 32px;
+ font-family: Georgia, serif;
+ color: #ffdeb4;
+}
+.start-hint {
+ position: absolute;
+ bottom: 23px;
+ left: 30px;
+ color: #fae8d7c2;
+ font-size: 9px;
+ letter-spacing: 0.2px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.start-hint kbd {
+ font-size: 8px;
+ padding: 4px 6px;
+ border-color: #fae8d74c;
+}
+.flight-guide {
+ display: flex;
+ align-items: center;
+ padding: 24px 8px 25px;
+ border-bottom: 1px solid #e9e0d8;
+ gap: 30px;
+}
+.guide-title {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1.3;
+}
+.small-caps {
+ font-size: 8px;
+ font-weight: 600;
+ letter-spacing: 1.4px;
+ color: #6e5f66;
+}
+.guide-title > span:last-child {
+ font-size: 10px;
+ color: #9a8b8e;
+}
+.guide-step {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex: 1;
+}
+.guide-step > svg {
+ color: #ae7b65;
+ flex-shrink: 0;
+}
+.guide-step > div {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+}
+.guide-step strong {
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.guide-step span {
+ font-size: 9px;
+ color: #9a8b8e;
+ white-space: nowrap;
+}
+.guide-step kbd {
+ font-size: 7px;
+ padding: 2px 4px;
+ margin-right: 3px;
+ color: #8d7d83;
+ border-color: #d9d0c9;
+ background: #fffcf7;
+}
+.guide-help {
+ background: transparent;
+ padding: 8px;
+ color: #ad9f9d;
+}
+.guide-help span {
+ width: 24px;
+ height: 24px;
+ border: 1px solid #d5cbc4;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ font-size: 12px;
+}
+.guide-help:hover {
+ color: #59444f;
+}
+.missions-section {
+ margin-top: 33px;
+}
+.section-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 4px 20px;
+}
+.section-heading .eyebrow {
+ font-size: 8px;
+ letter-spacing: 1.4px;
+ margin-bottom: 7px;
+}
+.section-heading h2 {
+ font-family: Bricolage, sans-serif;
+ font-size: 27px;
+ letter-spacing: -1px;
+ font-weight: 700;
+}
+.section-heading h2 > span {
+ color: #d18264;
+}
+.hangar-link {
+ display: flex;
+ align-items: center;
+ background: transparent;
+ gap: 8px;
+ padding: 0;
+}
+.hangar-link > span:last-child {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ font-size: 11px;
+}
+.hangar-link:hover > span:last-child {
+ color: #b47a63;
+}
+.hangar-ship {
+ width: 64px;
+}
+.ship-art {
+ width: 100%;
+ display: block;
+}
+.mission-list {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+}
+.mission-card {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ padding: 22px 18px;
+ border: 1px solid #e9e0d8;
+ border-radius: 9px;
+ position: relative;
+ background: #fffcf84d;
+}
+.mission-symbol {
+ height: 42px;
+ width: 42px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #f4e7d4;
+ color: #b08758;
+ flex-shrink: 0;
+}
+.symbol-1 {
+ background: #f0e4e2;
+ color: #b88388;
+}
+.symbol-2 {
+ background: #e7ece2;
+ color: #869a77;
+}
+.mission-content {
+ flex: 1;
+ min-width: 0;
+}
+.mission-content h3 {
+ font-family: Bricolage, sans-serif;
+ font-size: 13px;
+ font-weight: 700;
+ margin-bottom: 5px;
+ letter-spacing: -0.1px;
+}
+.mission-content p {
+ font-size: 10px;
+ color: #94878c;
+ line-height: 1.4;
+ max-width: 90%;
+}
+.mission-meter {
+ height: 3px;
+ background: #eee8e0;
+ margin-top: 12px;
+ border-radius: 4px;
+ overflow: hidden;
+ width: 90%;
+}
+.mission-meter > i {
+ display: block;
+ height: 100%;
+ background: #c3a17a;
+ border-radius: 4px;
+ transition: width 0.3s;
+}
+.mission-reward {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 9px;
+ color: #ac9475;
+ align-self: flex-start;
+ margin-top: 1px;
+ position: absolute;
+ right: 12px;
+ top: 14px;
+}
+.mission-achieved {
+ border-color: #adbd93;
+ background: #f2f4e8;
+}
+.mission-achieved .mission-symbol {
+ background: #e2ead5;
+ color: #6d805a;
+}
+.all-complete {
+ grid-column: 1/-1;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ padding: 25px;
+ background: #edf1e7;
+ border-radius: 9px;
+ color: #6d805a;
+}
+.all-complete p {
+ font-size: 12px;
+ margin-top: 7px;
+}
+.journey-section {
+ margin-top: 45px;
+}
+.journey-intro {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ padding: 0 5px;
+ margin-bottom: 21px;
+}
+.journey-intro > svg {
+ color: #bf9475;
+}
+.journey-intro h2 {
+ font-family: Bricolage, sans-serif;
+ font-weight: 700;
+ font-size: 18px;
+ letter-spacing: -0.5px;
+}
+.journey-intro p {
+ color: #9d8e94;
+ font-size: 10px;
+ margin-top: 5px;
+}
+.journey-best {
+ margin-left: auto;
+ color: #b1a4a5;
+ font-size: 7px;
+ letter-spacing: 1.4px;
+ text-align: right;
+ line-height: 2;
+}
+.journey-best strong {
+ display: block;
+ font-size: 10px;
+ letter-spacing: 0.3px;
+ color: #7f6d78;
+ font-weight: 400;
+}
+.region-map {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+}
+.map-region {
+ border-radius: 7px;
+ overflow: hidden;
+ opacity: 0.68;
+ transition: opacity 0.3s;
+ border: 1px solid #e9e0d8;
+}
+.map-region.discovered,
+.map-region:hover {
+ opacity: 1;
+}
+.landscape-mini {
+ height: 69px;
+ background: #f4d7af;
+ position: relative;
+ overflow: hidden;
+}
+.mini-sun {
+ position: absolute;
+ width: 36px;
+ height: 36px;
+ background: #eeb572;
+ border-radius: 50%;
+ left: 70%;
+ top: 8px;
+}
+.landscape-mini svg {
+ position: absolute;
+ bottom: 0;
+ height: 100%;
+ width: 100%;
+}
+.landscape-mini path:nth-child(1) {
+ fill: #dca986;
+}
+.landscape-mini path:nth-child(2) {
+ fill: #c38d79;
+}
+.landscape-mini path:nth-child(3) {
+ fill: #ac756d;
+}
+.region-1 .landscape-mini {
+ background: #f1cbd0;
+}
+.region-1 .mini-sun {
+ background: #e7b0b7;
+}
+.region-1 path:nth-child(1) {
+ fill: #dca2b5;
+}
+.region-1 path:nth-child(2) {
+ fill: #c588a4;
+}
+.region-1 path:nth-child(3) {
+ fill: #a76b90;
+}
+.region-2 .landscape-mini {
+ background: #d2c2e2;
+}
+.region-2 .mini-sun {
+ background: #e5d4ee;
+ width: 29px;
+ height: 29px;
+}
+.region-2 path:nth-child(1) {
+ fill: #ba9bc9;
+}
+.region-2 path:nth-child(2) {
+ fill: #a388ba;
+}
+.region-2 path:nth-child(3) {
+ fill: #816a9c;
+}
+.region-3 .landscape-mini {
+ background: #bfd5dd;
+}
+.region-3 .mini-sun {
+ background: #e2e8e0;
+ width: 24px;
+ height: 24px;
+}
+.region-3 path:nth-child(1) {
+ fill: #8ebbc5;
+}
+.region-3 path:nth-child(2) {
+ fill: #7ca2b3;
+}
+.region-3 path:nth-child(3) {
+ fill: #648797;
+}
+.map-region-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 13px 12px;
+ background: #fffcf8;
+}
+.map-region-label > span {
+ font-family: Georgia, serif;
+ font-size: 10px;
+ color: #b89b8a;
+}
+.map-region-label strong {
+ font-weight: 400;
+ font-size: 10px;
+ color: #6f5b69;
+}
+.map-region-label small {
+ font-size: 6px;
+ letter-spacing: 0.6px;
+ margin-left: auto;
+ color: #a2939c;
+}
+footer {
+ height: 106px;
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ color: #ac9fa1;
+ font-size: 9px;
+}
+.footer-brand {
+ font-family: Bricolage, sans-serif;
+ color: #776773;
+ font-size: 18px;
+ letter-spacing: -0.8px;
+}
+footer > span:last-child {
+ margin-left: auto;
+}
+.footer-star {
+ color: #be997f;
+ font-size: 18px;
+ margin-left: 5px;
+}
+.flight-hud {
+ position: absolute;
+ top: 77px;
+ left: 30px;
+ right: 30px;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ pointer-events: none;
+ color: #513b4d;
+ z-index: 2;
+}
+.hud-label {
+ font-size: 8px;
+ letter-spacing: 1.8px;
+ font-weight: 600;
+ display: block;
+}
+.distance-readout > strong {
+ font-family: Bricolage, sans-serif;
+ font-weight: 700;
+ font-size: 52px;
+ letter-spacing: -3px;
+ line-height: 1.2;
+ display: block;
+}
+.distance-readout > strong small {
+ font-size: 17px;
+ letter-spacing: 0;
+ font-weight: 400;
+ margin-left: 6px;
+ opacity: 0.65;
+}
+.run-pickups {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ margin-top: 5px;
+ color: #6b4753;
+}
+.run-pickups > span {
+ margin: 0 4px;
+ opacity: 0.4;
+}
+.run-pickups svg {
+ color: #ab773e;
+}
+.sunlight-readout {
+ width: 186px;
+ margin-top: 4px;
+ background: #fff2d825;
+ padding: 10px 12px;
+ border-radius: 6px;
+}
+.sunlight-readout .hud-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 7px;
+ letter-spacing: 1px;
+}
+.sunlight-readout b {
+ margin-left: auto;
+ font-weight: 400;
+ font-size: 9px;
+ letter-spacing: 0;
+}
+.energy-track {
+ height: 5px;
+ background: #75506a21;
+ border-radius: 6px;
+ overflow: hidden;
+ margin-top: 9px;
+}
+.energy-track > i {
+ height: 100%;
+ display: block;
+ background: #906146;
+ border-radius: 6px;
+ transition: width 0.15s;
+}
+.energy-track.low > i {
+ background: #bf4c50;
+}
+.energy-hint {
+ font-size: 7px;
+ display: block;
+ margin-top: 7px;
+ opacity: 0.7;
+ letter-spacing: 0.1px;
+}
+.combo-display {
+ position: absolute;
+ left: 50%;
+ top: 81px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform: translateX(-50%);
+ color: #74516a;
+ pointer-events: none;
+}
+.combo-display strong {
+ font-family: Bricolage, sans-serif;
+ font-size: 36px;
+ line-height: 1;
+ letter-spacing: -2px;
+}
+.combo-display span {
+ font-size: 7px;
+ letter-spacing: 3px;
+ margin-top: 5px;
+}
+.flight-event {
+ position: absolute;
+ left: 50%;
+ top: 35%;
+ transform: translateX(-50%);
+ background: #fff1cecf;
+ color: #795849;
+ padding: 9px 15px;
+ border: 1px solid #ffe8bcb3;
+ border-radius: 30px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 11px;
+ font-weight: 600;
+ box-shadow: 0 4px 20px #77435112;
+ pointer-events: none;
+ animation: event-in 0.25s ease both;
+ white-space: nowrap;
+}
+.event-hit {
+ background: #704058dc;
+ color: #ffe4d8;
+ border-color: #b26b7950;
+}
+.event-boost {
+ background: #d3f6e6d9;
+ color: #466d67;
+ border-color: #dcffdd9c;
+}
+.event-biome {
+ font-family: Bricolage, sans-serif;
+ top: 35%;
+ font-size: 25px;
+ padding: 14px 30px;
+ border: 0;
+ background: #fff2deab;
+ color: #61425e;
+}
+.inflight-tip {
+ position: absolute;
+ left: 50%;
+ bottom: 85px;
+ transform: translateX(-50%);
+ color: #fff4de;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 10px;
+ padding: 10px 15px;
+ border-radius: 6px;
+ background: #45324a50;
+ white-space: nowrap;
+ pointer-events: none;
+}
+.inflight-tip span {
+ opacity: 0.4;
+ margin: 0 4px;
+}
+.inflight-tip kbd {
+ border-color: #fff5de4d;
+ font-size: 8px;
+}
+.flight-bottom {
+ position: absolute;
+ left: 30px;
+ right: 30px;
+ bottom: 23px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+}
+.speed-readout {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #fae4ce;
+}
+.speed-readout strong {
+ font-family: Bricolage, sans-serif;
+ font-size: 22px;
+}
+.speed-readout span {
+ font-size: 9px;
+ opacity: 0.6;
+}
+.speed-readout svg {
+ opacity: 0.75;
+}
+.burst-button {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ padding: 13px 15px;
+ border-radius: 7px;
+ border: 1px solid #d1ddd34a;
+ color: #ffe7c9;
+ background: #49395199;
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 0.6px;
+ box-shadow: 0 3px 12px #32253f17;
+ transition: background 0.2s;
+}
+.burst-button:disabled {
+ opacity: 1;
+ cursor: default;
+}
+.burst-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: #a3c7b32e;
+ z-index: -1;
+ transition: width 0.12s;
+}
+.burst-button.charged {
+ border-color: #b7f6d7;
+ background: #c3f7e6;
+ color: #346860;
+ box-shadow: 0 0 25px #8debb43b;
+}
+.burst-button.charged:hover {
+ background: #ddfced;
+}
+.burst-button kbd {
+ font-size: 7px;
+ border-color: currentColor;
+ opacity: 0.6;
+ margin-left: 9px;
+}
+.touch-dive {
+ display: none;
+}
+.stage-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ display: grid;
+ place-items: center;
+ background: #33223c47;
+ backdrop-filter: blur(6px);
+ padding: 28px;
+}
+.pause-card {
+ text-align: center;
+ background: #faf5ecf5;
+ padding: 37px 56px;
+ border: 1px solid #fff4e7;
+ border-radius: 14px;
+ box-shadow: 0 20px 60px #35203824;
+}
+.pause-card h2 {
+ font-family: Bricolage, sans-serif;
+ font-size: 44px;
+ line-height: 1.08;
+ letter-spacing: -2px;
+ margin: 15px 0;
+}
+.pause-card p {
+ font-size: 12px;
+ color: #95818c;
+ margin-bottom: 25px;
+}
+.pause-card .eyebrow {
+ font-size: 8px;
+}
+.quiet-button {
+ display: block;
+ background: transparent;
+ margin: 16px auto 0;
+ color: #9b858e;
+ font-size: 10px;
+ padding: 7px;
+}
+.quiet-button:hover {
+ color: #614252;
+}
+.results-overlay {
+ backdrop-filter: blur(8px);
+ background: #38254042;
+}
+.results-card {
+ width: min(570px, 100%);
+ background: #faf5edf7;
+ padding: 30px 38px 24px;
+ border: 1px solid #fff1e6;
+ border-radius: 14px;
+ box-shadow: 0 20px 80px #34223b33;
+ text-align: center;
+}
+.result-kicker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ font-size: 8px;
+ letter-spacing: 1.3px;
+ color: #ad8158;
+}
+.results-card h2 {
+ font-family: Bricolage, sans-serif;
+ font-size: 29px;
+ letter-spacing: -1.3px;
+ margin-top: 12px;
+}
+.result-distance {
+ font-family: Bricolage, sans-serif;
+ font-size: 80px;
+ font-weight: 700;
+ line-height: 1.3;
+ letter-spacing: -5px;
+ color: #63495d;
+}
+.result-distance > span {
+ font-size: 24px;
+ letter-spacing: -1px;
+ margin-left: 7px;
+ color: #a7919b;
+}
+.result-reason {
+ font-size: 11px;
+ line-height: 1.5;
+ color: #9b8692;
+ max-width: 390px;
+ margin: auto;
+}
+.result-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ margin: 21px 0 19px;
+ border-top: 1px solid #e5dcd4;
+ border-bottom: 1px solid #e5dcd4;
+ padding: 17px 0;
+}
+.result-stats > div {
+ border-right: 1px solid #e5dcd4;
+}
+.result-stats > div:last-child {
+ border: 0;
+}
+.result-stats strong {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ font-family: Bricolage, sans-serif;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+}
+.result-stats > div:last-child strong {
+ color: #a87c44;
+}
+.result-stats span {
+ font-size: 6px;
+ letter-spacing: 0.8px;
+ color: #a2939c;
+ display: block;
+ margin-top: 5px;
+}
+.result-buttons {
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ margin-top: 16px;
+}
+.result-buttons .button {
+ font-size: 11px;
+ min-height: 44px;
+ padding: 12px 18px;
+ gap: 12px;
+}
+.saved-note {
+ display: block;
+ font-size: 8px;
+ color: #b19fa7;
+ margin-top: 14px;
+}
+.completed-missions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ color: #7d9170;
+}
+.completed-missions > span {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.completed-missions b {
+ font-size: 9px;
+ margin-left: 5px;
+ font-weight: 400;
+ color: #a38157;
+}
+.modal {
+ border: 1px solid #eee2d7;
+ border-radius: 17px;
+ background: #faf7f2;
+ box-shadow: 0 25px 100px #37233740;
+ padding: 0;
+ width: min(620px, calc(100% - 36px));
+ max-height: calc(100dvh - 48px);
+}
+.modal::backdrop {
+ background: #36253e66;
+ backdrop-filter: blur(5px);
+}
+.modal-inner {
+ padding: 41px;
+ position: relative;
+}
+.modal-close {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ color: #96848f;
+}
+.modal .eyebrow {
+ font-size: 8px;
+ color: #b48971;
+ margin-bottom: 13px;
+}
+.modal h2 {
+ font-family: Bricolage, sans-serif;
+ font-weight: 700;
+ font-size: 36px;
+ line-height: 1.08;
+ letter-spacing: -1.7px;
+}
+.modal-intro {
+ font-size: 12px;
+ line-height: 1.8;
+ color: #9a8692;
+ margin-top: 15px;
+}
+.help-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ margin: 32px 0;
+}
+.help-steps > div {
+ display: flex;
+ gap: 20px;
+ align-items: flex-start;
+}
+.help-steps > div > span {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background: #f1e6d8;
+ color: #ae805e;
+}
+.help-steps h3 {
+ font-family: Bricolage, sans-serif;
+ font-size: 16px;
+ letter-spacing: -0.4px;
+ margin: 2px 0 6px;
+}
+.help-steps p {
+ font-size: 11px;
+ line-height: 1.8;
+ color: #94818d;
+}
+.help-steps kbd,
+.help-note kbd {
+ font-size: 8px;
+ padding: 2px 4px;
+ color: #8e7785;
+ border-color: #d5c5cc;
+ white-space: nowrap;
+}
+.help-note {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ padding: 16px;
+ background: #e9ece2;
+ border-radius: 7px;
+ color: #849071;
+ margin-bottom: 26px;
+}
+.help-note > svg {
+ flex: none;
+}
+.help-note p {
+ font-size: 10px;
+ line-height: 1.8;
+}
+.help-note strong {
+ color: #6b795f;
+ font-weight: 600;
+}
+.hangar-modal {
+ width: min(820px, calc(100% - 36px));
+}
+.hangar-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+}
+.hangar-heading h2 > span {
+ color: #ca9370;
+}
+.hangar-bank {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: #f1e8d7;
+ padding: 10px 13px;
+ border-radius: 30px;
+ color: #a47b44;
+ font-size: 15px;
+}
+.hangar-bank > span {
+ font-size: 7px;
+ letter-spacing: 1px;
+ margin-left: 3px;
+}
+.ship-list {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 15px;
+ margin-top: 30px;
+}
+.ship-card {
+ border: 1px solid #e8dcd4;
+ border-radius: 10px;
+ overflow: hidden;
+ text-align: center;
+ padding-bottom: 17px;
+}
+.ship-selected {
+ border-color: #8cbcad;
+ background: #eef3eb;
+}
+.ship-display {
+ position: relative;
+ background: #f1e5d3;
+ padding: 17px 5px 0;
+}
+.ship-manta {
+ background: #ebe2f0;
+}
+.ship-comet {
+ background: #f3e0d3;
+}
+.ship-badge {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ font-size: 6px;
+ letter-spacing: 1px;
+ color: #6b9a8b;
+ border: 1px solid #acd1bb;
+ border-radius: 3px;
+ padding: 4px 5px;
+ background: #edffdd99;
+}
+.ship-card h3 {
+ font-family: Bricolage, sans-serif;
+ font-size: 22px;
+ letter-spacing: -1px;
+ margin: 16px 0 5px;
+}
+.ship-card p {
+ font-size: 9px;
+ line-height: 1.5;
+ color: #98858f;
+ margin: 0 9px 18px;
+ min-height: 27px;
+}
+.ship-card .button {
+ width: calc(100% - 30px);
+ padding: 10px 7px;
+ min-height: 37px;
+ font-size: 10px;
+ gap: 6px;
+}
+.selected-ship {
+ background: #dce9d8;
+ color: #6c926f;
+ opacity: 1 !important;
+}
+.hangar-note {
+ text-align: center;
+ font-size: 9px;
+ color: #ad9ba3;
+ margin-top: 22px;
+ line-height: 1.6;
+}
+.lifetime-stats {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ margin-top: 24px;
+ padding-top: 21px;
+ border-top: 1px solid #e9e0d8;
+ font-size: 9px;
+ color: #ac98a3;
+}
+.lifetime-stats strong {
+ color: #806678;
+ margin-right: 3px;
+}
+.notice {
+ position: fixed;
+ bottom: 25px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 50;
+ background: #423546;
+ color: #fff3df;
+ box-shadow: 0 6px 30px #35233826;
+ border-radius: 8px;
+ padding: 16px 23px;
+ max-width: calc(100% - 32px);
+ font-size: 12px;
+ line-height: 1.5;
+}
+.phase-running .region-label,
+.phase-paused .region-label {
+ color: #52394c;
+}
+.phase-running:has(.event-biome) .region-label {
+ opacity: 1;
+}
+@keyframes event-in {
+ from {
+ opacity: 0;
+ translate: 0 8px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+@media (min-width: 1500px) {
+ .welcome-panel {
+ top: 17.5%;
+ }
+ .welcome-copy {
+ margin-top: 24px;
+ }
+ .launch-button {
+ margin-top: 29px;
+ }
+ .mode-selector {
+ margin-top: 23px;
+ }
+ .world-caption {
+ bottom: 15%;
+ }
+}
+@media (max-width: 1150px) {
+ .app-shell {
+ padding: 0 30px;
+ }
+ .header-tagline {
+ display: none;
+ }
+ .flight-stage {
+ height: 550px;
+ }
+ .welcome-panel {
+ top: 18%;
+ left: 5%;
+ }
+ h1 {
+ font-size: 79px;
+ }
+ .welcome-copy {
+ font-size: 12px;
+ }
+ .flight-guide {
+ gap: 18px;
+ }
+ .guide-title {
+ flex: 1;
+ }
+ .guide-title > span:last-child {
+ font-size: 9px;
+ }
+ .guide-step {
+ gap: 8px;
+ }
+ .guide-step strong {
+ font-size: 10px;
+ }
+ .guide-step span {
+ font-size: 8px;
+ }
+ .guide-help {
+ display: none;
+ }
+ .mission-card {
+ padding: 19px 13px;
+ gap: 10px;
+ }
+ .mission-symbol {
+ height: 35px;
+ width: 35px;
+ }
+ .mission-content h3 {
+ font-size: 12px;
+ }
+ .mission-content p {
+ font-size: 9px;
+ }
+ .mission-reward {
+ top: 9px;
+ right: 8px;
+ font-size: 8px;
+ }
+ .map-region-label {
+ gap: 6px;
+ padding: 12px 9px;
+ }
+ .map-region-label small {
+ font-size: 5px;
+ }
+ .map-region-label strong {
+ font-size: 9px;
+ }
+}
+@media (max-width: 800px) {
+ .app-shell {
+ padding: 0 22px;
+ }
+ .site-header {
+ height: 92px;
+ }
+ .brand {
+ font-size: 30px;
+ gap: 8px;
+ }
+ .brand svg {
+ width: 32px;
+ }
+ .header-actions {
+ gap: 12px;
+ }
+ .help-link {
+ font-size: 10px;
+ padding-left: 12px;
+ }
+ .personal-best {
+ gap: 6px;
+ }
+ .personal-best > span {
+ font-size: 6px;
+ letter-spacing: 1px;
+ }
+ .personal-best strong {
+ font-size: 18px;
+ }
+ .preflight-line {
+ font-size: 7px;
+ letter-spacing: 1.1px;
+ }
+ .flight-stage {
+ height: 530px;
+ }
+ .welcome-panel {
+ top: 19%;
+ left: 6%;
+ }
+ h1 {
+ font-size: 72px;
+ letter-spacing: -4px;
+ }
+ .welcome-eyebrow {
+ font-size: 7px;
+ letter-spacing: 1.5px;
+ margin-bottom: 14px;
+ }
+ .welcome-copy {
+ font-size: 11px;
+ margin-top: 19px;
+ }
+ .launch-button {
+ margin-top: 23px;
+ height: 45px;
+ min-height: 45px;
+ min-width: 150px;
+ font-size: 12px;
+ }
+ .world-caption {
+ right: 5%;
+ bottom: 14%;
+ font-size: 8px;
+ gap: 6px;
+ }
+ .tiny-star {
+ font-size: 25px;
+ }
+ .flight-guide {
+ padding: 22px 2px;
+ gap: 22px;
+ justify-content: space-between;
+ }
+ .guide-title {
+ display: none;
+ }
+ .guide-step {
+ flex: none;
+ }
+ .guide-step > svg {
+ width: 21px;
+ }
+ .section-heading h2 {
+ font-size: 25px;
+ }
+ .mission-list {
+ gap: 9px;
+ }
+ .mission-card {
+ display: block;
+ padding: 17px 13px;
+ }
+ .mission-symbol {
+ height: 30px;
+ width: 30px;
+ margin-bottom: 11px;
+ }
+ .mission-symbol svg {
+ width: 18px;
+ }
+ .mission-content h3 {
+ font-size: 12px;
+ }
+ .mission-content p {
+ max-width: 100%;
+ font-size: 9px;
+ min-height: 25px;
+ }
+ .mission-meter {
+ width: 100%;
+ }
+ .mission-reward {
+ right: 13px;
+ top: 17px;
+ }
+ .journey-section {
+ margin-top: 32px;
+ }
+ .map-region-label {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 5px;
+ }
+ .map-region-label small {
+ grid-column: 2;
+ margin: 0;
+ font-size: 6px;
+ }
+ .journey-intro h2 {
+ font-size: 17px;
+ }
+ .journey-best {
+ font-size: 6px;
+ }
+ .journey-best strong {
+ font-size: 9px;
+ }
+ .header-actions .bank-pill {
+ font-size: 10px;
+ padding: 8px 9px;
+ }
+ .world-topline {
+ left: 22px;
+ right: 17px;
+ top: 18px;
+ }
+ .flight-hud {
+ left: 23px;
+ right: 23px;
+ }
+ .flight-bottom {
+ left: 23px;
+ right: 23px;
+ }
+ .touch-dive {
+ display: block;
+ }
+ .dive-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: #faefd72b;
+ color: #fef0d3;
+ border: 1px solid #fbe8ce3b;
+ border-radius: 7px;
+ padding: 12px;
+ font-size: 8px;
+ letter-spacing: 0.5px;
+ touch-action: none;
+ }
+ .dive-button:active {
+ background: #faefd755;
+ }
+ .burst-button {
+ font-size: 8px;
+ padding: 13px 10px;
+ }
+ .burst-button kbd {
+ display: none;
+ }
+ .speed-readout {
+ gap: 5px;
+ }
+ .speed-readout svg {
+ width: 17px;
+ }
+ .speed-readout strong {
+ font-size: 17px;
+ }
+ .speed-readout span {
+ font-size: 7px;
+ }
+ .inflight-tip {
+ font-size: 8px;
+ gap: 5px;
+ }
+ .inflight-tip kbd {
+ font-size: 6px;
+ }
+ .sunlight-readout {
+ width: 158px;
+ }
+ .distance-readout > strong {
+ font-size: 45px;
+ }
+ .region-map {
+ gap: 7px;
+ }
+ footer {
+ font-size: 8px;
+ gap: 14px;
+ height: 92px;
+ }
+}
+@media (max-width: 570px) {
+ .app-shell {
+ padding: 0 16px;
+ }
+ .site-header {
+ height: 82px;
+ gap: 0;
+ }
+ .brand {
+ font-size: 29px;
+ letter-spacing: -1.7px;
+ }
+ .brand svg {
+ width: 30px;
+ }
+ .header-actions {
+ gap: 9px;
+ }
+ .personal-best {
+ display: none;
+ }
+ .header-actions .bank-pill {
+ font-size: 10px;
+ padding: 7px 9px;
+ }
+ .header-actions .bank-pill svg {
+ width: 13px;
+ }
+ .help-link {
+ font-size: 0;
+ width: 26px;
+ padding: 0;
+ border: 0;
+ }
+ .help-link > span {
+ font-size: 20px;
+ margin: 0;
+ }
+ .icon-button.sound-button {
+ width: 29px;
+ height: 29px;
+ }
+ .sound-button svg {
+ width: 18px;
+ }
+ .preflight-line {
+ margin: 0 2px 10px;
+ font-size: 6px;
+ letter-spacing: 1.1px;
+ }
+ .preflight-line > span:last-child {
+ display: none;
+ }
+ .flight-stage {
+ height: 580px;
+ border-radius: 12px;
+ }
+ .world-topline {
+ left: 18px;
+ right: 12px;
+ top: 13px;
+ }
+ .region-label {
+ font-size: 8px;
+ letter-spacing: 1.1px;
+ gap: 7px;
+ }
+ .region-label svg {
+ width: 15px;
+ }
+ .stage-icon {
+ width: 29px;
+ height: 29px;
+ }
+ .welcome-panel {
+ top: 14.5%;
+ left: 8%;
+ right: 7%;
+ max-width: none;
+ }
+ .welcome-eyebrow {
+ font-size: 6px;
+ letter-spacing: 1.8px;
+ margin-bottom: 15px;
+ }
+ h1 {
+ font-size: clamp(58px, 16vw, 76px);
+ letter-spacing: -4px;
+ line-height: 1.01;
+ }
+ .welcome-copy {
+ font-size: 11px;
+ margin-top: 19px;
+ color: #754e58;
+ }
+ .launch-button {
+ margin-top: 24px;
+ font-size: 12px;
+ min-width: 165px;
+ height: 47px;
+ }
+ .mode-selector {
+ margin-top: 16px;
+ gap: 4px;
+ }
+ .mode-selector button {
+ font-size: 9px;
+ padding: 6px 9px;
+ }
+ .mode-description {
+ font-size: 8px;
+ max-width: 235px;
+ color: #725461;
+ }
+ .daily-best {
+ font-size: 8px;
+ }
+ .world-caption {
+ bottom: 51px;
+ right: 21px;
+ font-size: 8px;
+ color: #fff3dc;
+ gap: 8px;
+ }
+ .start-hint {
+ left: 22px;
+ bottom: 19px;
+ font-size: 7px;
+ color: #ffe8d7b5;
+ }
+ .start-hint kbd {
+ font-size: 6px;
+ }
+ .flight-guide {
+ gap: 10px;
+ padding: 20px 2px;
+ }
+ .guide-step {
+ flex: 1;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+ .guide-step > svg {
+ width: 22px;
+ height: 22px;
+ }
+ .guide-step strong {
+ font-size: 9px;
+ white-space: normal;
+ }
+ .guide-step > div {
+ gap: 6px;
+ }
+ .guide-step span {
+ font-size: 7px;
+ line-height: 1.5;
+ white-space: normal;
+ }
+ .guide-step kbd {
+ font-size: 6px;
+ padding: 1px 3px;
+ }
+ .guide-step:nth-child(4) {
+ flex: 1.1;
+ }
+ .missions-section {
+ margin-top: 28px;
+ }
+ .section-heading {
+ margin: 0 0 17px;
+ }
+ .section-heading .eyebrow {
+ font-size: 6px;
+ letter-spacing: 1px;
+ margin-bottom: 6px;
+ }
+ .section-heading h2 {
+ font-size: 23px;
+ letter-spacing: -1px;
+ }
+ .hangar-link {
+ gap: 0;
+ }
+ .hangar-ship {
+ display: none;
+ }
+ .hangar-link > span:last-child {
+ font-size: 9px;
+ gap: 7px;
+ }
+ .hangar-link svg {
+ width: 15px;
+ }
+ .mission-list {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+ .mission-card {
+ display: flex;
+ padding: 15px;
+ gap: 14px;
+ min-height: 82px;
+ }
+ .mission-symbol {
+ height: 35px;
+ width: 35px;
+ margin: 0;
+ }
+ .mission-content h3 {
+ font-size: 12px;
+ }
+ .mission-content p {
+ font-size: 9px;
+ min-height: 0;
+ max-width: 85%;
+ }
+ .mission-meter {
+ margin-top: 8px;
+ width: 86%;
+ }
+ .mission-reward {
+ right: 16px;
+ top: 16px;
+ font-size: 8px;
+ }
+ .journey-section {
+ margin-top: 29px;
+ }
+ .journey-intro {
+ padding: 0;
+ gap: 10px;
+ margin-bottom: 18px;
+ }
+ .journey-intro > svg {
+ width: 23px;
+ }
+ .journey-intro h2 {
+ font-size: 15px;
+ letter-spacing: -0.4px;
+ }
+ .journey-intro p {
+ font-size: 8px;
+ margin-top: 5px;
+ }
+ .journey-best {
+ display: none;
+ }
+ .region-map {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 9px;
+ }
+ .landscape-mini {
+ height: 53px;
+ }
+ .mini-sun {
+ width: 30px;
+ height: 30px;
+ }
+ .map-region-label {
+ padding: 10px 12px;
+ grid-template-columns: auto 1fr auto;
+ gap: 7px;
+ }
+ .map-region-label strong {
+ font-size: 8px;
+ }
+ .map-region-label small {
+ grid-column: 3;
+ font-size: 5px;
+ }
+ .map-region-label > span {
+ font-size: 9px;
+ }
+ footer {
+ height: auto;
+ padding: 28px 2px 24px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ column-gap: 13px;
+ row-gap: 7px;
+ font-size: 7px;
+ }
+ .footer-brand {
+ font-size: 16px;
+ grid-row: span 2;
+ }
+ footer > span:last-child {
+ margin: 0;
+ }
+ .footer-star {
+ font-size: 11px;
+ }
+ .flight-hud {
+ left: 20px;
+ right: 16px;
+ top: 65px;
+ }
+ .distance-readout > strong {
+ font-size: 39px;
+ letter-spacing: -2px;
+ }
+ .distance-readout > strong small {
+ font-size: 14px;
+ }
+ .hud-label {
+ font-size: 7px;
+ }
+ .run-pickups {
+ font-size: 8px;
+ margin-top: 3px;
+ gap: 4px;
+ }
+ .run-pickups svg {
+ width: 12px;
+ }
+ .sunlight-readout {
+ width: 139px;
+ margin-top: 0;
+ padding: 9px 9px;
+ }
+ .sunlight-readout .hud-label {
+ font-size: 6px;
+ letter-spacing: 0.7px;
+ }
+ .sunlight-readout b {
+ font-size: 8px;
+ }
+ .energy-hint {
+ font-size: 6px;
+ line-height: 1.5;
+ margin-top: 6px;
+ }
+ .combo-display {
+ top: 161px;
+ }
+ .combo-display strong {
+ font-size: 30px;
+ }
+ .combo-display span {
+ font-size: 6px;
+ }
+ .flight-event {
+ top: 39%;
+ font-size: 9px;
+ padding: 8px 12px;
+ }
+ .flight-event svg {
+ width: 14px;
+ }
+ .event-biome {
+ font-size: 18px;
+ }
+ .flight-bottom {
+ left: 16px;
+ right: 16px;
+ bottom: 21px;
+ gap: 9px;
+ flex-wrap: wrap;
+ }
+ .speed-readout {
+ position: absolute;
+ bottom: 63px;
+ left: 0;
+ }
+ .dive-button {
+ height: 44px;
+ font-size: 7px;
+ gap: 6px;
+ padding: 9px 12px;
+ }
+ .dive-button svg {
+ width: 18px;
+ }
+ .burst-button {
+ height: 44px;
+ font-size: 7px;
+ gap: 6px;
+ padding: 10px 9px;
+ max-width: 165px;
+ }
+ .burst-button svg {
+ width: 16px;
+ }
+ .inflight-tip {
+ bottom: 137px;
+ white-space: normal;
+ width: calc(100% - 40px);
+ justify-content: center;
+ flex-wrap: wrap;
+ line-height: 1.5;
+ font-size: 8px;
+ padding: 9px;
+ gap: 4px;
+ }
+ .inflight-tip span {
+ margin: 0 1px;
+ }
+ .stage-overlay {
+ padding: 15px;
+ }
+ .pause-card {
+ padding: 31px 29px;
+ width: 100%;
+ max-width: 330px;
+ }
+ .pause-card h2 {
+ font-size: 41px;
+ }
+ .results-card {
+ padding: 24px 17px 21px;
+ }
+ .result-kicker {
+ font-size: 6px;
+ letter-spacing: 1px;
+ gap: 6px;
+ }
+ .result-kicker svg {
+ width: 14px;
+ }
+ .results-card h2 {
+ font-size: 23px;
+ letter-spacing: -1px;
+ line-height: 1.2;
+ margin-top: 13px;
+ }
+ .result-distance {
+ font-size: 67px;
+ letter-spacing: -4px;
+ line-height: 1.4;
+ }
+ .result-distance > span {
+ font-size: 21px;
+ }
+ .result-reason {
+ font-size: 10px;
+ }
+ .result-stats {
+ margin: 19px 0 17px;
+ padding: 17px 0;
+ }
+ .result-stats strong {
+ font-size: 18px;
+ gap: 2px;
+ }
+ .result-stats span {
+ font-size: 5px;
+ letter-spacing: 0.5px;
+ line-height: 1.6;
+ }
+ .result-stats svg {
+ width: 12px;
+ }
+ .result-buttons {
+ flex-direction: column;
+ gap: 8px;
+ }
+ .result-buttons .button {
+ min-height: 42px;
+ }
+ .saved-note {
+ font-size: 7px;
+ margin-top: 15px;
+ }
+ .completed-missions {
+ font-size: 8px;
+ }
+ .modal-inner {
+ padding: 35px 24px 27px;
+ }
+ .modal {
+ max-height: calc(100dvh - 32px);
+ width: calc(100% - 26px);
+ }
+ .modal .eyebrow {
+ font-size: 6px;
+ letter-spacing: 1.3px;
+ }
+ .modal h2 {
+ font-size: 29px;
+ letter-spacing: -1.3px;
+ }
+ .modal-intro {
+ font-size: 11px;
+ }
+ .help-steps {
+ gap: 20px;
+ margin: 25px 0;
+ }
+ .help-steps > div {
+ gap: 14px;
+ }
+ .help-steps > div > span {
+ width: 35px;
+ height: 35px;
+ }
+ .help-steps > div > span svg {
+ width: 23px;
+ }
+ .help-steps h3 {
+ font-size: 14px;
+ }
+ .help-steps p {
+ font-size: 10px;
+ }
+ .help-note {
+ gap: 10px;
+ padding: 13px;
+ }
+ .help-note p {
+ font-size: 9px;
+ }
+ .modal .button {
+ min-height: 43px;
+ font-size: 11px;
+ }
+ .hangar-heading {
+ gap: 8px;
+ }
+ .hangar-bank {
+ font-size: 12px;
+ padding: 8px 9px;
+ gap: 3px;
+ }
+ .hangar-bank > span {
+ display: none;
+ }
+ .hangar-bank svg {
+ width: 16px;
+ }
+ .ship-list {
+ grid-template-columns: 1fr;
+ gap: 11px;
+ margin-top: 23px;
+ }
+ .ship-card {
+ display: grid;
+ grid-template-columns: 115px 1fr;
+ text-align: left;
+ padding: 0;
+ position: relative;
+ }
+ .ship-display {
+ grid-row: span 3;
+ padding: 20px 0;
+ display: flex;
+ align-items: center;
+ }
+ .ship-card h3 {
+ font-size: 20px;
+ margin: 15px 15px 3px;
+ }
+ .ship-card p {
+ font-size: 8px;
+ margin: 0 15px;
+ min-height: 0;
+ }
+ .ship-card .button {
+ margin: 12px 15px;
+ width: calc(100% - 30px);
+ min-height: 32px;
+ font-size: 9px;
+ padding: 7px;
+ }
+ .ship-badge {
+ right: auto;
+ left: 9px;
+ top: 8px;
+ font-size: 5px;
+ padding: 3px 5px;
+ }
+ .hangar-note {
+ font-size: 8px;
+ line-height: 1.8;
+ }
+ .lifetime-stats {
+ gap: 13px;
+ font-size: 7px;
+ margin-top: 20px;
+ padding-top: 18px;
+ }
+ .notice {
+ font-size: 10px;
+ padding: 13px 18px;
+ bottom: 16px;
+ width: max-content;
+ }
+}
+@media (max-width: 370px) {
+ .app-shell {
+ padding: 0 11px;
+ }
+ .brand {
+ font-size: 27px;
+ }
+ .header-actions {
+ gap: 7px;
+ }
+ .flight-stage {
+ height: 555px;
+ }
+ .welcome-panel {
+ left: 7%;
+ top: 15%;
+ }
+ .world-caption {
+ bottom: 42px;
+ }
+ .guide-step strong {
+ font-size: 8px;
+ }
+ .map-region-label {
+ gap: 4px;
+ padding: 10px 8px;
+ }
+ .flight-bottom {
+ left: 12px;
+ right: 12px;
+ }
+ .burst-button {
+ font-size: 6px;
+ }
+ .dive-button {
+ font-size: 6px;
+ padding: 9px;
+ }
+ .sunlight-readout {
+ width: 123px;
+ }
+ .result-stats span {
+ font-size: 4.5px;
+ }
+ .section-heading h2 {
+ font-size: 21px;
+ }
+ .hangar-link > span:last-child {
+ font-size: 8px;
+ }
+}
+@media (pointer: coarse) {
+ .touch-dive {
+ display: block;
+ }
+ .world-canvas {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+ .dive-button {
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+ .button:hover:not(:disabled) {
+ transform: none;
+ }
+}
+
+/* Quiet surfaces keep mode choices legible over every dune silhouette. */
+.mode-selector {
+ width: max-content;
+ padding: 3px;
+ background: #fbefdce8;
+ border: 1px solid #fff4e16b;
+ border-radius: 7px;
+ margin-left: 0;
+ gap: 2px;
+ box-shadow: 0 3px 12px #7343570b;
+}
+.mode-selector button {
+ font-size: 10px;
+ padding: 6px 12px;
+ color: #7b5a65;
+}
+.mode-selector button:hover {
+ background: #e1bdaf55;
+}
+.mode-selector button.selected {
+ background: #775368;
+ color: #fff1dd;
+ box-shadow: none;
+}
+.mode-description {
+ color: #fff0db;
+ text-shadow: 0 1px 4px #70405644;
+ font-size: 10px;
+}
+.daily-best {
+ color: #fff0db;
+ text-shadow: 0 1px 4px #70405644;
+}
+.night-world .flight-hud,
+.night-world .region-label,
+.night-world .combo-display {
+ color: #fff0db;
+}
+.night-world .run-pickups {
+ color: #f9e5d6;
+}
+.night-world .run-pickups svg {
+ color: #f6d68f;
+}
+.night-world .stage-icon {
+ color: #fff1dc;
+ background: #f5dcdf1a;
+ border-color: #f3dfdc26;
+}
+.night-world .energy-track {
+ background: #332a5542;
+}
+.night-world .energy-track > i {
+ background: #f4d490;
+}
+.night-world .energy-track.low > i {
+ background: #ed8f99;
+}
+.night-world .sunlight-readout {
+ background: #28284a20;
+}
+.night-world .combo-display {
+ text-shadow: 0 2px 10px #34315450;
+}
+.night-world .world-topline .daily-tag {
+ color: #ffe9d3;
+ border-color: #ffe6d133;
+}
+@media (max-width: 570px) {
+ .mode-selector button {
+ font-size: 9px;
+ padding: 6px 10px;
+ }
+ .mode-description {
+ font-size: 8px;
+ }
+ .daily-best {
+ font-size: 8px;
+ }
+}
diff --git a/projects/sunwake-a6f3/src/App.tsx b/projects/sunwake-a6f3/src/App.tsx
new file mode 100644
index 00000000..6ba2d76c
--- /dev/null
+++ b/projects/sunwake-a6f3/src/App.tsx
@@ -0,0 +1,1104 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { BrandMark, Icon, Modal, ShipArt } from './components'
+import { SunwakeEngine } from './game/engine'
+import { renderWorld } from './game/renderer'
+import { SunwakeAudio } from './game/audio'
+import {
+ dailySeed,
+ loadProgress,
+ MISSIONS,
+ saveProgress,
+ settleRun,
+ SHIPS,
+} from './game/progress'
+import type { Progress } from './game/progress'
+import { BIOMES } from './game/types'
+import type { GameInput, GameMode, GameState, ShipId } from './game/types'
+import './App.css'
+
+const today = () => new Date().toISOString().slice(0, 10)
+const randomSeed = () => Math.floor(Math.random() * 2147483646) + 1
+const number = (value: number) => Math.floor(value).toLocaleString('en-US')
+const snapshot = (state: GameState): GameState => ({
+ ...state,
+ player: { ...state.player },
+ events: [...state.events],
+})
+const MODES: { id: GameMode; label: string; description: string }[] = [
+ {
+ id: 'voyage',
+ label: 'Voyage',
+ description: 'An open horizon. A new adventure every time.',
+ },
+ {
+ id: 'daily',
+ label: 'Daily flight',
+ description: 'One shared landscape today. Beat your own best.',
+ },
+ {
+ id: 'zen',
+ label: 'Free flight',
+ description: 'Endless sunlight. No records or rewards. Just flow.',
+ },
+]
+
+export default function App() {
+ const [progress, setProgress] = useState(loadProgress)
+ const progressRef = useRef(progress)
+ const [engine] = useState(
+ () =>
+ new SunwakeEngine({
+ mode: 'voyage',
+ ship: progress.selected,
+ seed: 92847,
+ }),
+ )
+ const [audio] = useState(() => new SunwakeAudio())
+ const [view, setView] = useState(() => snapshot(engine.state))
+ const [mode, setMode] = useState('voyage')
+ const [modal, setModal] = useState<'help' | 'hangar' | null>(null)
+ const [result, setResult] = useState | null>(
+ null,
+ )
+ const [notice, setNotice] = useState('')
+ const [storageAvailable, setStorageAvailable] = useState(true)
+ const [reducedMotion, setReducedMotion] = useState(
+ () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ )
+ const canvasRef = useRef(null)
+ const stageRef = useRef(null)
+ const inputRef = useRef({ dive: false, boost: false })
+ const settledRef = useRef(false)
+ const runDateRef = useRef(today())
+ const [runMissions, setRunMissions] = useState(() =>
+ MISSIONS.filter(
+ (mission) => !progress.completed.includes(mission.id),
+ ).slice(0, 3),
+ )
+
+ const commitProgress = useCallback((next: Progress) => {
+ progressRef.current = next
+ setProgress(next)
+ setStorageAvailable(saveProgress(next))
+ }, [])
+
+ const pause = useCallback(() => {
+ inputRef.current = { dive: false, boost: false }
+ engine.pause()
+ setView(snapshot(engine.state))
+ }, [engine])
+
+ const startRun = useCallback(() => {
+ const date = today()
+ runDateRef.current = date
+ engine.reset({
+ mode,
+ ship: progressRef.current.selected,
+ seed: mode === 'daily' ? dailySeed(date) : randomSeed(),
+ })
+ inputRef.current = { dive: false, boost: false }
+ settledRef.current = false
+ setResult(null)
+ setRunMissions(
+ MISSIONS.filter(
+ (mission) => !progressRef.current.completed.includes(mission.id),
+ ).slice(0, 3),
+ )
+ engine.start()
+ audio.setEnabled(progressRef.current.sound)
+ void audio.unlock()
+ setView(snapshot(engine.state))
+ canvasRef.current?.focus({ preventScroll: true })
+ }, [audio, engine, mode])
+
+ const resume = useCallback(() => {
+ engine.resume()
+ setView(snapshot(engine.state))
+ canvasRef.current?.focus({ preventScroll: true })
+ }, [engine])
+
+ const goHome = () => {
+ engine.reset({ mode, ship: progressRef.current.selected, seed: 92847 })
+ inputRef.current = { dive: false, boost: false }
+ setResult(null)
+ setView(snapshot(engine.state))
+ }
+
+ const selectMode = (next: GameMode) => {
+ setMode(next)
+ engine.reset({
+ mode: next,
+ ship: progress.selected,
+ seed: next === 'daily' ? dailySeed(today()) : 92847,
+ })
+ setView(snapshot(engine.state))
+ }
+
+ const openModal = (next: 'help' | 'hangar') => {
+ if (engine.state.phase === 'running') pause()
+ setModal(next)
+ }
+
+ const toggleSound = () => {
+ const enabled = !progressRef.current.sound
+ audio.setEnabled(enabled)
+ void audio.unlock()
+ commitProgress({ ...progressRef.current, sound: enabled })
+ }
+
+ const selectShip = (id: ShipId) => {
+ const ship = SHIPS.find((item) => item.id === id)!
+ const current = progressRef.current
+ const owned = current.owned.includes(id)
+ if (!owned && current.bank < ship.price) return
+ const next = {
+ ...current,
+ bank: current.bank - (owned ? 0 : ship.price),
+ owned: owned ? current.owned : [...current.owned, id],
+ selected: id,
+ }
+ commitProgress(next)
+ if (engine.state.phase === 'ready') {
+ engine.reset({ mode, ship: id, seed: engine.state.seed })
+ setView(snapshot(engine.state))
+ }
+ }
+
+ const fullscreen = async () => {
+ try {
+ if (document.fullscreenElement) await document.exitFullscreen()
+ else if (stageRef.current?.requestFullscreen)
+ await stageRef.current.requestFullscreen()
+ else
+ setNotice(
+ 'Fullscreen is unavailable here. Try rotating your device for a wider horizon.',
+ )
+ } catch {
+ setNotice(
+ 'Fullscreen is unavailable here. Your flight is ready in this window.',
+ )
+ }
+ }
+
+ useEffect(() => {
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
+ const change = () => setReducedMotion(query.matches)
+ query.addEventListener('change', change)
+ return () => query.removeEventListener('change', change)
+ }, [])
+
+ useEffect(() => {
+ if (!notice) return
+ const timeout = window.setTimeout(() => setNotice(''), 4500)
+ return () => window.clearTimeout(timeout)
+ }, [notice])
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ const host = stageRef.current
+ if (!canvas || !host) return
+ const context = canvas.getContext('2d', { alpha: false })
+ if (!context) return
+ let width = 0
+ let height = 0
+ let dpr = 1
+ const resize = () => {
+ const bounds = host.getBoundingClientRect()
+ width = bounds.width
+ height = bounds.height
+ dpr = Math.min(window.devicePixelRatio || 1, 2)
+ canvas.width = Math.round(width * dpr)
+ canvas.height = Math.round(height * dpr)
+ }
+ const observer = new ResizeObserver(resize)
+ observer.observe(host)
+ resize()
+ let frame = 0
+ let previous = performance.now()
+ let lastHud = 0
+ const tick = (now: number) => {
+ const dt = Math.min((now - previous) / 1000, 0.05)
+ previous = now
+ engine.step(dt, inputRef.current)
+ audio.update(engine.state)
+ context.setTransform(dpr, 0, 0, dpr, 0, 0)
+ renderWorld(
+ context,
+ engine.state,
+ width,
+ height,
+ now / 1000,
+ reducedMotion,
+ progressRef.current.best,
+ )
+ if (engine.state.phase === 'ended' && !settledRef.current) {
+ settledRef.current = true
+ inputRef.current = { dive: false, boost: false }
+ const settled = settleRun(
+ progressRef.current,
+ engine.state,
+ runDateRef.current,
+ )
+ commitProgress(settled.progress)
+ setResult(settled)
+ setView(snapshot(engine.state))
+ } else if (now - lastHud > 90) {
+ setView(snapshot(engine.state))
+ lastHud = now
+ }
+ frame = requestAnimationFrame(tick)
+ }
+ frame = requestAnimationFrame(tick)
+ return () => {
+ cancelAnimationFrame(frame)
+ observer.disconnect()
+ }
+ }, [audio, commitProgress, engine, reducedMotion])
+
+ useEffect(() => {
+ const down = (event: KeyboardEvent) => {
+ if (modal || event.ctrlKey || event.metaKey || event.altKey) return
+ const target = event.target as HTMLElement
+ if (target.closest('input, select, textarea, [contenteditable=true]'))
+ return
+ if (event.code === 'Space' && target.closest('button, a')) return
+ if (
+ [
+ 'Space',
+ 'ArrowDown',
+ 'ArrowUp',
+ 'ShiftLeft',
+ 'ShiftRight',
+ 'Escape',
+ 'KeyP',
+ ].includes(event.code)
+ )
+ event.preventDefault()
+ if (event.repeat) return
+ if (event.code === 'Space' || event.code === 'ArrowDown') {
+ if (engine.state.phase === 'ready' || engine.state.phase === 'ended')
+ startRun()
+ if (engine.state.phase === 'running') inputRef.current.dive = true
+ }
+ if (
+ event.code === 'ShiftLeft' ||
+ event.code === 'ShiftRight' ||
+ event.code === 'ArrowUp'
+ )
+ inputRef.current.boost = true
+ if (event.code === 'Escape' || event.code === 'KeyP') {
+ if (engine.state.phase === 'running') pause()
+ else if (engine.state.phase === 'paused') resume()
+ }
+ if (event.code === 'KeyR' && engine.state.phase === 'ended') startRun()
+ }
+ const up = (event: KeyboardEvent) => {
+ if (event.code === 'Space' || event.code === 'ArrowDown')
+ inputRef.current.dive = false
+ if (
+ event.code === 'ShiftLeft' ||
+ event.code === 'ShiftRight' ||
+ event.code === 'ArrowUp'
+ )
+ inputRef.current.boost = false
+ }
+ const blur = () => {
+ if (engine.state.phase === 'running') pause()
+ inputRef.current = { dive: false, boost: false }
+ }
+ const visibility = () => {
+ if (document.hidden) blur()
+ }
+ window.addEventListener('keydown', down)
+ window.addEventListener('keyup', up)
+ window.addEventListener('blur', blur)
+ document.addEventListener('visibilitychange', visibility)
+ return () => {
+ window.removeEventListener('keydown', down)
+ window.removeEventListener('keyup', up)
+ window.removeEventListener('blur', blur)
+ document.removeEventListener('visibilitychange', visibility)
+ }
+ }, [engine, modal, pause, resume, startRun])
+
+ useEffect(() => () => audio.dispose(), [audio])
+
+ const playing = view.phase === 'running'
+ const inRun = playing || view.phase === 'paused'
+ const biome = BIOMES[view.biome % BIOMES.length]
+ const nextMissions =
+ view.phase === 'ready' || view.phase === 'ended'
+ ? MISSIONS.filter((m) => !progress.completed.includes(m.id)).slice(0, 3)
+ : runMissions
+ const lastEvent = [...view.events]
+ .reverse()
+ .find((event) => event.kind !== 'spark' && view.time - event.time < 2.2)
+ const boostReady = view.player.charge >= 65
+ const modeDescription = MODES.find((item) => item.id === mode)!.description
+
+ return (
+
+
+
+
+
+
+ MADE FOR ONE MORE RUN
+
+ NO DOWNLOAD. JUST A LITTLE DAYDREAM.
+
+ = 2 || (view.biome === 1 && view.distance % 1000 > 800) ? 'night-world' : ''} ${reducedMotion ? 'reduced-motion' : ''}`}
+ ref={stageRef}
+ aria-label="Sunwake game"
+ >
+ {
+ if (engine.state.phase !== 'running') return
+ event.preventDefault()
+ event.currentTarget.focus({ preventScroll: true })
+ event.currentTarget.setPointerCapture(event.pointerId)
+ inputRef.current.dive = true
+ }}
+ onPointerUp={() => {
+ inputRef.current.dive = false
+ }}
+ onPointerCancel={() => {
+ inputRef.current.dive = false
+ }}
+ onLostPointerCapture={() => {
+ inputRef.current.dive = false
+ }}
+ >
+ Sunwake is an interactive dune-surfing game. Your browser needs
+ canvas support to play.
+
+
+
+ 1 ? 'moon' : 'sun'} size={17} />
+ {biome.short}
+ {mode === 'daily' && DAILY }
+ {mode === 'zen' && FREE FLIGHT }
+
+
+ {inRun && (
+
+
+
+ )}
+ void fullscreen()}
+ >
+
+
+
+
+
+ {view.phase === 'ready' && (
+
+
+ THE HORIZON IS CALLING
+
+
+ Ride the
+
+ last light.
+
+
+ A little courage. A little sunlight.
+
+ See how far the wind will take you.
+
+
+ Let’s fly
+
+
+ {MODES.map((item) => (
+ selectMode(item.id)}
+ aria-pressed={mode === item.id}
+ >
+ {item.label}
+
+ ))}
+
+
{modeDescription}
+ {mode === 'daily' && (
+
+ {today()} · Your daily best:{' '}
+ {number(
+ progress.daily.date === today() ? progress.daily.best : 0,
+ )}{' '}
+ m
+
+ )}
+
+ )}
+ {view.phase === 'ready' && (
+
+ ✧
+
+ Find your rhythm.
+
+ Leave a little stardust.
+
+
+ )}
+ {view.phase === 'ready' && (
+
+ SPACE or tap Let’s fly to begin
+
+ )}
+
+ {inRun && (
+ <>
+
+
+ DISTANCE
+
+ {number(view.distance)}
+ m
+
+
+
+ {view.sparks}
+ ·
+ {number(view.score)} pts
+
+
+
+
+ SUNLIGHT{' '}
+
+ {mode === 'zen'
+ ? '∞'
+ : `${Math.ceil(view.player.energy)}%`}
+
+
+
+
+
+
+ {mode === 'zen'
+ ? 'The sun is yours. Take your time.'
+ : 'Collect light. Keep the dream alive.'}
+
+
+
+
+ {view.combo > 1 && (
+ <>
+ ×{view.combo}
+ FLOW
+ >
+ )}
+
+ {lastEvent && (
+
+ {lastEvent.kind === 'hit' ? (
+
+ ) : (
+
+ )}
+ {lastEvent.text}
+
+ )}
+ {playing && view.distance < 180 && (
+
+ HOLD dive into the slope · {' '}
+ RELEASE catch the sky
+
+ )}
+
+
+
+ {Math.round(view.player.vx * 0.36)}
+ km/h
+
+
+ {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ event.preventDefault()
+ inputRef.current.dive = true
+ }
+ }}
+ onKeyUp={(event) => {
+ if (event.code === 'Space' || event.code === 'Enter') {
+ event.preventDefault()
+ inputRef.current.dive = false
+ }
+ }}
+ onBlur={() => {
+ inputRef.current.dive = false
+ }}
+ onPointerDown={(event) => {
+ event.preventDefault()
+ event.currentTarget.setPointerCapture(event.pointerId)
+ inputRef.current.dive = true
+ }}
+ onPointerUp={() => {
+ inputRef.current.dive = false
+ }}
+ onPointerCancel={() => {
+ inputRef.current.dive = false
+ }}
+ onLostPointerCapture={() => {
+ inputRef.current.dive = false
+ }}
+ >
+ HOLD TO DIVE
+
+
+
{
+ inputRef.current.boost = true
+ window.setTimeout(() => {
+ inputRef.current.boost = false
+ }, 150)
+ canvasRef.current?.focus({ preventScroll: true })
+ }}
+ aria-label={`Solar burst, ${Math.min(100, Math.round((view.player.charge / 65) * 100))}% ready`}
+ >
+
+
+
+ {view.player.boostTime > 0
+ ? 'SUNWAKE!'
+ : boostReady
+ ? 'SOLAR BURST'
+ : 'BUILD YOUR FLOW'}
+
+ SHIFT
+
+
+ >
+ )}
+
+ {view.phase === 'paused' && (
+
+
+
A MOMENT IN THE SUN
+
+ The horizon
+
+ can wait.
+
+
Your flight is paused at {number(view.distance)} m.
+
+ Keep flying
+
+
engine.finish()}
+ >
+ End this flight
+
+
+
+ )}
+
+ {view.phase === 'ended' && result && (
+
+
+
+
+ {result.newBest
+ ? 'A NEW PERSONAL HORIZON'
+ : mode === 'zen'
+ ? 'A LITTLE TIME WELL SPENT'
+ : 'EVERY FLIGHT IS A NEW STORY'}
+
+
+ {result.newBest
+ ? 'Look how far you flew.'
+ : 'The sky will miss you.'}
+
+
+ {number(view.distance)}
+ m
+
+
+ {view.reason || 'The light fades. Another horizon awaits.'}
+
+
+
+ {number(view.score)}
+ FLIGHT SCORE
+
+
+ ×{Math.max(1, view.bestCombo)}
+ BEST FLOW
+
+
+ {view.cleanLandings}
+ CLEAN LANDINGS
+
+
+
+ +{result.earned}
+
+
+ LIGHT EARNED
+
+
+ {result.completed.length > 0 && (
+
+ {result.completed.map((mission) => (
+
+
+ {mission.title}
+ +{mission.reward}
+
+ ))}
+
+ )}
+
+
+ One more horizon
+
+
+ Back to shore
+
+
+
+ {mode === 'zen'
+ ? 'Free flight: no records or rewards. Just the joy of flying.'
+ : storageAvailable
+ ? 'Your journey is saved on this device.'
+ : 'Storage is unavailable. Your journey lasts for this session.'}
+
+
+
+ )}
+
+
+
+
+ THE ART OF FLIGHT
+ Easy to learn. Lovely to master.
+
+
+
+
+ Hold to dive
+
+ SPACE or touch & hold
+
+
+
+
+
+
+ Release to soar
+ Catch a slope. Find your flow.
+
+
+
+
+
+ Build a solar burst
+ Clean landings + sun rings
+
+
+
openModal('help')}
+ aria-label="Read the flight guide"
+ >
+ ?
+
+
+
+
+
+
+ SMALL WINS. BIG ADVENTURES.
+
+ Your next horizons.
+
+
+
openModal('hangar')}>
+
+
+
+
+ The hangar
+
+
+
+
+ {nextMissions.length === 0 ? (
+
+
+
+
Every horizon, discovered.
+
+ You’ve completed every challenge. How far can your next
+ flight go?
+
+
+
+ ) : (
+ nextMissions.map((mission, index) => {
+ const value =
+ inRun && mode !== 'zen'
+ ? Math.min(view[mission.kind] as number, mission.target)
+ : 0
+ const completed = value >= mission.target
+ return (
+
+
+
+
+
+
{mission.title}
+
{mission.description}
+
+
+
+
+
+ +{mission.reward}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+
+
There’s a whole world out there.
+
One endless journey. Four shades of wonder.
+
+
+ FURTHEST HORIZON{' '}
+
+ {BIOMES[Math.min(3, Math.floor(progress.best / 1000))].short}
+
+
+
+
+ {BIOMES.map((region, index) => (
+
= index * 1000 ? 'discovered' : ''}`}
+ key={region.name}
+ >
+
+
+ {region.mark}
+ {region.short}
+
+ {index === 0
+ ? 'THE BEGINNING'
+ : `${number(index * 1000)} M`}
+
+
+
+ ))}
+
+
+
+
+ sunwake.
+ Chase the light. Enjoy the in-between.
+
+ Made for the joy of it ✧
+
+
+
+ {modal === 'help' && (
+
setModal(null)}>
+ A FIELD GUIDE TO THE SKY
+
+ A little less gravity.
+ A little more wonder.
+
+
+ Your skiff follows the dunes. You decide when to let go.
+
+
+
+
+
+
+
+
Lean into the descent
+
+ Hold SPACE , ↓ , or touch the sky to dive.
+ Hold on a downhill slope to build speed.
+
+
+
+
+
+
+
+
+
Let the horizon lift you
+
+ Release to soar from the dunes. A clean landing on a downhill
+ slope keeps your momentum and builds your flow multiplier.
+
+
+
+
+
+
+
+
+
Follow the light
+
+ Golden sparks and sun rings refill sunlight. Avoid dark rocks
+ and electric storms; a hit costs sunlight and breaks your
+ flow.
+
+
+
+
+
+
+
+
+
Become a little comet
+
+ Fill your burst meter with pickups and clean landings. Burst
+ near the top of a jump to stay aloft. Press SHIFT ,{' '}
+ ↑ , or the burst button for a rush of speed and
+ protection.
+
+
+
+
+
+
+
+ Find your rhythm in Free flight : unlimited
+ sunlight, no records or rewards. Press P or{' '}
+ ESC to pause any flight.
+
+
+ setModal(null)}>
+ The sky is yours
+
+
+ )}
+
+ {modal === 'hangar' && (
+
setModal(null)}
+ >
+ A SKIFF TO CALL YOUR OWN
+
+
+ The hangar.
+
+
+
+ {number(progress.bank)}
+ LIGHT
+
+
+
+ A different silhouette. The same free spirit.
+
+ Collect light and complete challenges to make one yours.
+
+
+ {SHIPS.map((ship) => {
+ const owned = progress.owned.includes(ship.id)
+ const selected = progress.selected === ship.id
+ return (
+
+
+
+ {selected && YOUR SKIFF }
+
+
{ship.name}
+
{ship.subtitle}
+
selectShip(ship.id)}
+ >
+ {selected ? (
+ <>
+
+ Selected
+ >
+ ) : owned ? (
+ 'Select skiff'
+ ) : (
+ <>
+ = ship.price ? 'spark' : 'lock'}
+ size={15}
+ />
+ {ship.price} light
+ >
+ )}
+
+
+ )
+ })}
+
+
+ All skiffs fly alike. Style is the only advantage.{' '}
+ {inRun && 'Your selection takes flight next run.'}
+
+
+
+ {number(progress.totalRuns)} flights
+
+
+ {number(progress.totalDistance / 1000)} km
+ travelled
+
+
+
+ {progress.completed.length}/{MISSIONS.length}
+ {' '}
+ challenges
+
+
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ )
+}
diff --git a/projects/sunwake-a6f3/src/components.tsx b/projects/sunwake-a6f3/src/components.tsx
new file mode 100644
index 00000000..a1c986dd
--- /dev/null
+++ b/projects/sunwake-a6f3/src/components.tsx
@@ -0,0 +1,233 @@
+import { useEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import type { ShipId } from './game/types'
+
+type IconName =
+ | 'sun'
+ | 'arrow'
+ | 'spark'
+ | 'sound'
+ | 'mute'
+ | 'pause'
+ | 'play'
+ | 'expand'
+ | 'close'
+ | 'flag'
+ | 'wind'
+ | 'check'
+ | 'lock'
+ | 'moon'
+ | 'dive'
+ | 'soar'
+ | 'ring'
+ | 'restart'
+
+export function Icon({
+ name,
+ size = 20,
+ className = '',
+}: {
+ name: IconName
+ size?: number
+ className?: string
+}) {
+ const paths: Record = {
+ sun: (
+ <>
+
+
+ >
+ ),
+ arrow: ,
+ spark: ,
+ sound: (
+ <>
+
+ >
+ ),
+ mute: (
+ <>
+
+ >
+ ),
+ pause: (
+ <>
+
+ >
+ ),
+ play: ,
+ expand: ,
+ close: ,
+ flag: ,
+ wind: (
+
+ ),
+ check: ,
+ lock: (
+ <>
+
+
+ >
+ ),
+ moon: ,
+ dive: ,
+ soar: ,
+ ring: (
+ <>
+
+
+ >
+ ),
+ restart: (
+ <>
+
+ >
+ ),
+ }
+ return (
+
+ {paths[name]}
+
+ )
+}
+
+export function BrandMark() {
+ return (
+
+
+
+
+
+ )
+}
+
+export function ShipArt({
+ ship,
+ large = false,
+}: {
+ ship: ShipId
+ large?: boolean
+}) {
+ const colors = {
+ sol: ['#77d3c7', '#b7eee1'],
+ manta: ['#b798db', '#dfcbf3'],
+ comet: ['#ec8a67', '#ffe0a6'],
+ }
+ const [main, light] = colors[ship]
+ return (
+
+
+
+ {ship === 'manta' ? (
+ <>
+
+
+ >
+ ) : ship === 'comet' ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export function Modal({
+ title,
+ children,
+ onClose,
+ className = '',
+}: {
+ title: string
+ children: ReactNode
+ onClose: () => void
+ className?: string
+}) {
+ const ref = useRef(null)
+ useEffect(() => {
+ const dialog = ref.current
+ dialog?.showModal()
+ return () => dialog?.close()
+ }, [])
+ return (
+ {
+ event.preventDefault()
+ onClose()
+ }}
+ onClick={(event) => {
+ if (event.target === event.currentTarget) onClose()
+ }}
+ >
+
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/projects/sunwake-a6f3/src/game/audio.ts b/projects/sunwake-a6f3/src/game/audio.ts
new file mode 100644
index 00000000..acaec308
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/audio.ts
@@ -0,0 +1,238 @@
+import type { EventKind, GameState } from './types'
+
+/** A small, gesture-unlocked instrument: no downloads, timers, or unbounded voices. */
+export class SunwakeAudio {
+ private context: AudioContext | null = null
+ private master: GainNode | null = null
+ private windGain: GainNode | null = null
+ private windFilter: BiquadFilterNode | null = null
+ private continuous: AudioScheduledSourceNode[] = []
+ private voices = new Set()
+ private enabled = false
+ private running = false
+ private disposed = false
+ private lastEventId = -1
+ private lastGameTime = -1
+ private lastUpdate = -1
+ private lastSparks = 0
+
+ async unlock(): Promise {
+ // React StrictMode deliberately disposes and reuses the instance in development.
+ // A new user gesture may safely revive it after that cleanup.
+ this.disposed = false
+ try {
+ if (!this.context) this.create()
+ if (this.enabled && this.context?.state === 'suspended')
+ await this.context.resume()
+ } catch {
+ // Sound is optional; browser audio policies must never interrupt a run.
+ }
+ }
+
+ setEnabled(enabled: boolean): void {
+ this.enabled = enabled
+ const context = this.context
+ if (!context || this.disposed) return
+ const now = context.currentTime
+ this.master?.gain.cancelScheduledValues(now)
+ this.master?.gain.setValueAtTime(enabled && this.running ? 0.5 : 0, now)
+ if (!enabled) {
+ for (const voice of this.voices) {
+ try {
+ voice.stop()
+ } catch {
+ /* A voice may already have reached its stop time. */
+ }
+ }
+ this.voices.clear()
+ if (context.state === 'running') void context.suspend().catch(() => {})
+ } else if (context.state === 'suspended') {
+ void context.resume().catch(() => {})
+ }
+ }
+
+ update(state: GameState): void {
+ if (this.disposed) return
+ if (state.time < this.lastGameTime) this.lastEventId = -1
+ this.lastGameTime = state.time
+ const collectedSpark = state.sparks > this.lastSparks
+ this.lastSparks = state.sparks
+ const freshEvents = state.events.filter(
+ (event) => event.id > this.lastEventId,
+ )
+ for (const event of freshEvents)
+ this.lastEventId = Math.max(this.lastEventId, event.id)
+ const running = state.phase === 'running'
+ const context = this.context
+ if (context && this.master && running !== this.running) {
+ const now = context.currentTime
+ this.master.gain.cancelScheduledValues(now)
+ this.master.gain.setTargetAtTime(
+ this.enabled && running ? 0.5 : 0,
+ now,
+ 0.06,
+ )
+ }
+ this.running = running
+ if (!this.enabled || !context || context.state !== 'running') return
+ if (running && context.currentTime - this.lastUpdate >= 0.12) {
+ this.lastUpdate = context.currentTime
+ const speed = Math.min(
+ 1,
+ Math.max(0, Math.hypot(state.player.vx, state.player.vy) / 600),
+ )
+ const now = context.currentTime
+ this.windGain?.gain.cancelScheduledValues(now)
+ this.windGain?.gain.setTargetAtTime(0.035 + speed * 0.11, now, 0.14)
+ this.windFilter?.frequency.cancelScheduledValues(now)
+ this.windFilter?.frequency.setTargetAtTime(250 + speed * 1050, now, 0.2)
+ }
+ if (running) {
+ if (collectedSpark) this.event('spark', state.combo)
+ for (const event of freshEvents.slice(-6))
+ this.event(event.kind, state.combo)
+ }
+ }
+
+ dispose(): void {
+ if (this.disposed) return
+ this.disposed = true
+ this.enabled = false
+ for (const source of [...this.continuous, ...this.voices]) {
+ try {
+ source.stop()
+ } catch {
+ /* Sources have a single lifetime. */
+ }
+ source.disconnect()
+ }
+ this.continuous = []
+ this.voices.clear()
+ this.master?.disconnect()
+ this.windGain?.disconnect()
+ this.windFilter?.disconnect()
+ if (this.context && this.context.state !== 'closed')
+ void this.context.close().catch(() => {})
+ this.context = null
+ this.master = null
+ this.windGain = null
+ this.windFilter = null
+ }
+
+ private create(): void {
+ const context = new AudioContext()
+ this.lastUpdate = -1
+ this.context = context
+ this.master = context.createGain()
+ this.master.gain.value = this.enabled && this.running ? 0.5 : 0
+ this.master.connect(context.destination)
+ const noise = context.createBuffer(
+ 1,
+ context.sampleRate * 3,
+ context.sampleRate,
+ )
+ const data = noise.getChannelData(0)
+ let previous = 0
+ for (let i = 0; i < data.length; i++) {
+ previous = (previous + (Math.random() * 2 - 1) * 0.025) / 1.025
+ data[i] = previous * 5
+ }
+ const wind = context.createBufferSource()
+ wind.buffer = noise
+ wind.loop = true
+ this.windFilter = context.createBiquadFilter()
+ this.windFilter.type = 'lowpass'
+ this.windFilter.frequency.value = 500
+ this.windFilter.Q.value = 0.5
+ this.windGain = context.createGain()
+ this.windGain.gain.value = 0.05
+ wind.connect(this.windFilter).connect(this.windGain).connect(this.master)
+ wind.start()
+ this.continuous.push(wind)
+ for (const [frequency, volume] of [
+ [110, 0.024],
+ [164.81, 0.012],
+ [220.16, 0.007],
+ ]) {
+ const note = context.createOscillator()
+ const gain = context.createGain()
+ note.type = 'sine'
+ note.frequency.value = frequency
+ gain.gain.value = volume
+ note.connect(gain).connect(this.master)
+ note.start()
+ this.continuous.push(note)
+ }
+ if (!this.enabled && context.state === 'running')
+ void context.suspend().catch(() => {})
+ }
+
+ private event(kind: EventKind, combo: number): void {
+ const scale = [440, 493.88, 554.37, 659.25, 739.99, 880]
+ switch (kind) {
+ case 'spark':
+ this.tone(
+ scale[Math.min(scale.length - 1, Math.max(0, Math.floor(combo) - 1))],
+ 0.35,
+ 0.09,
+ )
+ break
+ case 'ring':
+ this.tone(554.37, 0.65, 0.1)
+ this.tone(739.99, 0.8, 0.065, 0.09)
+ this.tone(1108.73, 0.8, 0.04, 0.17)
+ break
+ case 'landing':
+ this.tone(220, 0.45, 0.1)
+ this.tone(440, 0.4, 0.03, 0.025)
+ break
+ case 'boost':
+ this.tone(329.63, 0.9, 0.11)
+ this.tone(659.25, 0.9, 0.08, 0.13)
+ break
+ case 'hit':
+ this.tone(82.41, 0.3, 0.12)
+ this.tone(87.31, 0.25, 0.045)
+ break
+ case 'near':
+ this.tone(987.77, 0.25, 0.045)
+ break
+ case 'biome':
+ ;[440, 554.37, 659.25].forEach((frequency, index) =>
+ this.tone(frequency, 1.8, 0.065, index * 0.2),
+ )
+ break
+ case 'end':
+ break
+ }
+ }
+
+ private tone(
+ frequency: number,
+ duration: number,
+ volume: number,
+ delay = 0,
+ ): void {
+ const context = this.context
+ if (!context || !this.master || !this.enabled || this.voices.size >= 24)
+ return
+ const oscillator = context.createOscillator()
+ const envelope = context.createGain()
+ const start = context.currentTime + delay
+ oscillator.type = 'sine'
+ oscillator.frequency.setValueAtTime(frequency, start)
+ envelope.gain.setValueAtTime(0, context.currentTime)
+ envelope.gain.setValueAtTime(0, start)
+ envelope.gain.linearRampToValueAtTime(volume, start + 0.014)
+ envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration)
+ oscillator.connect(envelope).connect(this.master)
+ this.voices.add(oscillator)
+ oscillator.onended = () => {
+ this.voices.delete(oscillator)
+ oscillator.disconnect()
+ envelope.disconnect()
+ }
+ oscillator.start(start)
+ oscillator.stop(start + duration + 0.03)
+ }
+}
diff --git a/projects/sunwake-a6f3/src/game/engine.ts b/projects/sunwake-a6f3/src/game/engine.ts
new file mode 100644
index 00000000..b43b220a
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/engine.ts
@@ -0,0 +1,546 @@
+import { BIOMES } from './types.ts'
+import type {
+ Entity,
+ EventKind,
+ GameInput,
+ GameState,
+ RunOptions,
+} from './types.ts'
+
+const STEP = 1 / 120
+const START_X = 200
+const HULL = 14
+const CHUNK = 820
+const TAU = Math.PI * 2
+const clamp = (n: number, low: number, high: number) =>
+ Math.max(low, Math.min(high, n))
+
+/** Coordinate hashing keeps the course identical regardless of how it is flown. */
+function noise(seed: number, coordinate: number): number {
+ let value = Math.imul(
+ (seed | 0) ^ Math.imul(coordinate | 0, 0x45d9f3b),
+ 0x45d9f3b,
+ )
+ value ^= value >>> 16
+ value = Math.imul(value, 0x45d9f3b)
+ return ((value ^ (value >>> 16)) >>> 0) / 4294967296
+}
+
+/** Smooth, analytically continuous dunes shared by simulation and renderer. */
+export function terrain(x: number, seed: number): number {
+ const phase = noise(seed, 1) * TAU
+ return (
+ 523 +
+ Math.sin(x / 132 + phase) * 66 +
+ Math.sin(x / 311 + phase * 0.7) * 25 +
+ Math.sin(x / 67 + phase * 1.3) * 8
+ )
+}
+
+export function terrainSlope(x: number, seed: number): number {
+ const phase = noise(seed, 1) * TAU
+ return (
+ Math.cos(x / 132 + phase) * (66 / 132) +
+ Math.cos(x / 311 + phase * 0.7) * (25 / 311) +
+ Math.cos(x / 67 + phase * 1.3) * (8 / 67)
+ )
+}
+
+export class SunwakeEngine {
+ state: GameState
+ private accumulator = 0
+ private nextChunk = 0
+ private nextEntity = 0
+ private nextEvent = 0
+ private effectCounter = 0
+ private lastDive = false
+ private lastBoost = false
+ private launchCooldown = 0
+ private airDuration = 0
+ private scoreBonus = 0
+ private trailClock = 0
+ private exhaustClock = 0
+
+ constructor(options: RunOptions) {
+ this.state = this.initialState(options)
+ this.populate()
+ }
+
+ private initialState(options: RunOptions): GameState {
+ return {
+ phase: 'ready',
+ mode: options.mode,
+ ship: options.ship,
+ seed: options.seed,
+ time: 0,
+ distance: 0,
+ score: 0,
+ sparks: 0,
+ rings: 0,
+ combo: 0,
+ bestCombo: 0,
+ cleanLandings: 0,
+ airtime: 0,
+ maxAirtime: 0,
+ nearMisses: 0,
+ boosts: 0,
+ biome: 0,
+ reason: '',
+ shake: 0,
+ player: {
+ x: START_X,
+ y: terrain(START_X, options.seed) - HULL,
+ vx: 360,
+ vy: 0,
+ rotation: Math.atan(terrainSlope(START_X, options.seed)),
+ grounded: true,
+ energy: 100,
+ charge: 25,
+ invincible: 0,
+ boostTime: 0,
+ trail: [],
+ },
+ entities: [],
+ particles: [],
+ events: [],
+ }
+ }
+
+ reset(options: RunOptions): void {
+ this.accumulator = 0
+ this.nextChunk = 0
+ this.nextEntity = 0
+ this.nextEvent = 0
+ this.effectCounter = 0
+ this.lastDive = false
+ this.lastBoost = false
+ this.launchCooldown = 0
+ this.airDuration = 0
+ this.scoreBonus = 0
+ this.trailClock = 0
+ this.exhaustClock = 0
+ this.state = this.initialState(options)
+ this.populate()
+ }
+
+ start(): void {
+ if (this.state.phase === 'ready') this.state.phase = 'running'
+ }
+
+ pause(): void {
+ if (this.state.phase === 'running') {
+ this.state.phase = 'paused'
+ this.accumulator = 0
+ }
+ }
+
+ resume(): void {
+ if (this.state.phase === 'paused') this.state.phase = 'running'
+ }
+
+ finish(): void {
+ if (this.state.phase === 'ended') return
+ this.state.phase = 'ended'
+ this.state.reason = 'A beautiful place to rest.'
+ this.event('end', 'Journey complete')
+ }
+
+ /** A bounded fixed clock keeps handling stable on both 60 Hz and 144 Hz screens. */
+ step(dt: number, input: GameInput): void {
+ if (this.state.phase !== 'running' || !Number.isFinite(dt) || dt <= 0)
+ return
+ this.accumulator += Math.min(dt, 0.1)
+ while (this.accumulator + 1e-9 >= STEP && this.state.phase === 'running') {
+ this.tick(STEP, input)
+ this.accumulator -= STEP
+ }
+ }
+
+ private tick(dt: number, input: GameInput): void {
+ const state = this.state
+ const player = state.player
+ state.time += dt
+ state.shake = Math.max(0, state.shake - dt * 3.5)
+ player.invincible = Math.max(0, player.invincible - dt)
+ player.boostTime = Math.max(0, player.boostTime - dt)
+ this.launchCooldown = Math.max(0, this.launchCooldown - dt)
+
+ if (input.boost && !this.lastBoost && player.charge >= 65) this.boost()
+ const boosting = player.boostTime > 0
+ const oldSlope = terrainSlope(player.x, state.seed)
+ const released = this.lastDive && !input.dive
+
+ if (player.grounded) {
+ // Holding catches the slope: gravity rewards the downhill and punishes the uphill.
+ const gravityAlongSlope = oldSlope * (input.dive ? 1050 : 525)
+ const cruise = (365 - player.vx) * (input.dive ? 0.22 : 0.46)
+ player.vx += (gravityAlongSlope + cruise + (boosting ? 285 : 18)) * dt
+ if (released && this.launchCooldown <= 0) {
+ this.launch(245 + Math.max(0, -oldSlope) * player.vx * 0.85)
+ }
+ } else {
+ player.vx += ((input.dive ? 420 : 445) - player.vx) * dt * 0.12
+ if (boosting) player.vx += 145 * dt
+ player.vy += (input.dive ? 1550 : boosting ? 245 : 445) * dt
+ player.vy = Math.min(player.vy, 1100)
+ this.airDuration += dt
+ state.airtime += dt
+ state.maxAirtime = Math.max(state.maxAirtime, this.airDuration)
+ }
+
+ player.vx = clamp(player.vx, 230, boosting ? 940 : 740)
+ player.x += player.vx * dt
+ const slope = terrainSlope(player.x, state.seed)
+ const ground = terrain(player.x, state.seed) - HULL
+
+ if (player.grounded) {
+ player.y = ground
+ player.vy = slope * player.vx
+ // An open sail naturally floats off a crest; a held sail stays on the sand.
+ if (
+ !input.dive &&
+ oldSlope < -0.055 &&
+ slope >= -0.055 &&
+ player.vx > 330 &&
+ this.launchCooldown <= 0
+ ) {
+ this.launch(190 + Math.max(0, player.vx - 360) * 0.3)
+ }
+ } else {
+ player.y += player.vy * dt
+ // Keep the tall sail inside the view even on a powerful uphill launch.
+ if (player.y < 82) {
+ player.y = 82
+ player.vy = Math.max(0, player.vy)
+ }
+ if (player.y >= ground) this.land(ground, slope)
+ }
+
+ const targetRotation = player.grounded
+ ? Math.atan(slope)
+ : clamp(Math.atan2(player.vy, player.vx) * 0.72, -0.72, 0.85)
+ player.rotation +=
+ (targetRotation - player.rotation) * (1 - Math.exp(-dt * 12))
+ state.distance = Math.max(0, (player.x - START_X) / 10)
+ const biome = Math.floor(state.distance / 1000) % BIOMES.length
+ if (biome !== state.biome) {
+ state.biome = biome
+ this.event('biome', BIOMES[biome].name)
+ player.energy = Math.min(100, player.energy + 16)
+ this.spray(player.x, player.y, 35, '#fae2ad', 240)
+ }
+
+ this.populate()
+ this.collide()
+ this.effects(dt, input.dive)
+ state.entities = state.entities.filter(
+ (entity) => entity.x > player.x - 260,
+ )
+ state.events = state.events
+ .filter((event) => state.time - event.time < 3.2)
+ .slice(-6)
+ state.score = Math.floor(state.distance * 3 + this.scoreBonus)
+
+ if (state.mode !== 'zen') {
+ // The opening stretch is deliberately generous while the pilot learns the sail.
+ player.energy = Math.max(
+ 0,
+ player.energy - dt * (state.time < 8 ? 0.35 : 1.65),
+ )
+ if (player.energy <= 0) {
+ state.phase = 'ended'
+ state.reason = 'Your sunlight ran out. Catch sparks to keep the glow.'
+ this.event('end', 'Until the next sunrise')
+ }
+ } else {
+ player.energy = 100
+ }
+ this.lastDive = input.dive
+ this.lastBoost = input.boost
+ }
+
+ private launch(strength: number): void {
+ const player = this.state.player
+ player.grounded = false
+ player.y -= 2
+ player.vy = -Math.min(590, strength)
+ this.airDuration = 0
+ this.launchCooldown = 0.42
+ this.spray(player.x - 14, player.y + 16, 7, '#fbe1aa', 90)
+ }
+
+ private land(ground: number, slope: number): void {
+ const state = this.state
+ const player = state.player
+ const impact = player.vy - slope * player.vx
+ const flew = this.airDuration > 0.42
+ const clean =
+ flew && ((slope > 0.09 && impact < 590) || Math.abs(impact) < 200)
+ player.y = ground
+ player.grounded = true
+ player.vy = slope * player.vx
+ this.launchCooldown = Math.max(this.launchCooldown, 0.18)
+ if (clean) {
+ state.cleanLandings++
+ this.addCombo()
+ player.charge = Math.min(100, player.charge + 14)
+ player.energy = Math.min(100, player.energy + 1.5)
+ player.vx = Math.min(760, player.vx + 32 + Math.max(0, slope) * 45)
+ this.scoreBonus += 80 * Math.max(1, state.combo)
+ this.event(
+ 'landing',
+ state.combo > 1 ? `Silky landing · ×${state.combo}` : 'Silky landing',
+ )
+ this.spray(player.x, player.y + 12, 20, '#a2efe0', 160)
+ } else if (flew) {
+ player.vx *= impact > 650 ? 0.83 : 0.96
+ state.combo = Math.max(0, state.combo - 1)
+ this.spray(player.x, player.y + 12, 10, '#f2cda2', 100)
+ }
+ this.airDuration = 0
+ }
+
+ private boost(): void {
+ const state = this.state
+ const player = state.player
+ player.charge -= 65
+ player.boostTime = 2.8
+ player.invincible = Math.max(player.invincible, 2.8)
+ player.vx = Math.min(940, player.vx + 240)
+ if (player.grounded) this.launch(255)
+ state.boosts++
+ state.shake = 0.3
+ this.scoreBonus += 150
+ this.event('boost', 'Solar burst!')
+ this.spray(player.x, player.y, 32, '#8ce8dc', 320)
+ }
+
+ private addCombo(): void {
+ this.state.combo = Math.min(8, this.state.combo + 1)
+ this.state.bestCombo = Math.max(this.state.bestCombo, this.state.combo)
+ }
+
+ private populate(): void {
+ const state = this.state
+ while (this.nextChunk * CHUNK < state.player.x + 2300) {
+ const index = this.nextChunk++
+ const base = index * CHUNK
+ const r = (offset: number) => noise(state.seed, index * 37 + offset + 20)
+ const aerial = index > 1 && r(1) > 0.45
+ const start = base + 290
+ for (let i = 0; i < 6; i++) {
+ const x = start + i * 48
+ const arch = aerial
+ ? Math.sin(((i + 1) / 7) * Math.PI) * (85 + r(2) * 55)
+ : 0
+ this.entity(
+ 'spark',
+ x,
+ terrain(x, state.seed) - 31 - arch,
+ 9,
+ r(3 + i) * TAU,
+ )
+ }
+ const ringX = base + 640
+ this.entity(
+ 'ring',
+ ringX,
+ terrain(ringX, state.seed) - (105 + r(10) * 105),
+ 30,
+ r(11) * TAU,
+ )
+ if (index > 0 && index % 4 === 2) {
+ const x = base + 130
+ this.entity('sunwell', x, terrain(x, state.seed) - 38, 23, r(12) * TAU)
+ }
+ // Obstacles never share a chunk with the first lesson. Every obstacle has an open route.
+ if (base > 4100 && index % 4 !== 2 && r(14) > 0.2) {
+ const x = base + 160
+ if (r(15) < 0.56) {
+ this.entity(
+ 'rock',
+ x,
+ terrain(x, state.seed) - 15,
+ 23 + r(16) * 7,
+ r(17) * TAU,
+ )
+ } else {
+ this.entity(
+ 'storm',
+ x,
+ terrain(x, state.seed) - 150 - r(16) * 75,
+ 32 + r(17) * 9,
+ r(18) * TAU,
+ )
+ }
+ }
+ }
+ }
+
+ private entity(
+ kind: Entity['kind'],
+ x: number,
+ y: number,
+ radius: number,
+ phase: number,
+ ): void {
+ this.state.entities.push({
+ id: this.nextEntity++,
+ kind,
+ x,
+ y,
+ radius,
+ collected: false,
+ phase,
+ })
+ }
+
+ private collide(): void {
+ const state = this.state
+ const player = state.player
+ for (const entity of state.entities) {
+ if (entity.collected || Math.abs(entity.x - player.x) > 110) continue
+ const dx = entity.x - player.x
+ const dy = entity.y - player.y
+ const distance = Math.hypot(dx, dy)
+ const hazard = entity.kind === 'rock' || entity.kind === 'storm'
+ const reach =
+ entity.radius + (hazard ? 10 : entity.kind === 'spark' ? 23 : 20)
+ if (distance < reach) {
+ if (hazard) {
+ if (player.boostTime > 0) {
+ entity.collected = true
+ this.scoreBonus += 100
+ this.spray(entity.x, entity.y, 20, '#fce2bd', 220)
+ } else if (player.invincible <= 0 && state.time > 8) {
+ entity.collected = true
+ player.invincible = 1.8
+ player.energy = Math.max(
+ state.mode === 'zen' ? 100 : 0,
+ player.energy - 25,
+ )
+ player.vx = Math.max(245, player.vx * 0.74)
+ player.charge = Math.max(0, player.charge - 8)
+ state.combo = 0
+ state.shake = 0.72
+ this.event(
+ 'hit',
+ entity.kind === 'rock'
+ ? 'Rough sand · −25 sunlight'
+ : 'Storm brushed · −25 sunlight',
+ )
+ this.spray(player.x, player.y, 24, '#ee927e', 210)
+ }
+ } else {
+ entity.collected = true
+ const multiplier = Math.max(1, state.combo)
+ if (entity.kind === 'spark') {
+ state.sparks++
+ player.energy = Math.min(100, player.energy + 0.55)
+ player.charge = Math.min(100, player.charge + 2)
+ this.scoreBonus += 15 * multiplier
+ this.spray(entity.x, entity.y, 5, '#ffecaf', 85)
+ } else if (entity.kind === 'ring') {
+ state.rings++
+ this.addCombo()
+ player.energy = Math.min(100, player.energy + 5)
+ player.charge = Math.min(100, player.charge + 15)
+ this.scoreBonus += 120 * Math.max(1, state.combo)
+ this.event('ring', `Sun ring · ×${Math.max(1, state.combo)}`)
+ this.spray(entity.x, entity.y, 22, '#ffdea0', 180)
+ } else {
+ player.energy = Math.min(100, player.energy + 20)
+ player.charge = Math.min(100, player.charge + 12)
+ this.scoreBonus += 100
+ this.event('spark', 'Sunwell · +20 sunlight')
+ this.spray(entity.x, entity.y, 26, '#a4eee0', 180)
+ }
+ }
+ } else if (
+ hazard &&
+ !entity.nearMissed &&
+ dx < -entity.radius - 10 &&
+ distance < entity.radius + 75
+ ) {
+ entity.nearMissed = true
+ state.nearMisses++
+ player.charge = Math.min(100, player.charge + 7)
+ this.scoreBonus += 60 * Math.max(1, state.combo)
+ this.event('near', 'Close call · +7 charge')
+ }
+ }
+ }
+
+ private effects(dt: number, diving: boolean): void {
+ const state = this.state
+ const player = state.player
+ this.trailClock += dt
+ if (this.trailClock >= 1 / 60) {
+ this.trailClock = 0
+ player.trail.push({
+ x: player.x - 14,
+ y: player.y + 5,
+ life: player.boostTime > 0 ? 0.8 : 0.58,
+ })
+ }
+ for (const dot of player.trail) dot.life -= dt
+ player.trail = player.trail.filter((dot) => dot.life > 0).slice(-50)
+ this.exhaustClock += dt
+ if (this.exhaustClock > (player.boostTime > 0 ? 0.035 : 0.095)) {
+ this.exhaustClock = 0
+ if (player.boostTime > 0 || (diving && player.grounded)) {
+ this.spray(
+ player.x - 20,
+ player.y + 10,
+ 1,
+ player.boostTime > 0 ? '#95ebdb' : '#f4d8b2',
+ 55,
+ )
+ }
+ }
+ for (const particle of state.particles) {
+ particle.life -= dt
+ particle.x += particle.vx * dt
+ particle.y += particle.vy * dt
+ particle.vy += dt * 130
+ }
+ state.particles = state.particles
+ .filter((particle) => particle.life > 0)
+ .slice(-240)
+ }
+
+ private spray(
+ x: number,
+ y: number,
+ count: number,
+ color: string,
+ speed: number,
+ ): void {
+ for (let i = 0; i < count; i++) {
+ const random = () =>
+ noise(this.state.seed ^ 0x27d4eb2d, this.effectCounter++)
+ const angle = random() * TAU
+ const velocity = (0.25 + random() * 0.75) * speed
+ const life = 0.3 + random() * 0.5
+ this.state.particles.push({
+ x,
+ y,
+ vx: Math.cos(angle) * velocity,
+ vy: Math.sin(angle) * velocity - 35,
+ life,
+ maxLife: life,
+ size: 1.5 + random() * 3,
+ color,
+ })
+ }
+ }
+
+ private event(kind: EventKind, text: string): void {
+ this.state.events.push({
+ id: this.nextEvent++,
+ kind,
+ text,
+ time: this.state.time,
+ })
+ }
+}
diff --git a/projects/sunwake-a6f3/src/game/progress.ts b/projects/sunwake-a6f3/src/game/progress.ts
new file mode 100644
index 00000000..de244586
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/progress.ts
@@ -0,0 +1,337 @@
+import type { GameState, ShipId } from './types'
+
+export interface Progress {
+ version: 1
+ bank: number
+ best: number
+ bestScore: number
+ totalDistance: number
+ totalRuns: number
+ owned: ShipId[]
+ selected: ShipId
+ completed: string[]
+ daily: { date: string; best: number }
+ sound: boolean
+}
+
+export interface Mission {
+ id: string
+ title: string
+ description: string
+ reward: number
+ kind:
+ | 'distance'
+ | 'sparks'
+ | 'rings'
+ | 'cleanLandings'
+ | 'maxAirtime'
+ | 'bestCombo'
+ | 'boosts'
+ | 'nearMisses'
+ target: number
+}
+
+export const SHIPS: {
+ id: ShipId
+ name: string
+ subtitle: string
+ price: number
+ color: string
+}[] = [
+ {
+ id: 'sol',
+ name: 'Sol',
+ subtitle: 'A little sail. An endless sky.',
+ price: 0,
+ color: '#75d8ce',
+ },
+ {
+ id: 'manta',
+ name: 'Manta',
+ subtitle: 'A violet-colored daydream.',
+ price: 150,
+ color: '#b798db',
+ },
+ {
+ id: 'comet',
+ name: 'Comet',
+ subtitle: 'Leave a little stardust.',
+ price: 400,
+ color: '#ec8a67',
+ },
+]
+
+export const MISSIONS: Mission[] = [
+ {
+ id: 'first-light',
+ title: 'First light',
+ description: 'Travel 500 m in one run',
+ kind: 'distance',
+ target: 500,
+ reward: 25,
+ },
+ {
+ id: 'pocket-sunshine',
+ title: 'Pocket sunshine',
+ description: 'Collect 12 sparks in one run',
+ kind: 'sparks',
+ target: 12,
+ reward: 25,
+ },
+ {
+ id: 'soft-touch',
+ title: 'Soft touch',
+ description: 'Make 2 clean landings in one run',
+ kind: 'cleanLandings',
+ target: 2,
+ reward: 30,
+ },
+ {
+ id: 'ring-seeker',
+ title: 'Ring seeker',
+ description: 'Thread 3 sun rings in one run',
+ kind: 'rings',
+ target: 3,
+ reward: 40,
+ },
+ {
+ id: 'air-mail',
+ title: 'Air mail',
+ description: 'Stay airborne for 3 seconds',
+ kind: 'maxAirtime',
+ target: 3,
+ reward: 40,
+ },
+ {
+ id: 'sun-powered',
+ title: 'Sun-powered',
+ description: 'Use 2 solar bursts in one run',
+ kind: 'boosts',
+ target: 2,
+ reward: 50,
+ },
+ {
+ id: 'far-from-home',
+ title: 'Far from home',
+ description: 'Travel 2,000 m in one run',
+ kind: 'distance',
+ target: 2000,
+ reward: 60,
+ },
+ {
+ id: 'close-call',
+ title: 'Close call',
+ description: 'Make 2 near misses in one run',
+ kind: 'nearMisses',
+ target: 2,
+ reward: 60,
+ },
+ {
+ id: 'in-the-flow',
+ title: 'In the flow',
+ description: 'Reach a 5× combo',
+ kind: 'bestCombo',
+ target: 5,
+ reward: 70,
+ },
+ {
+ id: 'gold-rush',
+ title: 'Gold rush',
+ description: 'Collect 60 sparks in one run',
+ kind: 'sparks',
+ target: 60,
+ reward: 80,
+ },
+ {
+ id: 'cloud-surfer',
+ title: 'Cloud surfer',
+ description: 'Make 12 clean landings in one run',
+ kind: 'cleanLandings',
+ target: 12,
+ reward: 90,
+ },
+ {
+ id: 'last-light',
+ title: 'Chase the last light',
+ description: 'Travel 5,000 m in one run',
+ kind: 'distance',
+ target: 5000,
+ reward: 120,
+ },
+]
+
+const SAVE_KEY = 'sunwake.progress.v1'
+const MAX_VALUE = 1_000_000_000_000
+const today = () => new Date().toISOString().slice(0, 10)
+
+function count(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value)
+ ? Math.min(MAX_VALUE, Math.max(0, Math.floor(value)))
+ : 0
+}
+
+function validDate(value: unknown): value is string {
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
+ return false
+ const date = new Date(`${value}T00:00:00.000Z`)
+ return (
+ Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value
+ )
+}
+
+function isShip(value: unknown): value is ShipId {
+ return value === 'sol' || value === 'manta' || value === 'comet'
+}
+
+export function freshProgress(): Progress {
+ return {
+ version: 1,
+ bank: 0,
+ best: 0,
+ bestScore: 0,
+ totalDistance: 0,
+ totalRuns: 0,
+ owned: ['sol'],
+ selected: 'sol',
+ completed: [],
+ daily: { date: today(), best: 0 },
+ sound: false,
+ }
+}
+
+export function loadProgress(): Progress {
+ try {
+ const raw: unknown = JSON.parse(localStorage.getItem(SAVE_KEY) ?? 'null')
+ if (
+ !raw ||
+ typeof raw !== 'object' ||
+ !('version' in raw) ||
+ raw.version !== 1
+ )
+ return freshProgress()
+ const data = raw as Record
+ const owned: ShipId[] = ['sol']
+ if (Array.isArray(data.owned)) {
+ for (const id of data.owned)
+ if (isShip(id) && !owned.includes(id)) owned.push(id)
+ }
+ const knownMissions = new Set(MISSIONS.map((mission) => mission.id))
+ const completed = Array.isArray(data.completed)
+ ? [
+ ...new Set(
+ data.completed.filter(
+ (id): id is string =>
+ typeof id === 'string' && knownMissions.has(id),
+ ),
+ ),
+ ]
+ : []
+ const daily =
+ data.daily && typeof data.daily === 'object'
+ ? (data.daily as Record)
+ : {}
+ const date = today()
+ return {
+ version: 1,
+ bank: count(data.bank),
+ best: count(data.best),
+ bestScore: count(data.bestScore),
+ totalDistance: count(data.totalDistance),
+ totalRuns: count(data.totalRuns),
+ owned,
+ selected:
+ isShip(data.selected) && owned.includes(data.selected)
+ ? data.selected
+ : 'sol',
+ completed,
+ sound: data.sound === true,
+ daily: {
+ date,
+ best:
+ validDate(daily.date) && daily.date === date ? count(daily.best) : 0,
+ },
+ }
+ } catch {
+ // Private browsing and blocked storage still allow a complete in-memory game.
+ return freshProgress()
+ }
+}
+
+export function saveProgress(progress: Progress): boolean {
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify(progress))
+ return true
+ } catch {
+ // The caller keeps the live state even when the browser cannot persist it.
+ return false
+ }
+}
+
+export function dailySeed(date: string): number {
+ let hash = 2166136261
+ for (const char of `sunwake:${date}`)
+ hash = Math.imul(hash ^ char.charCodeAt(0), 16777619)
+ return hash >>> 0
+}
+
+export function settleRun(
+ progress: Progress,
+ state: GameState,
+ date: string,
+): {
+ progress: Progress
+ earned: number
+ completed: typeof MISSIONS
+ newBest: boolean
+} {
+ if (state.phase !== 'ended')
+ return { progress, earned: 0, completed: [], newBest: false }
+ if (state.mode === 'zen') {
+ return {
+ progress: {
+ ...progress,
+ totalDistance: count(progress.totalDistance + count(state.distance)),
+ totalRuns: count(progress.totalRuns + 1),
+ },
+ earned: 0,
+ completed: [],
+ newBest: false,
+ }
+ }
+ // Snapshot eligibility before awarding so a single run never skips ahead in the journal.
+ const eligible = MISSIONS.filter(
+ (mission) => !progress.completed.includes(mission.id),
+ ).slice(0, 3)
+ const completed = eligible.filter(
+ (mission) => state[mission.kind] >= mission.target,
+ )
+ const earned =
+ count(state.sparks) +
+ completed.reduce((sum, mission) => sum + mission.reward, 0)
+ const distance = count(state.distance)
+ const runDate = validDate(date) ? date : today()
+ const dailyBest = progress.daily.date === runDate ? progress.daily.best : 0
+ const next: Progress = {
+ ...progress,
+ bank: count(progress.bank + earned),
+ best: Math.max(progress.best, distance),
+ bestScore: Math.max(progress.bestScore, count(state.score)),
+ totalDistance: count(progress.totalDistance + distance),
+ totalRuns: count(progress.totalRuns + 1),
+ owned: [...progress.owned],
+ completed: [
+ ...progress.completed,
+ ...completed.map((mission) => mission.id),
+ ],
+ daily: {
+ date: runDate,
+ best: state.mode === 'daily' ? Math.max(dailyBest, distance) : dailyBest,
+ },
+ }
+ return {
+ progress: next,
+ earned,
+ completed,
+ newBest: distance > progress.best,
+ }
+}
diff --git a/projects/sunwake-a6f3/src/game/renderer.ts b/projects/sunwake-a6f3/src/game/renderer.ts
new file mode 100644
index 00000000..d743b250
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/renderer.ts
@@ -0,0 +1,970 @@
+import { terrain } from './engine'
+import { WORLD_HEIGHT } from './types'
+import type { Entity, GameState, ShipId } from './types'
+
+type RGB = readonly [number, number, number]
+type Palette = readonly RGB[]
+
+// Sky, horizon, sun, far ridge, middle ridge, near ridge, sand, deep sand,
+// etched sand, plum ink, solar gold, sail. Each world keeps the same contrast.
+const PALETTES: readonly Palette[] = [
+ [
+ [244, 183, 145],
+ [255, 224, 165],
+ [255, 240, 177],
+ [215, 143, 138],
+ [181, 106, 123],
+ [137, 80, 110],
+ [227, 150, 109],
+ [173, 89, 93],
+ [251, 189, 125],
+ [70, 46, 73],
+ [255, 230, 151],
+ [112, 220, 208],
+ ],
+ [
+ [221, 153, 161],
+ [255, 203, 174],
+ [255, 227, 185],
+ [190, 128, 154],
+ [151, 100, 142],
+ [113, 77, 120],
+ [204, 124, 146],
+ [139, 74, 116],
+ [242, 170, 164],
+ [58, 40, 73],
+ [255, 231, 168],
+ [135, 235, 214],
+ ],
+ [
+ [125, 109, 166],
+ [222, 173, 191],
+ [255, 222, 194],
+ [148, 115, 171],
+ [111, 87, 146],
+ [77, 62, 119],
+ [161, 126, 178],
+ [96, 74, 130],
+ [209, 171, 202],
+ [43, 34, 67],
+ [255, 222, 162],
+ [127, 227, 222],
+ ],
+ [
+ [75, 113, 153],
+ [170, 205, 213],
+ [254, 236, 190],
+ [99, 147, 169],
+ [76, 116, 145],
+ [50, 83, 115],
+ [112, 167, 181],
+ [59, 108, 134],
+ [169, 216, 217],
+ [32, 48, 70],
+ [255, 226, 153],
+ [173, 243, 218],
+ ],
+]
+
+const TAU = Math.PI * 2
+
+function mix(a: number, b: number, t: number) {
+ return a + (b - a) * t
+}
+
+function smooth(t: number) {
+ return t * t * (3 - 2 * t)
+}
+
+function mod(value: number, range: number) {
+ return ((value % range) + range) % range
+}
+
+function random(value: number) {
+ const n = Math.sin(value * 127.1 + 311.7) * 43758.5453
+ return n - Math.floor(n)
+}
+
+function colors(distance: number) {
+ const progress = Math.max(0, distance) / 1000
+ const index = Math.floor(progress) % PALETTES.length
+ const blend = smooth(Math.max(0, ((progress % 1) - 0.68) / 0.32))
+ return PALETTES[index].map((rgb, i) => {
+ const next = PALETTES[(index + 1) % PALETTES.length][i]
+ return `rgb(${Math.round(mix(rgb[0], next[0], blend))},${Math.round(mix(rgb[1], next[1], blend))},${Math.round(mix(rgb[2], next[2], blend))})`
+ })
+}
+
+function circle(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ radius: number,
+) {
+ ctx.beginPath()
+ ctx.arc(x, y, Math.max(0, radius), 0, TAU)
+}
+
+function diamond(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ radius: number,
+) {
+ ctx.beginPath()
+ ctx.moveTo(x, y - radius)
+ ctx.lineTo(x + radius * 0.66, y)
+ ctx.lineTo(x, y + radius)
+ ctx.lineTo(x - radius * 0.66, y)
+ ctx.closePath()
+}
+
+function sky(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ camera: number,
+ time: number,
+ palette: string[],
+ ready: boolean,
+ distance: number,
+ top: number,
+ bottom: number,
+) {
+ const gradient = ctx.createLinearGradient(0, top, 0, 540)
+ gradient.addColorStop(0, palette[0])
+ gradient.addColorStop(1, palette[1])
+ ctx.fillStyle = gradient
+ ctx.fillRect(0, top, w, bottom - top)
+
+ // Quiet, widely spaced pinpricks, with a little more light in the night worlds.
+ const night = Math.sin(Math.min(1, (distance % 4000) / 2400) * Math.PI * 0.5)
+ ctx.fillStyle = '#fff5d8'
+ for (let i = 0; i < 46; i++) {
+ const x = mod(random(i + 6) * (w + 200) - camera * 0.012, w + 200) - 100
+ const y = mix(top + 38, 352, random(i + 89))
+ ctx.globalAlpha =
+ (0.14 + night * 0.34) * (0.6 + Math.sin(time * 0.8 + i) * 0.2)
+ if (i % 7 === 0) {
+ const r = 2.2 + random(i) * 1.7
+ ctx.fillRect(x - r, y - 0.55, r * 2, 1.1)
+ ctx.fillRect(x - 0.55, y - r, 1.1, r * 2)
+ } else {
+ circle(ctx, x, y, random(i + 4) * 1.25 + 0.5)
+ ctx.fill()
+ }
+ }
+ ctx.globalAlpha = 1
+
+ const sunX = w * (ready ? 0.765 : 0.78)
+ const sunY = w < 670 ? 200 : 213
+ const sunR =
+ Math.min(118, Math.max(79, w * 0.12)) * (1 + Math.min(0.38, -top / 1200))
+ const glow = ctx.createRadialGradient(
+ sunX,
+ sunY,
+ sunR * 0.3,
+ sunX,
+ sunY,
+ sunR * 2.25,
+ )
+ glow.addColorStop(0, '#ffeac350')
+ glow.addColorStop(0.55, '#ffeac31b')
+ glow.addColorStop(1, '#ffeac300')
+ ctx.fillStyle = glow
+ ctx.fillRect(sunX - sunR * 2.3, sunY - sunR * 2.3, sunR * 4.6, sunR * 4.6)
+
+ ctx.save()
+ ctx.translate(sunX, sunY)
+ ctx.rotate(-0.3)
+ ctx.lineWidth = 0.9
+ ctx.strokeStyle = '#fff1cc'
+ ctx.globalAlpha = 0.33
+ ctx.beginPath()
+ ctx.ellipse(0, 0, sunR * 1.62, sunR * 0.64, 0, Math.PI * 0.78, Math.PI * 1.96)
+ ctx.stroke()
+ ctx.globalAlpha = 0.2
+ circle(ctx, 0, 0, sunR + 15)
+ ctx.stroke()
+ ctx.restore()
+
+ circle(ctx, sunX, sunY, sunR)
+ const disc = ctx.createLinearGradient(0, sunY - sunR, 0, sunY + sunR)
+ disc.addColorStop(0, '#fff5ce')
+ disc.addColorStop(0.55, palette[2])
+ disc.addColorStop(1, '#f7ba91')
+ ctx.fillStyle = disc
+ ctx.fill()
+ ctx.save()
+ ctx.clip()
+ ctx.globalAlpha = 0.24
+ ctx.fillStyle = palette[0]
+ for (let i = 0; i < 7; i++) {
+ const y = sunY + sunR * 0.14 + i * 13
+ ctx.fillRect(sunX - sunR - 2, y, sunR * 2 + 4, 1 + i * 0.75)
+ }
+ ctx.restore()
+
+ // A small companion moon and a single long cirrus ribbon give the sky scale.
+ ctx.globalAlpha = 0.35
+ ctx.fillStyle = palette[2]
+ circle(ctx, sunX - sunR * 1.48, sunY - sunR * 0.92, 8)
+ ctx.fill()
+ ctx.globalAlpha = 0.28
+ ctx.fillStyle = '#fff2d7'
+ ctx.beginPath()
+ ctx.moveTo(w * 0.43, 114)
+ ctx.bezierCurveTo(w * 0.53, 100, w * 0.58, 112, w * 0.68, 107)
+ ctx.bezierCurveTo(w * 0.6, 121, w * 0.55, 117, w * 0.43, 114)
+ ctx.fill()
+ ctx.globalAlpha = 1
+}
+
+function ridgeHeight(x: number, depth: number) {
+ return (
+ 387 +
+ depth * 41 +
+ Math.sin(x * 0.0045 + depth * 1.8) * 33 +
+ Math.sin(x * 0.013 + depth * 2.1) * (depth === 0 ? 20 : 13)
+ )
+}
+
+function mountains(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ camera: number,
+ palette: string[],
+) {
+ for (let layer = 0; layer < 3; layer++) {
+ const offset = camera * (0.065 + layer * 0.07)
+ ctx.beginPath()
+ ctx.moveTo(-30, 740)
+ for (let x = -30; x <= w + 30; x += 15) {
+ const pointX = x + offset
+ const jagged =
+ layer === 0
+ ? Math.abs(Math.sin(pointX * 0.009)) * 48 +
+ Math.abs(Math.sin(pointX * 0.0032 + 5)) * 48
+ : 0
+ ctx.lineTo(x, ridgeHeight(pointX, layer) - jagged)
+ }
+ ctx.lineTo(w + 30, 740)
+ ctx.closePath()
+ ctx.fillStyle = palette[3 + layer]
+ ctx.globalAlpha = layer === 0 ? 0.55 : layer === 1 ? 0.7 : 0.79
+ ctx.fill()
+ ctx.globalAlpha = 1
+
+ if (layer === 0) {
+ ctx.strokeStyle = palette[1]
+ ctx.globalAlpha = 0.14
+ ctx.lineWidth = 1
+ for (let j = 0; j < 9; j++) {
+ const x = mod(j * 261 - offset, w + 250) - 100
+ const y = ridgeHeight(x + offset, 0) - 35
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ ctx.lineTo(x - 22, y + 30)
+ ctx.lineTo(x - 16, y + 46)
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ }
+ }
+
+ const ruinOffset = camera * 0.17
+ const first = Math.floor((ruinOffset - 250) / 620)
+ ctx.fillStyle = palette[5]
+ for (let i = first; i < first + Math.ceil(w / 620) + 2; i++) {
+ const x = i * 620 + 230 + random(i + 66) * 180 - ruinOffset
+ const baseY = ridgeHeight(x + camera * 0.205, 2) + 5
+ const size = 0.5 + random(i + 3) * 0.38
+ ctx.save()
+ ctx.translate(x, baseY)
+ ctx.scale(size, size)
+ ctx.globalAlpha = 0.71
+ if (mod(i, 3) === 0) {
+ // Monolithic arch cut directly out of the silhouette.
+ ctx.beginPath()
+ ctx.moveTo(-39, 3)
+ ctx.lineTo(-37, -58)
+ ctx.bezierCurveTo(-36, -113, 34, -119, 41, -61)
+ ctx.lineTo(44, 3)
+ ctx.lineTo(23, 3)
+ ctx.lineTo(22, -60)
+ ctx.bezierCurveTo(20, -86, -17, -89, -18, -57)
+ ctx.lineTo(-19, 3)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillRect(-54, -4, 112, 10)
+ ctx.strokeStyle = palette[3]
+ ctx.lineWidth = 1.5
+ ctx.beginPath()
+ ctx.moveTo(-29, -54)
+ ctx.lineTo(-27, -16)
+ ctx.moveTo(33, -51)
+ ctx.lineTo(35, -8)
+ ctx.stroke()
+ } else {
+ ctx.beginPath()
+ ctx.moveTo(-8, 0)
+ ctx.lineTo(-5, -72)
+ ctx.lineTo(0, -94)
+ ctx.lineTo(9, -68)
+ ctx.lineTo(12, 0)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillRect(21, -31, 7, 32)
+ ctx.fillRect(-26, -18, 9, 19)
+ }
+ ctx.restore()
+ }
+
+ // Small migrating birds stay well above the playable horizon.
+ ctx.strokeStyle = palette[5]
+ ctx.globalAlpha = 0.4
+ ctx.lineWidth = 1.4
+ for (let i = 0; i < 5; i++) {
+ const x = mod(w * 0.65 + i * 23 - camera * 0.027, w + 100) - 20
+ const y = 314 + Math.sin(i * 0.8) * 14
+ ctx.beginPath()
+ ctx.moveTo(x - 4, y + 1)
+ ctx.quadraticCurveTo(x - 1, y - 2, x, y)
+ ctx.quadraticCurveTo(x + 2, y - 3, x + 6, y - 2)
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+}
+
+function ground(
+ ctx: CanvasRenderingContext2D,
+ w: number,
+ camera: number,
+ seed: number,
+ palette: string[],
+ bottom: number,
+) {
+ const dune = new Path2D()
+ dune.moveTo(-20, bottom + 10)
+ for (let x = -20; x <= w + 24; x += 8)
+ dune.lineTo(x, terrain(x + camera, seed))
+ dune.lineTo(w + 24, bottom + 10)
+ dune.closePath()
+ const fill = ctx.createLinearGradient(0, 400, 0, Math.max(760, bottom))
+ fill.addColorStop(0, palette[6])
+ fill.addColorStop(1, palette[7])
+ ctx.fillStyle = fill
+ ctx.fill(dune)
+
+ ctx.save()
+ ctx.clip(dune)
+ // Contours follow the real course, then open up into long wind-carved ribbons.
+ for (
+ let line = 0;
+ line < Math.max(12, Math.ceil((bottom - 450) / 45));
+ line++
+ ) {
+ ctx.beginPath()
+ for (let x = -24; x <= w + 24; x += 15) {
+ const worldX = x + camera
+ const depth = 12 + line * 17 + line * line * 1.55
+ const y =
+ terrain(worldX - line * 7, seed) +
+ depth +
+ Math.sin(worldX * 0.005 + line * 0.42) * line * 2.4
+ if (x === -24) ctx.moveTo(x, y)
+ else ctx.lineTo(x, y)
+ }
+ ctx.strokeStyle = line % 3 === 0 ? palette[9] : palette[8]
+ ctx.globalAlpha =
+ line % 3 === 0 ? 0.065 : Math.max(0.045, 0.23 - line * 0.009)
+ ctx.lineWidth = line % 4 === 0 ? 1.4 : 0.7
+ ctx.stroke()
+ }
+
+ ctx.strokeStyle = palette[8]
+ ctx.lineWidth = 1
+ ctx.globalAlpha = 0.3
+ const segment = Math.floor(camera / 110)
+ for (let i = segment - 1; i < segment + Math.ceil(w / 110) + 2; i++) {
+ const worldX = i * 110 + random(i + seed) * 70
+ const x = worldX - camera
+ const y = terrain(worldX, seed) + 35 + random(i + 8) * 120
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ ctx.quadraticCurveTo(x + 10, y - 3, x + 23, y - 1)
+ ctx.moveTo(x + 5, y + 5)
+ ctx.lineTo(x + 15, y + 4)
+ ctx.stroke()
+ }
+ ctx.restore()
+
+ ctx.beginPath()
+ for (let x = -16; x <= w + 16; x += 8) {
+ const y = terrain(x + camera, seed)
+ if (x === -16) ctx.moveTo(x, y)
+ else ctx.lineTo(x, y)
+ }
+ ctx.strokeStyle = palette[8]
+ ctx.lineWidth = 3.5
+ ctx.stroke()
+ ctx.strokeStyle = palette[2]
+ ctx.globalAlpha = 0.54
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.globalAlpha = 1
+}
+
+function collectible(
+ ctx: CanvasRenderingContext2D,
+ entity: Entity,
+ x: number,
+ time: number,
+ palette: string[],
+) {
+ const { y, radius, kind, phase } = entity
+ const pulse = Math.sin(time * 3 + phase)
+ ctx.save()
+ ctx.translate(x, y)
+ if (kind === 'spark') {
+ const glow = ctx.createRadialGradient(0, 0, 1, 0, 0, 24)
+ glow.addColorStop(0, '#fff0b34a')
+ glow.addColorStop(1, '#fff0b300')
+ ctx.fillStyle = glow
+ ctx.fillRect(-24, -24, 48, 48)
+ ctx.fillStyle = palette[10]
+ diamond(ctx, 0, pulse * 2, 9)
+ ctx.fill()
+ ctx.fillStyle = '#fff9df'
+ diamond(ctx, -1.2, -1 + pulse * 2, 4)
+ ctx.fill()
+ ctx.strokeStyle = '#fff4ca'
+ ctx.globalAlpha = 0.22
+ ctx.lineWidth = 0.8
+ diamond(ctx, 0, pulse * 2, 15)
+ ctx.stroke()
+ } else {
+ const well = kind === 'sunwell'
+ const r = radius * (well ? 0.94 : 1)
+ ctx.strokeStyle = well ? palette[11] : palette[10]
+ ctx.lineWidth = 10
+ ctx.globalAlpha = 0.09
+ circle(ctx, 0, 0, r)
+ ctx.stroke()
+ ctx.lineWidth = 2.2
+ ctx.globalAlpha = 0.86
+ circle(ctx, 0, 0, r)
+ ctx.stroke()
+ ctx.globalAlpha = 0.35
+ ctx.lineWidth = 0.85
+ circle(ctx, 0, 0, r + 6)
+ ctx.stroke()
+ ctx.globalAlpha = 1
+ ctx.strokeStyle = '#fff8dc'
+ ctx.lineWidth = 3
+ ctx.beginPath()
+ ctx.arc(0, 0, r, time * 0.55 + phase, time * 0.55 + phase + 0.72)
+ ctx.stroke()
+ ctx.fillStyle = well ? palette[11] : '#fff6cc'
+ if (well) {
+ for (let i = 0; i < 8; i++) {
+ const angle = (i / 8) * TAU + time * 0.15
+ ctx.save()
+ ctx.rotate(angle)
+ ctx.fillRect(-0.7, -15, 1.4, 5)
+ ctx.restore()
+ }
+ circle(ctx, 0, 0, 6 + pulse)
+ ctx.fill()
+ } else {
+ diamond(ctx, 0, -r, 4.5)
+ ctx.fill()
+ diamond(ctx, 0, r, 4.5)
+ ctx.fill()
+ }
+ }
+ ctx.restore()
+}
+
+function hazard(
+ ctx: CanvasRenderingContext2D,
+ entity: Entity,
+ x: number,
+ time: number,
+) {
+ const { y, radius, kind, phase } = entity
+ ctx.save()
+ ctx.translate(x, y)
+ if (kind === 'rock') {
+ ctx.fillStyle = '#8c4c66'
+ ctx.beginPath()
+ ctx.moveTo(-radius - 7, radius * 0.9)
+ ctx.lineTo(-radius * 0.63, -radius * 0.48)
+ ctx.lineTo(-radius * 0.07, -radius * 1.13)
+ ctx.lineTo(radius * 0.58, -radius * 0.49)
+ ctx.lineTo(radius + 5, radius * 0.85)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillStyle = '#cc747e'
+ ctx.beginPath()
+ ctx.moveTo(-radius * 0.07, -radius * 1.13)
+ ctx.lineTo(radius * 0.58, -radius * 0.49)
+ ctx.lineTo(radius + 5, radius * 0.85)
+ ctx.lineTo(radius * 0.1, radius * 0.61)
+ ctx.closePath()
+ ctx.fill()
+ ctx.strokeStyle = '#ffb99c'
+ ctx.lineWidth = 1.8
+ ctx.beginPath()
+ ctx.moveTo(-radius * 0.48, -radius * 0.28)
+ ctx.lineTo(-radius * 0.06, -radius * 0.63)
+ ctx.lineTo(radius * 0.13, -radius * 0.2)
+ ctx.stroke()
+ } else {
+ const glow = ctx.createRadialGradient(0, 0, 3, 0, 0, radius * 1.9)
+ glow.addColorStop(0, '#ec6b8160')
+ glow.addColorStop(1, '#ed637700')
+ ctx.fillStyle = glow
+ ctx.fillRect(-radius * 2, -radius * 2, radius * 4, radius * 4)
+
+ ctx.save()
+ ctx.rotate(Math.sin(time + phase) * 0.13)
+ ctx.fillStyle = '#875270'
+ ctx.beginPath()
+ for (let i = 0; i <= 36; i++) {
+ const angle = (i / 36) * TAU
+ const r = radius * (0.86 + Math.sin(angle * 5 + phase) * 0.15)
+ const px = Math.cos(angle) * r * 1.22
+ const py = Math.sin(angle) * r * 0.84
+ if (i === 0) ctx.moveTo(px, py)
+ else ctx.lineTo(px, py)
+ }
+ ctx.closePath()
+ ctx.fill()
+ ctx.strokeStyle = '#f599a0'
+ ctx.lineWidth = 2
+ ctx.stroke()
+ ctx.strokeStyle = '#ffd9b7'
+ ctx.lineWidth = 2.4
+ ctx.beginPath()
+ ctx.moveTo(5, -radius * 0.58)
+ ctx.lineTo(-6, 0)
+ ctx.lineTo(5, -1)
+ ctx.lineTo(-3, radius * 0.58)
+ ctx.stroke()
+ ctx.restore()
+
+ ctx.strokeStyle = '#ed96a2'
+ ctx.lineWidth = 1.1
+ ctx.globalAlpha = 0.68
+ ctx.beginPath()
+ ctx.ellipse(
+ 0,
+ 0,
+ radius * 1.6,
+ radius * 0.53,
+ -0.28,
+ phase + time * 0.4,
+ phase + time * 0.4 + 4.2,
+ )
+ ctx.stroke()
+ for (let i = 0; i < 3; i++) {
+ const angle = time * 0.55 + (i / 3) * TAU + phase
+ circle(
+ ctx,
+ Math.cos(angle) * radius * 1.48,
+ Math.sin(angle) * radius * 0.95,
+ 1.8,
+ )
+ ctx.fillStyle = '#ffb6a9'
+ ctx.fill()
+ }
+ }
+ ctx.restore()
+}
+
+function personalHorizon(
+ ctx: CanvasRenderingContext2D,
+ state: GameState,
+ w: number,
+ camera: number,
+ scale: number,
+ personalBest: number,
+ palette: string[],
+) {
+ if (state.phase !== 'running' || personalBest <= 30) return
+ const worldX = 200 + personalBest * 10
+ const x = worldX - camera
+ if (x < -40 || x > w + 40) return
+ const y = terrain(worldX, state.seed)
+ const fontSize = Math.max(10, 8 / scale)
+ const pennant = Math.max(27, 12 / scale)
+ const captionX = Math.min(w - fontSize * 4.5, Math.max(fontSize * 4.5, x))
+ ctx.save()
+ ctx.strokeStyle = '#fff0d4'
+ ctx.globalAlpha = 0.75
+ ctx.lineWidth = Math.max(1, 0.8 / scale)
+ ctx.setLineDash([4 / scale, 5 / scale])
+ ctx.beginPath()
+ ctx.moveTo(x, y + 3)
+ ctx.lineTo(x, y - 153)
+ ctx.stroke()
+ ctx.setLineDash([])
+ ctx.fillStyle = '#fff0d4'
+ ctx.beginPath()
+ ctx.moveTo(x, y - 153)
+ ctx.lineTo(x + pennant, y - 146)
+ ctx.lineTo(x, y - 136)
+ ctx.closePath()
+ ctx.fill()
+ ctx.globalAlpha = 0.8
+ ctx.fillStyle = palette[9]
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'alphabetic'
+ ctx.font = `500 ${fontSize}px system-ui, sans-serif`
+ ctx.fillText('YOUR HORIZON', captionX, y - 161 - fontSize * 1.5)
+ ctx.font = `650 ${fontSize * 1.16}px system-ui, sans-serif`
+ ctx.fillText(
+ `${Math.floor(personalBest).toLocaleString('en-US')} m`,
+ captionX,
+ y - 161,
+ )
+ ctx.restore()
+}
+
+function drawShip(
+ ctx: CanvasRenderingContext2D,
+ ship: ShipId,
+ time: number,
+ palette: string[],
+ charge: number,
+) {
+ const sail =
+ ship === 'manta' ? '#b798db' : ship === 'comet' ? '#ec8a67' : palette[11]
+ const sailLight =
+ ship === 'manta' ? '#dfcbf3' : ship === 'comet' ? '#ffe0a6' : '#c8f1d8'
+ const ink = palette[9]
+ const flutter = Math.sin(time * 6) * 2
+
+ ctx.fillStyle = '#ffd29d'
+ ctx.globalAlpha = 0.38
+ ctx.beginPath()
+ ctx.moveTo(-27, 5)
+ ctx.quadraticCurveTo(-51, 1 + flutter, -65, 9)
+ ctx.quadraticCurveTo(-42, 13 + flutter, -22, 10)
+ ctx.fill()
+ ctx.globalAlpha = 1
+
+ // Each craft has an immediately different silhouette, with a shared sailmaking language.
+ ctx.strokeStyle = ink
+ ctx.lineWidth = 2
+ if (ship === 'manta') {
+ ctx.fillStyle = sail
+ ctx.beginPath()
+ ctx.moveTo(2, -37)
+ ctx.quadraticCurveTo(-14, -53, -39, -25 + flutter)
+ ctx.quadraticCurveTo(-17, -22, -4, -10)
+ ctx.lineTo(17, -6)
+ ctx.quadraticCurveTo(31, -22, 46, -17)
+ ctx.quadraticCurveTo(26, -45, 2, -37)
+ ctx.fill()
+ ctx.stroke()
+ ctx.strokeStyle = sailLight
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.moveTo(-30, -26)
+ ctx.quadraticCurveTo(-13, -38, 2, -37)
+ ctx.lineTo(33, -21)
+ ctx.stroke()
+ ctx.strokeStyle = ink
+ ctx.beginPath()
+ ctx.moveTo(-14, -25)
+ ctx.lineTo(-1, 4)
+ ctx.lineTo(19, -21)
+ ctx.stroke()
+ } else {
+ const mastHeight = ship === 'comet' ? -60 : -69
+ ctx.fillStyle = sail
+ ctx.beginPath()
+ ctx.moveTo(4, mastHeight)
+ ctx.quadraticCurveTo(15, -49, 33 + flutter, -26)
+ ctx.quadraticCurveTo(19, -22, 4, -13)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = sailLight
+ ctx.globalAlpha = 0.65
+ ctx.beginPath()
+ ctx.moveTo(5, mastHeight + 2)
+ ctx.quadraticCurveTo(10, -43, 21, -25)
+ ctx.lineTo(5, -17)
+ ctx.closePath()
+ ctx.fill()
+ ctx.globalAlpha = 1
+ ctx.strokeStyle = sailLight
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.moveTo(5, -44)
+ ctx.lineTo(20, -39)
+ ctx.moveTo(5, -28)
+ ctx.lineTo(31, -27)
+ ctx.stroke()
+ ctx.strokeStyle = ink
+ ctx.lineWidth = 2.6
+ ctx.beginPath()
+ ctx.moveTo(4, mastHeight - 3)
+ ctx.lineTo(4, 6)
+ ctx.stroke()
+ ctx.strokeStyle = '#ffe5b3'
+ ctx.lineWidth = 0.9
+ ctx.beginPath()
+ ctx.moveTo(4, mastHeight + 6)
+ ctx.lineTo(-29, 5)
+ ctx.stroke()
+ }
+
+ // Wind-caught scarf, rider, goggles, and a low bent-knee stance.
+ ctx.fillStyle = '#de7577'
+ ctx.beginPath()
+ ctx.moveTo(-11, -28)
+ ctx.quadraticCurveTo(-24, -34, -41, -27 + flutter)
+ ctx.lineTo(-30, -26 + flutter)
+ ctx.lineTo(-38, -23 + flutter)
+ ctx.quadraticCurveTo(-22, -28, -12, -24)
+ ctx.fill()
+ ctx.strokeStyle = ink
+ ctx.lineWidth = 4.6
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(-11, -25)
+ ctx.lineTo(-14 - charge * 2, -11)
+ ctx.lineTo(-23, -3)
+ ctx.lineTo(-16, 4)
+ ctx.moveTo(-14, -11)
+ ctx.lineTo(-5, -6)
+ ctx.lineTo(-4, 4)
+ ctx.moveTo(-10, -21)
+ ctx.lineTo(-3, -14)
+ ctx.lineTo(3, -19)
+ ctx.stroke()
+ ctx.fillStyle = '#ffddb0'
+ circle(ctx, -10, -32, 6.5)
+ ctx.fill()
+ ctx.fillStyle = ink
+ ctx.beginPath()
+ ctx.arc(-10, -33, 6.6, Math.PI * 0.97, TAU)
+ ctx.lineTo(-3, -32)
+ ctx.lineTo(-5, -35)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillStyle = '#f6ac8c'
+ ctx.fillRect(-9, -33, 6, 2.7)
+
+ ctx.fillStyle = ink
+ ctx.strokeStyle = '#ffe1a3'
+ ctx.lineWidth = 1.4
+ ctx.beginPath()
+ ctx.moveTo(ship === 'comet' ? -36 : -31, 2)
+ ctx.quadraticCurveTo(-7, 8, ship === 'comet' ? 43 : 36, -2)
+ ctx.quadraticCurveTo(24, 14, -21, 13)
+ ctx.quadraticCurveTo(-29, 10, ship === 'comet' ? -36 : -31, 2)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.strokeStyle = sail
+ ctx.lineWidth = 2.1
+ ctx.beginPath()
+ ctx.moveTo(-21, 9)
+ ctx.quadraticCurveTo(-2, 13, 24, 4)
+ ctx.stroke()
+ if (ship === 'comet') {
+ ctx.fillStyle = '#fce0a4'
+ ctx.beginPath()
+ ctx.moveTo(-27, 4)
+ ctx.lineTo(-36, -9)
+ ctx.lineTo(-16, 5)
+ ctx.closePath()
+ ctx.fill()
+ }
+ ctx.lineCap = 'butt'
+}
+
+function player(
+ ctx: CanvasRenderingContext2D,
+ state: GameState,
+ camera: number,
+ time: number,
+ palette: string[],
+) {
+ const craft = state.player
+ const charge = Math.min(1, Math.max(0, craft.charge / 100))
+ const x = craft.x - camera
+ const groundY = terrain(craft.x, state.seed)
+ const altitude = Math.max(0, groundY - craft.y)
+ ctx.save()
+ ctx.fillStyle = palette[9]
+ ctx.globalAlpha = Math.max(0.03, 0.17 - altitude / 1800)
+ ctx.beginPath()
+ ctx.ellipse(x, groundY + 7, Math.max(14, 34 - altitude / 30), 4.3, 0, 0, TAU)
+ ctx.fill()
+ ctx.restore()
+
+ if (craft.trail.length > 1) {
+ for (let layer = 0; layer < 2; layer++) {
+ ctx.beginPath()
+ craft.trail.forEach((point, i) => {
+ const tx = point.x - camera
+ if (i === 0) ctx.moveTo(tx, point.y + 9)
+ else ctx.lineTo(tx, point.y + 9)
+ })
+ ctx.strokeStyle = palette[10]
+ ctx.globalAlpha = layer === 0 ? 0.1 : craft.boostTime > 0 ? 0.68 : 0.34
+ ctx.lineWidth = layer === 0 ? 12 : 2
+ ctx.lineCap = 'round'
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ ctx.lineCap = 'butt'
+ }
+
+ ctx.save()
+ ctx.translate(x, craft.y)
+ ctx.rotate(craft.rotation)
+ if (craft.boostTime > 0) {
+ const boost = ctx.createLinearGradient(-145, 0, -10, 0)
+ boost.addColorStop(0, '#fff1b100')
+ boost.addColorStop(0.7, '#ffeca647')
+ boost.addColorStop(1, '#fff5c1cc')
+ ctx.fillStyle = boost
+ ctx.beginPath()
+ ctx.moveTo(-21, -5)
+ ctx.quadraticCurveTo(-78, -12, -150, 8 + Math.sin(time * 18) * 3)
+ ctx.quadraticCurveTo(-79, 21, -20, 15)
+ ctx.fill()
+ }
+ if (craft.invincible > 0 || craft.boostTime > 0) {
+ ctx.strokeStyle = palette[11]
+ ctx.globalAlpha =
+ craft.boostTime > 0 ? 0.5 : 0.25 + Math.sin(time * 10) * 0.16
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ ctx.ellipse(0, -21, 48, 55, -0.2, 0.3, 5.6)
+ ctx.stroke()
+ ctx.globalAlpha = 1
+ }
+ if (craft.invincible > 0 && Math.sin(time * 23) > 0.45) ctx.globalAlpha = 0.55
+ drawShip(ctx, state.ship, time, palette, charge)
+ ctx.restore()
+
+ if (charge > 0.08 && craft.grounded) {
+ ctx.strokeStyle = palette[10]
+ ctx.globalAlpha = charge * 0.8
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.ellipse(
+ x,
+ craft.y + 15,
+ 33 + charge * 10,
+ 7,
+ craft.rotation,
+ Math.PI * 0.08,
+ Math.PI * 0.92,
+ )
+ ctx.stroke()
+ ctx.globalAlpha = 1
+ }
+}
+
+/** Draw in CSS pixels without changing the caller's device-pixel-ratio transform. */
+export function renderWorld(
+ ctx: CanvasRenderingContext2D,
+ state: GameState,
+ width: number,
+ height: number,
+ ambientTime: number,
+ reducedMotion: boolean,
+ personalBest = 0,
+): void {
+ if (width <= 0 || height <= 0) return
+ const ready = state.phase === 'ready'
+ // Portrait play keeps enough course ahead for a deliberate jump at full speed.
+ // Extend the sky and sand instead of cropping or stretching the physical world.
+ const scale = ready
+ ? height / WORLD_HEIGHT
+ : Math.min(height / WORLD_HEIGHT, width / 900)
+ const w = width / scale
+ const verticalOffset = Math.max(0, height / scale - WORLD_HEIGHT) * 0.45
+ const top = -verticalOffset
+ const bottom = height / scale - verticalOffset
+ const camera = state.player.x - w * (ready ? 0.64 : 0.25)
+ const time = reducedMotion ? state.time : ambientTime
+ const palette = colors(state.distance)
+
+ ctx.save()
+ ctx.scale(scale, scale)
+ ctx.translate(0, verticalOffset)
+ ctx.lineJoin = 'round'
+ sky(ctx, w, camera, time, palette, ready, state.distance, top, bottom)
+ mountains(ctx, w, camera, palette)
+
+ ctx.save()
+ if (!reducedMotion && state.shake > 0) {
+ const amount = Math.min(8, state.shake * 6)
+ ctx.translate(
+ Math.sin(time * 93) * amount,
+ Math.cos(time * 117) * amount * 0.6,
+ )
+ }
+ ground(ctx, w, camera, state.seed, palette, bottom)
+ personalHorizon(ctx, state, w, camera, scale, personalBest, palette)
+
+ for (const entity of state.entities) {
+ const x = entity.x - camera
+ if (entity.collected || x < -100 || x > w + 100) continue
+ if (entity.kind === 'rock' || entity.kind === 'storm')
+ hazard(ctx, entity, x, time)
+ else collectible(ctx, entity, x, time, palette)
+ }
+
+ for (const particle of state.particles) {
+ const x = particle.x - camera
+ if (x < -30 || x > w + 30) continue
+ ctx.globalAlpha = Math.max(0, Math.min(1, particle.life / particle.maxLife))
+ ctx.fillStyle = particle.color
+ if (particle.size > 3) {
+ diamond(ctx, x, particle.y, particle.size)
+ } else {
+ circle(ctx, x, particle.y, particle.size)
+ }
+ ctx.fill()
+ }
+ ctx.globalAlpha = 1
+ player(ctx, state, camera, time, palette)
+ ctx.restore()
+
+ if (!reducedMotion && state.phase === 'running' && state.player.vx > 500) {
+ const speed = Math.min(1, (state.player.vx - 500) / 500)
+ ctx.strokeStyle = palette[2]
+ ctx.globalAlpha = speed * 0.27
+ ctx.lineWidth = 1
+ for (let i = 0; i < 10; i++) {
+ const x = mod(random(i + 93) * w - time * (110 + i * 11), w + 180) - 90
+ const y = 100 + random(i + 54) * 520
+ if (Math.abs(y - state.player.y) < 55) continue
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ ctx.lineTo(x + 25 + speed * 65, y)
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ }
+
+ // A narrow film of warmth at the edges unifies the paper-cut silhouettes.
+ const vignette = ctx.createLinearGradient(0, top, 0, bottom)
+ vignette.addColorStop(0, '#502e4810')
+ vignette.addColorStop(0.16, '#502e4800')
+ vignette.addColorStop(0.79, '#502e4800')
+ vignette.addColorStop(1, '#502e4824')
+ ctx.fillStyle = vignette
+ ctx.fillRect(0, top, w, bottom - top)
+ ctx.restore()
+}
diff --git a/projects/sunwake-a6f3/src/game/types.ts b/projects/sunwake-a6f3/src/game/types.ts
new file mode 100644
index 00000000..b421fe94
--- /dev/null
+++ b/projects/sunwake-a6f3/src/game/types.ts
@@ -0,0 +1,117 @@
+export type GamePhase = 'ready' | 'running' | 'paused' | 'ended'
+export type GameMode = 'voyage' | 'daily' | 'zen'
+export type ShipId = 'sol' | 'manta' | 'comet'
+export type EntityKind = 'spark' | 'ring' | 'storm' | 'rock' | 'sunwell'
+export type EventKind =
+ 'spark' | 'ring' | 'landing' | 'hit' | 'boost' | 'near' | 'biome' | 'end'
+
+export interface Player {
+ x: number
+ y: number
+ vx: number
+ vy: number
+ rotation: number
+ grounded: boolean
+ energy: number
+ charge: number
+ invincible: number
+ boostTime: number
+ trail: { x: number; y: number; life: number }[]
+}
+
+export interface Entity {
+ id: number
+ kind: EntityKind
+ x: number
+ y: number
+ radius: number
+ collected: boolean
+ nearMissed?: boolean
+ phase: number
+}
+
+export interface Particle {
+ x: number
+ y: number
+ vx: number
+ vy: number
+ life: number
+ maxLife: number
+ size: number
+ color: string
+}
+
+export interface GameEvent {
+ id: number
+ kind: EventKind
+ text: string
+ time: number
+}
+
+export interface GameState {
+ phase: GamePhase
+ mode: GameMode
+ seed: number
+ ship: ShipId
+ time: number
+ distance: number
+ score: number
+ sparks: number
+ rings: number
+ combo: number
+ bestCombo: number
+ cleanLandings: number
+ airtime: number
+ maxAirtime: number
+ nearMisses: number
+ boosts: number
+ biome: number
+ player: Player
+ entities: Entity[]
+ particles: Particle[]
+ events: GameEvent[]
+ reason: string
+ shake: number
+}
+
+export interface GameInput {
+ dive: boolean
+ boost: boolean
+}
+export interface RunOptions {
+ mode: GameMode
+ ship: ShipId
+ seed: number
+}
+
+export const WORLD_HEIGHT = 720
+export const BIOMES = [
+ {
+ name: 'The amber sea',
+ short: 'Amber sea',
+ color: '#f6c779',
+ mark: 'I',
+ note: 'Where every journey catches the light.',
+ },
+ {
+ name: 'The rose ruins',
+ short: 'Rose ruins',
+ color: '#f5a6ae',
+ mark: 'II',
+ note: 'Old worlds. New horizons.',
+ },
+ {
+ name: 'The violet reach',
+ short: 'Violet reach',
+ color: '#c4acf4',
+ mark: 'III',
+ note: 'Follow the stars beyond the dunes.',
+ },
+ {
+ name: 'The blue hour',
+ short: 'Blue hour',
+ color: '#91d7dc',
+ mark: 'IV',
+ note: 'Even the longest night gives way.',
+ },
+] as const
diff --git a/projects/sunwake-a6f3/src/index.css b/projects/sunwake-a6f3/src/index.css
new file mode 100644
index 00000000..76e319f5
--- /dev/null
+++ b/projects/sunwake-a6f3/src/index.css
@@ -0,0 +1,107 @@
+@font-face {
+ font-family: Bricolage;
+ src: url('/fonts/bricolage-grotesque-bold.ttf') format('truetype');
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: DM Sans;
+ src: url('/fonts/dm-sans-regular.ttf') format('truetype');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: DM Sans;
+ src: url('/fonts/dm-sans-semibold.ttf') format('truetype');
+ font-weight: 500 700;
+ font-style: normal;
+ font-display: swap;
+}
+:root {
+ font-family: 'DM Sans', sans-serif;
+ color: #342c40;
+ background: #faf7f2;
+ font-synthesis: weight;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 400;
+ font-size: 14px;
+ font-synthesis: weight;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ min-width: 320px;
+}
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+button {
+ font: inherit;
+}
+button,
+a {
+ touch-action: manipulation;
+}
+button {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: default;
+}
+button {
+ color: inherit;
+}
+button:focus-visible,
+a:focus-visible {
+ outline: 3px solid #438e86;
+ outline-offset: 5px;
+}
+button:disabled {
+ opacity: 0.5;
+}
+button svg {
+ flex: none;
+}
+svg {
+ vertical-align: middle;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+button {
+ border: 0;
+}
+::selection {
+ background: #f3bc90;
+ color: #342c40;
+}
+kbd {
+ font-family: inherit;
+ font-size: 0.7em;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ border: 1px solid currentColor;
+ border-radius: 4px;
+ padding: 3px 5px;
+ white-space: nowrap;
+}
+dialog {
+ color: inherit;
+}
+button:focus:not(:focus-visible) {
+ outline: none;
+}
diff --git a/projects/sunwake-a6f3/src/main.tsx b/projects/sunwake-a6f3/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/projects/sunwake-a6f3/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/projects/sunwake-a6f3/tests/progress.test.mjs b/projects/sunwake-a6f3/tests/progress.test.mjs
new file mode 100644
index 00000000..57a3c05a
--- /dev/null
+++ b/projects/sunwake-a6f3/tests/progress.test.mjs
@@ -0,0 +1,153 @@
+import assert from 'node:assert/strict'
+import { after, beforeEach, test } from 'node:test'
+import { dailySeed, freshProgress, loadProgress, MISSIONS, saveProgress, settleRun } from '../src/game/progress.ts'
+
+const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
+const key = 'sunwake.progress.v1'
+let storage
+
+beforeEach(() => {
+ storage = new Map()
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
+ })
+})
+
+after(() => {
+ if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
+ else delete globalThis.localStorage
+})
+
+const run = (overrides = {}) => ({
+ phase: 'ended', mode: 'voyage', distance: 1000, score: 4000, sparks: 20,
+ rings: 10, cleanLandings: 8, maxAirtime: 7, bestCombo: 10, boosts: 6, nearMisses: 6,
+ ...overrides,
+})
+
+test('new players get a silent game, the free Sol, and no invented records', () => {
+ assert.deepEqual(loadProgress(), freshProgress())
+ const progress = freshProgress()
+ assert.equal(progress.sound, false)
+ assert.deepEqual(progress.owned, ['sol'])
+ assert.equal(progress.bank, 0)
+ assert.equal(progress.daily.date, new Date().toISOString().slice(0, 10))
+})
+
+test('malformed JSON, unexpected versions, and non-object saves recover safely', () => {
+ for (const bad of ['{broken', 'null', '12', 'true', '[]', '{"version":2}', '{"version":"1"}']) {
+ storage.set(key, bad)
+ assert.deepEqual(loadProgress(), freshProgress())
+ }
+})
+
+test('corrupt saves cannot select locked ships or inject unknown mission IDs', () => {
+ storage.set(key, JSON.stringify({
+ version: 1, bank: -300, best: '999', bestScore: null, totalDistance: 9.9, totalRuns: 1e99,
+ owned: ['manta', 'manta', 'imaginary', 42], selected: 'comet', sound: 'true',
+ completed: ['first-light', 'first-light', 'fake-mission', null],
+ daily: { date: '2026-02-31', best: 1000 },
+ }))
+ const saved = loadProgress()
+ assert.equal(saved.bank, 0)
+ assert.equal(saved.best, 0)
+ assert.equal(saved.bestScore, 0)
+ assert.equal(saved.totalDistance, 9)
+ assert.equal(saved.totalRuns, 1e12)
+ assert.deepEqual(saved.owned, ['sol', 'manta'])
+ assert.equal(saved.selected, 'sol')
+ assert.deepEqual(saved.completed, ['first-light'])
+ assert.equal(saved.sound, false)
+ assert.equal(saved.daily.best, 0)
+})
+
+test('overflow and nonfinite numbers never load into records', () => {
+ storage.set(key, '{"version":1,"bank":1e999,"best":-1e999,"bestScore":1e999}')
+ const saved = loadProgress()
+ assert.equal(saved.bank, 0)
+ assert.equal(saved.best, 0)
+ assert.equal(saved.bestScore, 0)
+})
+
+test('storage errors do not prevent play or throw during saves', () => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ get() { throw new Error('Storage access denied') },
+ })
+ assert.deepEqual(loadProgress(), freshProgress())
+ assert.doesNotThrow(() => saveProgress(freshProgress()))
+})
+
+test('progress round trips with independent ownership and mission state', () => {
+ const expected = { ...freshProgress(), bank: 91, best: 3821, owned: ['sol', 'manta'], selected: 'manta', completed: ['first-light'], sound: true }
+ saveProgress(expected)
+ const actual = loadProgress()
+ assert.deepEqual(actual, expected)
+ actual.owned.push('comet')
+ assert.deepEqual(loadProgress().owned, ['sol', 'manta'])
+})
+
+test('unfinished runs never award sparks, records, or missions', () => {
+ for (const phase of ['ready', 'running', 'paused']) {
+ const progress = freshProgress()
+ const result = settleRun(progress, run({ phase }), '2026-09-24')
+ assert.equal(result.progress, progress)
+ assert.equal(result.earned, 0)
+ assert.equal(result.newBest, false)
+ assert.deepEqual(result.completed, [])
+ }
+})
+
+test('only the first three incomplete missions are eligible, even on an exceptional run', () => {
+ const progress = freshProgress()
+ const result = settleRun(progress, run({ distance: 10000, sparks: 100 }), '2026-09-24')
+ assert.deepEqual(result.completed.map(m => m.id), MISSIONS.slice(0, 3).map(m => m.id))
+ assert.equal(result.earned, 100 + 25 + 25 + 30)
+ assert.equal(result.progress.bank, result.earned)
+ assert.equal(result.progress.totalRuns, 1)
+ assert.equal(result.progress.best, 10000)
+ assert.equal(result.newBest, true)
+ assert.deepEqual(progress, freshProgress(), 'settlement must not mutate the caller state')
+})
+
+test('free flight tracks practice distance without awarding sparks, missions, or competitive records', () => {
+ const progress = {
+ ...freshProgress(), bank: 75, best: 1200, bestScore: 6000, totalDistance: 2000, totalRuns: 3,
+ completed: ['first-light'], daily: { date: '2026-09-24', best: 1100 },
+ }
+ const before = structuredClone(progress)
+ const result = settleRun(progress, run({ mode: 'zen', distance: 100000, sparks: 20000, score: 900000 }), '2026-09-25')
+ assert.deepEqual(result.progress, { ...progress, totalDistance: 102000, totalRuns: 4 })
+ assert.equal(result.earned, 0)
+ assert.deepEqual(result.completed, [])
+ assert.equal(result.newBest, false)
+ assert.deepEqual(progress, before)
+})
+
+test('completed missions never pay again, and new missions wait for the next run', () => {
+ const first = settleRun(freshProgress(), run(), '2026-09-24')
+ const second = settleRun(first.progress, run({ distance: 900 }), '2026-09-24')
+ assert.deepEqual(second.completed.map(m => m.id), MISSIONS.slice(3, 6).map(m => m.id))
+ assert.equal(second.earned, 20 + 40 + 40 + 50)
+ assert.equal(second.progress.bank, first.progress.bank + second.earned)
+ assert.equal(second.progress.totalDistance, 1900)
+ assert.equal(second.progress.best, 1000)
+ assert.equal(second.progress.totalRuns, 2)
+ assert.equal(second.newBest, false)
+})
+
+test('a daily best belongs to its UTC date and only daily runs improve it', () => {
+ const progress = { ...freshProgress(), daily: { date: '2026-09-24', best: 600 } }
+ const voyage = settleRun(progress, run({ distance: 900 }), '2026-09-24')
+ assert.deepEqual(voyage.progress.daily, { date: '2026-09-24', best: 600 })
+ const daily = settleRun(progress, run({ mode: 'daily', distance: 900 }), '2026-09-24')
+ assert.deepEqual(daily.progress.daily, { date: '2026-09-24', best: 900 })
+ const nextDay = settleRun(daily.progress, run({ mode: 'daily', distance: 300 }), '2026-09-25')
+ assert.deepEqual(nextDay.progress.daily, { date: '2026-09-25', best: 300 })
+})
+
+test('daily terrain seeds are repeatable, date-dependent unsigned integers', () => {
+ assert.equal(dailySeed('2026-09-24'), dailySeed('2026-09-24'))
+ assert.notEqual(dailySeed('2026-09-24'), dailySeed('2026-09-25'))
+ assert.equal(dailySeed('2026-09-24') >>> 0, dailySeed('2026-09-24'))
+})
diff --git a/projects/sunwake-a6f3/tsconfig.app.json b/projects/sunwake-a6f3/tsconfig.app.json
new file mode 100644
index 00000000..d6ca4e74
--- /dev/null
+++ b/projects/sunwake-a6f3/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+ "strict": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/projects/sunwake-a6f3/tsconfig.json b/projects/sunwake-a6f3/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/projects/sunwake-a6f3/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/projects/sunwake-a6f3/tsconfig.node.json b/projects/sunwake-a6f3/tsconfig.node.json
new file mode 100644
index 00000000..a0267b23
--- /dev/null
+++ b/projects/sunwake-a6f3/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+ "strict": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/projects/sunwake-a6f3/vite.config.ts b/projects/sunwake-a6f3/vite.config.ts
new file mode 100644
index 00000000..50f5e243
--- /dev/null
+++ b/projects/sunwake-a6f3/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ base: './', // required: relative base so the build works under /agent-experiments/projects//
+ plugins: [react()],
+})