From c9f5c8a1664e3e782a347da0152d2d0aae8a49d3 Mon Sep 17 00:00:00 2001
From: Kyle Deliyannides <112675076+kkd16@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:08:39 -0400
Subject: [PATCH] Add Lumen: The Lost Gardens puzzle adventure
---
projects/lumen-atelier-8f3c/.gitignore | 24 +
projects/lumen-atelier-8f3c/JOURNAL.md | 41 +
projects/lumen-atelier-8f3c/README.md | 60 +
projects/lumen-atelier-8f3c/eslint.config.js | 22 +
projects/lumen-atelier-8f3c/index.html | 15 +
projects/lumen-atelier-8f3c/package.json | 36 +
.../lumen-atelier-8f3c/playwright.config.ts | 25 +
projects/lumen-atelier-8f3c/pnpm-lock.yaml | 1774 +++++++++++++++++
projects/lumen-atelier-8f3c/project.json | 8 +
.../lumen-atelier-8f3c/public/favicon.svg | 1 +
.../lumen-atelier-8f3c/public/thumbnail.svg | 51 +
projects/lumen-atelier-8f3c/src/App.css | 342 ++++
projects/lumen-atelier-8f3c/src/App.tsx | 421 ++++
projects/lumen-atelier-8f3c/src/Board.css | 108 +
projects/lumen-atelier-8f3c/src/Board.tsx | 231 +++
projects/lumen-atelier-8f3c/src/audio.ts | 142 ++
projects/lumen-atelier-8f3c/src/game.ts | 343 ++++
projects/lumen-atelier-8f3c/src/index.css | 27 +
projects/lumen-atelier-8f3c/src/main.tsx | 10 +
projects/lumen-atelier-8f3c/src/progress.ts | 92 +
.../lumen-atelier-8f3c/tests/game.test.mjs | 132 ++
.../lumen-atelier-8f3c/tests/gameplay.spec.ts | 292 +++
projects/lumen-atelier-8f3c/tsconfig.app.json | 26 +
projects/lumen-atelier-8f3c/tsconfig.json | 7 +
.../lumen-atelier-8f3c/tsconfig.node.json | 25 +
projects/lumen-atelier-8f3c/vite.config.ts | 8 +
26 files changed, 4263 insertions(+)
create mode 100644 projects/lumen-atelier-8f3c/.gitignore
create mode 100644 projects/lumen-atelier-8f3c/JOURNAL.md
create mode 100644 projects/lumen-atelier-8f3c/README.md
create mode 100644 projects/lumen-atelier-8f3c/eslint.config.js
create mode 100644 projects/lumen-atelier-8f3c/index.html
create mode 100644 projects/lumen-atelier-8f3c/package.json
create mode 100644 projects/lumen-atelier-8f3c/playwright.config.ts
create mode 100644 projects/lumen-atelier-8f3c/pnpm-lock.yaml
create mode 100644 projects/lumen-atelier-8f3c/project.json
create mode 100644 projects/lumen-atelier-8f3c/public/favicon.svg
create mode 100644 projects/lumen-atelier-8f3c/public/thumbnail.svg
create mode 100644 projects/lumen-atelier-8f3c/src/App.css
create mode 100644 projects/lumen-atelier-8f3c/src/App.tsx
create mode 100644 projects/lumen-atelier-8f3c/src/Board.css
create mode 100644 projects/lumen-atelier-8f3c/src/Board.tsx
create mode 100644 projects/lumen-atelier-8f3c/src/audio.ts
create mode 100644 projects/lumen-atelier-8f3c/src/game.ts
create mode 100644 projects/lumen-atelier-8f3c/src/index.css
create mode 100644 projects/lumen-atelier-8f3c/src/main.tsx
create mode 100644 projects/lumen-atelier-8f3c/src/progress.ts
create mode 100644 projects/lumen-atelier-8f3c/tests/game.test.mjs
create mode 100644 projects/lumen-atelier-8f3c/tests/gameplay.spec.ts
create mode 100644 projects/lumen-atelier-8f3c/tsconfig.app.json
create mode 100644 projects/lumen-atelier-8f3c/tsconfig.json
create mode 100644 projects/lumen-atelier-8f3c/tsconfig.node.json
create mode 100644 projects/lumen-atelier-8f3c/vite.config.ts
diff --git a/projects/lumen-atelier-8f3c/.gitignore b/projects/lumen-atelier-8f3c/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/.gitignore
@@ -0,0 +1,24 @@
+# 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?
diff --git a/projects/lumen-atelier-8f3c/JOURNAL.md b/projects/lumen-atelier-8f3c/JOURNAL.md
new file mode 100644
index 00000000..7710459b
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/JOURNAL.md
@@ -0,0 +1,41 @@
+# Lumen: The Lost Gardens — journal
+
+An atmospheric puzzle game about restoring floating gardens with light. The campaign introduces six optical mechanisms over 48 puzzles; daily and seeded workshop gardens keep the game open-ended.
+
+## Design
+
+- Palette: parchment mist `#f0f3ed`, limestone `#d5dfca`, deep lagoon `#133d3c`, forest ink `#243e36`, sunlight `#e8bd70`, faded lilac `#ada6c0`.
+- Typography: Cormorant Garamond for literary titles, DM Sans for clear interface text. All fonts bundled locally.
+- Layout: calm botanical field guide surrounding a large, luminous isometric stone garden. The playable garden is the hero.
+- Signature: light traces engraved paths across floating stone slabs and wakes faceted seed crystals.
+- Aesthetic review: avoided a generic space dashboard; the botanical field-guide frame and tactile light-bearing ruins ground the interface in the game's subject.
+
+## Ideas / backlog
+
+- [x] 48 verified campaign puzzles across six chapters
+- [x] Isometric interactive garden with keyboard and touch support
+- [x] Daily puzzle and shareable seeded workshop gardens
+- [x] Undo, restart, two-stage hints, chapter selection, and completion awards
+- [x] Local progress, backup/import, settings, and optional synthesized audio
+- [x] Field guide, narrative memories, and achievements
+- [x] Responsive design and reduced motion support
+- [x] Custom static catalog thumbnail
+- [x] Engine and browser interaction verification
+- [x] Run exact repository gate and publish a PR
+- [ ] Future: a freeform tile editor and community puzzle exchange
+
+## Session log
+
+- 2026-09-24 (codex, gpt-6): Started from the required template; established visual direction and parallelized puzzle engine, garden renderer, and persistence/audio work.
+
+- 2026-09-24 (codex, gpt-6): Shipped the full campaign, atlas, daily/workshop, eight achievements, journal ending, responsive isometric renderer, synthesized audio, local sessions, safe progress backups, and accessibility settings. Verified all 48 solutions and 600 generated boards with 11 engine tests; browser tests cover real pointer solves, gates/bridges/portals, hints/undo, backups, mobile, and the ending. Fixed deep-link resume, storage failure reporting, and saved ambience without autoplay. Exact repository conformance/lint/build/output gate passed.
+
+## Follow-up notes
+
+- The canonical guide turn count is a known valid solution budget; it is not an optimality proof.
+- The workshop generates reproducible boards from seeds. A freeform tile editor remains a separate future feature.
+- Save data is device-local. Export transfers completed gardens; only the active arrangement is saved locally and undo history resets on reload.
+- Font subsets are bundled locally; game, art, and audio need no external services.
+- See README.md for development and verification commands.
+
+- 2026-09-24 (codex, gpt-6): Final release verified with 11 engine tests, 13 browser scenarios, and the exact repository gate. Publishing the isolated project branch through the mandatory auto-merge PR workflow.
diff --git a/projects/lumen-atelier-8f3c/README.md b/projects/lumen-atelier-8f3c/README.md
new file mode 100644
index 00000000..1b96d317
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/README.md
@@ -0,0 +1,60 @@
+# Lumen: The Lost Gardens
+
+An unhurried light-routing puzzle game. Rotate carved stone channels on a floating garden to connect a sun well to every sleeping crystal.
+
+## Play
+
+- **48 campaign gardens** across six chapters, with an ending and six recoverable journal memories.
+- **Six evolving rules:** turning channels, branches, rooted stones, directional gates, isolated bridges, and paired portals.
+- **A daily garden** shared by UTC date, with date links that remain replayable.
+- **Seeded workshop gardens:** choose a chapter's rules and any 32-bit seed; share the resulting URL.
+- **Three-bloom awards**, eight achievements, unlimited undo/restart, and optional two-step hints.
+- **Local saves and portable backups**, keyboard support, reduced motion, contrast settings, and optional synthesized sound and ambience.
+- Every chapter is open from the beginning. There are no timers, accounts, ads, or network game services.
+
+## Controls
+
+| Action | Mouse / touch | Keyboard |
+| --- | --- | --- |
+| Turn a stone clockwise | Click / tap | Tab to stone, then Enter or Space |
+| Turn counterclockwise | Shift + click | Shift + Enter / Space |
+| Move focus across stones | — | Arrow keys |
+| Undo | Undo | Z or U |
+| Restart | Restart | R |
+| Hint | A gentle nudge | H |
+| Field guide | Help / Field guide | ? |
+| Close dialog / leave focus mode | Close | Escape |
+
+A hint first identifies a useful stone. Request it again to rotate that stone to the guide solution. Hints reduce the current attempt's award to one bloom. Three blooms require a solution within the displayed turn guide without hints; any other unassisted solution earns two. The guide is a known-solution budget, not a claim of a mathematically shortest solution. Undo preserves the hint count; restarting begins a new attempt. Best awards remain saved when replaying.
+
+Progress and the current garden's arrangement are saved locally. Export/import in Settings transfers completed gardens; it does not transfer an unfinished arrangement or undo history. Importing preserves existing best scores. If storage is unavailable, playing still works and the footer advises creating a backup. Saved ambience begins after the next player interaction; audio never starts on page load.
+
+## Development
+
+```sh
+pnpm install
+pnpm dev
+pnpm lint
+pnpm build
+node --test tests/game.test.mjs
+pnpm exec playwright install chromium
+pnpm exec playwright test
+```
+
+Use Node 24 for the engine tests (native TypeScript stripping). Browser tests accept `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` when Chromium is already installed elsewhere. Playwright starts a local Vite server and keeps screenshots/traces under ignored `node_modules/.cache/`.
+
+Run the repository's exact release gate from the repository root:
+
+```sh
+node scripts/verify-project.mjs lumen-atelier-8f3c
+```
+
+## Implementation
+
+- `src/game.ts`: deterministic generation, solutions, light propagation, and hints. Sources and receivers are fixed; light requires reciprocal ports. A bridge keeps its two channels isolated, gates accept only the correct incoming direction, and matched portals connect distant components. Visited entry states prevent infinite cycles.
+- `src/Board.tsx`: responsive interactive SVG with procedural stone, crystal, plant, and light artwork; no image downloads.
+- `src/App.tsx`: campaign, atlas, workshop, journal, settings, awards, hash routing, and current-session state.
+- `src/progress.ts`: validated persistent records and portable, mergeable backups.
+- `src/audio.ts`: small Web Audio instrument with lazy creation and explicit cleanup.
+
+All imagery, typography, and code are served from the app's relative Vite base. The only runtime persistence is browser local storage. No tracking or third-party requests.
diff --git a/projects/lumen-atelier-8f3c/eslint.config.js b/projects/lumen-atelier-8f3c/eslint.config.js
new file mode 100644
index 00000000..ef614d25
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/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/lumen-atelier-8f3c/index.html b/projects/lumen-atelier-8f3c/index.html
new file mode 100644
index 00000000..2b3ae055
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Lumen — The Lost Gardens
+
+
+
+
+
+
diff --git a/projects/lumen-atelier-8f3c/package.json b/projects/lumen-atelier-8f3c/package.json
new file mode 100644
index 00000000..39fa338a
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "lumen-lost-gardens",
+ "private": true,
+ "version": "1.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": {
+ "@fontsource/cormorant-garamond": "^5.3.0",
+ "@fontsource/dm-sans": "^5.3.0",
+ "lucide-react": "^1.47.0",
+ "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/lumen-atelier-8f3c/playwright.config.ts b/projects/lumen-atelier-8f3c/playwright.config.ts
new file mode 100644
index 00000000..f4d84fb0
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests',
+ testMatch: '**/*.spec.ts',
+ timeout: 60000,
+ expect: { timeout: 8000 },
+ fullyParallel: true,
+ workers: 3,
+ reporter: 'list',
+ outputDir: 'node_modules/.cache/playwright-results',
+ use: {
+ baseURL: 'http://127.0.0.1:5174',
+ viewport: { width: 1440, height: 1000 },
+ reducedMotion: 'reduce',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH },
+ },
+ webServer: {
+ command: 'pnpm dev --host 127.0.0.1 --port 5174 --strictPort',
+ url: 'http://127.0.0.1:5174',
+ reuseExistingServer: !process.env.CI,
+ },
+})
diff --git a/projects/lumen-atelier-8f3c/pnpm-lock.yaml b/projects/lumen-atelier-8f3c/pnpm-lock.yaml
new file mode 100644
index 00000000..f9cafc5e
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/pnpm-lock.yaml
@@ -0,0 +1,1774 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@fontsource/cormorant-garamond':
+ specifier: ^5.3.0
+ version: 5.3.0
+ '@fontsource/dm-sans':
+ specifier: ^5.3.0
+ version: 5.3.0
+ lucide-react:
+ specifier: ^1.47.0
+ version: 1.47.0(react@19.2.7)
+ 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}
+
+ '@fontsource/cormorant-garamond@5.3.0':
+ resolution: {integrity: sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==}
+
+ '@fontsource/dm-sans@5.3.0':
+ resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==}
+
+ '@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==}
+
+ lucide-react@1.47.0:
+ resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ 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
+
+ '@fontsource/cormorant-garamond@5.3.0': {}
+
+ '@fontsource/dm-sans@5.3.0': {}
+
+ '@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
+
+ lucide-react@1.47.0(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+
+ 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/lumen-atelier-8f3c/project.json b/projects/lumen-atelier-8f3c/project.json
new file mode 100644
index 00000000..cc846604
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/project.json
@@ -0,0 +1,8 @@
+{
+ "title": "Lumen: The Lost Gardens",
+ "description": "Restore floating gardens with light in 48 atmospheric puzzles. Discover six mechanics, a daily ritual, and endless shareable gardens.",
+ "agent": "codex",
+ "model": "gpt-6",
+ "tags": ["puzzle", "games", "creative"],
+ "createdAt": "2026-09-24"
+}
diff --git a/projects/lumen-atelier-8f3c/public/favicon.svg b/projects/lumen-atelier-8f3c/public/favicon.svg
new file mode 100644
index 00000000..4cdfaba4
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/projects/lumen-atelier-8f3c/public/thumbnail.svg b/projects/lumen-atelier-8f3c/public/thumbnail.svg
new file mode 100644
index 00000000..676c6a9c
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/public/thumbnail.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A LITTLE LIGHT. A LOST WORLD.
+ LUMEN
+ THE LOST GARDENS
+
+ Follow the light.
+ Bring a forgotten world to life.
+ A PUZZLE ODYSSEY
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 48 GARDENS
+ 6 WORLDS
+ ENDLESS DISCOVERY
+ ✦
+
diff --git a/projects/lumen-atelier-8f3c/src/App.css b/projects/lumen-atelier-8f3c/src/App.css
new file mode 100644
index 00000000..90e4666f
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/App.css
@@ -0,0 +1,342 @@
+.app-shell{min-height:100vh;background:radial-gradient(ellipse at 85% 0%,#f9faf3 0,transparent 55%),var(--paper)}
+.topbar{height:89px;display:flex;align-items:center;justify-content:space-between;padding:0 43px 0 32px;border-bottom:1px solid var(--line);gap:25px;background:#f7f8f2c9;position:relative;z-index:5}
+.brand{display:flex;align-items:center;gap:16px;background:none;padding:0;text-align:left;min-width:198px}
+.brand-mark{width:26px;height:40px;position:relative;display:block;margin:0 5px}
+.brand-mark:before{content:'';position:absolute;width:23px;height:23px;transform:rotate(45deg) skew(13deg,13deg);top:9px;border:1px solid #789278;background:#dde3cc}
+.brand-mark:after{content:'';position:absolute;top:0;left:12px;height:41px;width:1px;background:#8c9c76}
+.brand-mark span{position:absolute;width:44px;height:1px;background:#a4b092;left:-9px;top:20px}
+.brand-type{font-family:var(--serif);font-size:31px;line-height:1;letter-spacing:5px;font-weight:500}
+.brand-type small{display:block;font-family:'DM Sans',sans-serif;font-weight:500;font-size:7.5px;letter-spacing:2.2px;margin-top:7px}
+.main-nav{display:flex;gap:32px;height:100%;align-items:center}
+.main-nav button{display:flex;align-items:center;gap:9px;position:relative;height:100%;padding:0 1px;color:#798374;background:none;font-size:12px;white-space:nowrap}
+.main-nav button:after{content:'';position:absolute;bottom:0;left:50%;right:50%;height:2px;background:#426449;transition:left .2s,right .2s}
+.main-nav button.active{color:#2a4534}
+.main-nav button.active:after{left:0;right:0}
+.main-nav button:hover{color:#294432}
+.nav-dot{width:4px;height:4px;background:#b4995d;border-radius:50%;margin-left:-3px}
+.topbar-tools{display:flex;gap:13px;align-items:center}
+.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;background:transparent;border-radius:50%;color:#6e7e6b;transition:background .2s,color .2s;flex-shrink:0}
+.icon-button:hover,.icon-button.enabled{background:#e5ebdf;color:#344f38}
+.app-body{display:flex;min-height:calc(100vh - 89px)}
+.sidebar{flex:0 0 238px;width:238px;border-right:1px solid var(--line);padding:33px 17px 0 19px;display:flex;flex-direction:column;background:#f0f3eeb3}
+.eyebrow{display:block;font-size:9px;letter-spacing:1.8px;line-height:1.6;font-weight:600;color:#85917a}
+.sidebar-title{display:flex;align-items:center;justify-content:space-between;margin:0 12px}
+.sidebar-title .eyebrow{font-size:8px;letter-spacing:1.4px}
+.sidebar-count{font-size:9px;letter-spacing:1px;color:#899381;font-variant-numeric:tabular-nums}
+.journey-progress{height:2px;background:#dce4d5;margin:16px 12px 27px}
+.journey-progress>span{height:100%;display:block;background:#6e8863;min-width:3px;transition:width .4s}
+.chapter-list{display:flex;flex-direction:column;gap:8px}
+.chapter-button{position:relative;display:flex;gap:12px;align-items:center;min-height:89px;padding:12px 11px;width:100%;text-align:left;border:1px solid transparent;border-radius:5px;background:none;transition:background .2s,border-color .2s}
+.chapter-button:hover{background:#e8eee3}
+.chapter-button.selected{background:#e3e9db;border-color:#d5decb}
+.chapter-icon{position:relative;width:41px;flex:0 0 41px;color:#a5ae94}
+.sigil{display:block;width:100%;height:auto}
+.chapter-button.selected .chapter-icon{color:#65805b}
+.chapter-copy{display:flex;flex-direction:column;gap:4px}
+.chapter-copy small{font-size:7px;letter-spacing:1.5px;color:#9ba48e;font-weight:600}
+.chapter-copy strong{font-size:11px;font-weight:500;color:#60735b;line-height:1.35}
+.chapter-copy>span{font-size:8px;color:#97a08c}
+.chapter-button.selected .chapter-copy strong{color:#304b33}
+.chapter-button.selected .chapter-copy small{color:#6e865f}
+.chapter-button.selected .chapter-copy>span{color:#879578}
+.chapter-active-dot{position:absolute;right:10px;top:17px;width:4px;height:4px;border-radius:50%;background:#7f955d}
+.chapter-check{position:absolute;bottom:0;right:0;background:#6e885e;color:#fff;border-radius:50%;width:15px;height:15px;display:grid;place-items:center;border:2px solid var(--paper)}
+.sidebar-link{display:flex;align-items:center;gap:10px;padding:15px 12px;background:none;color:#7c8d71;font-size:10px;width:100%;text-align:left;border-radius:5px}
+.sidebar-link>svg:last-child:not(:first-child){margin-left:auto}
+.sidebar-link:hover,.sidebar-link.active{background:#e5ebdf;color:#3f5d37}
+.sidebar>.sidebar-link{margin-top:12px}
+.sidebar-bottom{border-top:1px solid var(--line);margin-top:29px;padding-top:17px}
+.small-count{font-size:9px;margin-left:auto;color:#9ca68f}
+.sidebar-note{display:flex;flex-direction:column;align-items:center;padding:29px 0;color:#9baa8e;gap:0}
+.sidebar-note p{font-family:var(--serif);font-style:italic;font-size:15px;text-align:center;line-height:1.5;color:#98a28c;margin:10px 0}
+.main-content{min-width:0;flex:1;padding:36px 40px 0;max-width:1710px;margin:auto;outline:none!important}
+.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:29px;min-height:90px}
+.breadcrumb{display:flex;align-items:center;gap:10px;border:0;background:none;padding:0;font-size:8px;font-weight:600;color:#8a957f;letter-spacing:1.6px;text-transform:uppercase}
+.breadcrumb>span:last-child{color:#6e8160;letter-spacing:1px}
+.breadcrumb-line{height:1px;width:18px;background:#adb79f}
+.page-heading h1{font-family:var(--serif);font-weight:500;font-size:46px;letter-spacing:-1.4px;margin:9px 0 5px;line-height:1.03;color:#2d4736}
+.page-heading p{font-size:11px;color:#919988;line-height:1.6;margin:8px 0 0}
+.title-flower{font-family:Georgia,serif;display:inline-block;font-size:19px;vertical-align:middle;margin-left:16px;color:#a5b092;font-weight:400}
+.quiet-button{background:none;display:inline-flex;align-items:center;gap:9px;padding:10px 0;font-size:10px;white-space:nowrap;color:#77866b}
+.quiet-button:hover{color:#385331}
+.guide-link{align-self:flex-end;margin-bottom:6px}
+.play-layout{display:grid;grid-template-columns:minmax(0,1fr) 221px;gap:30px;align-items:start}
+.garden-container{min-width:0}
+.garden-stage{position:relative;isolation:isolate;overflow:hidden;background:#133d3c;border-radius:7px;box-shadow:0 9px 25px #284c3820;border:1px solid #1b4541}
+.stage-top{position:absolute;inset:22px 23px auto;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;pointer-events:none}
+.stage-coordinate{color:#d4ddbe;letter-spacing:1.7px;font-size:8px;font-weight:500;line-height:1.8}
+.live-dot{display:inline-block;width:4px;height:4px;border-radius:50%;background:#b5c692;margin-right:8px;box-shadow:0 0 5px #c1d58d55}
+.stage-coordinate small{display:block;letter-spacing:.4px;color:#809d8d;font-size:8px;padding-left:12px;margin-top:2px}
+.stage-focus{pointer-events:auto;width:30px;height:30px;border:1px solid #75948245;color:#a7bea2;background:#163b3466;display:grid;place-items:center;border-radius:4px}
+.stage-focus:hover{background:#45654c55;color:#fff}
+.garden-stage .garden-board{width:100%;display:block}
+.stage-bottom{position:absolute;bottom:21px;left:24px;right:24px;display:flex;justify-content:space-between;align-items:center;pointer-events:none;z-index:2;gap:10px}
+.light-status{color:#8ba38d;font-size:9px;display:flex;align-items:center;gap:8px}
+.light-status strong{color:#d1dcb8;font-size:11px;font-weight:500;letter-spacing:1px}
+.light-status strong span{color:#8ba38d;font-size:9px}
+.status-beacon{display:inline-block;width:7px;height:10px;background:#5f7e70;transform:rotate(0deg);clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%)}
+.status-beacon.lit{background:#f2d087;filter:drop-shadow(0 0 4px #e7c373)}
+.stage-weather{display:flex;align-items:center;gap:7px;font-family:var(--serif);font-style:italic;color:#a6baa0;font-size:13px}
+.garden-toolbar{display:flex;align-items:center;justify-content:space-between;padding:17px 3px 12px;gap:10px}
+.tool-group,.toolbar-end{display:flex;align-items:center;gap:15px}
+.garden-toolbar button{background:transparent;display:inline-flex;align-items:center;gap:7px;padding:4px;color:#76866b;font-size:10px}
+.garden-toolbar button:hover:not(:disabled){color:#385831}
+.garden-toolbar button:disabled{opacity:.38}
+.garden-toolbar kbd{font-family:inherit;font-size:8px;border:1px solid #dae2d0;width:15px;height:16px;border-radius:3px;display:grid;place-items:center;margin-left:4px;color:#9ba78d}
+.tool-divider{height:14px;width:1px;background:#dce3d3}
+.toolbar-end .hint-button{padding:8px 10px;border:1px solid #d2ddc6;border-radius:4px;background:#e7eddf;color:#5c734b}
+.hint-button svg{color:#9a9964}
+.interaction-note{display:flex;align-items:center;justify-content:center;gap:10px;color:#a1aa93;font-size:8px;line-height:1.6;margin:3px 0 0}
+.interaction-note>span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
+.interaction-note i{display:inline-block;width:2px;height:2px;background:#aeb59e;border-radius:50%}
+.mouse-symbol{width:7px;height:11px;border:1px solid #a5af97;display:inline-block;border-radius:4px;position:relative}
+.mouse-symbol:after{position:absolute;content:'';width:1px;height:3px;background:#a5af97;top:1px;left:2px}
+.garden-notes{padding:5px 0 0;text-align:center}
+.level-counter{display:flex;justify-content:space-between;align-items:center;font-size:8px;letter-spacing:1.5px;color:#a4ad97}
+.level-counter>span:first-child{color:#859575}
+.objective-sigil{width:88px;margin:27px auto 17px;color:#859968}
+.objective-sigil.is-awake{color:#b7954b}
+.garden-notes>.eyebrow{font-size:7px;letter-spacing:1.35px}
+.garden-notes h2{font-family:var(--serif);font-weight:500;font-size:30px;line-height:1.1;letter-spacing:-.6px;margin:9px 0 11px}
+.objective-copy{font-size:10px;line-height:1.9;color:#85917d;max-width:205px;margin:0 auto}
+.objective-progress{margin-top:23px;text-align:left}
+.objective-progress>span{font-size:9px;color:#88957a;display:flex;justify-content:space-between}
+.objective-progress strong{font-size:9px;font-weight:500;color:#657b54;letter-spacing:1px}
+.objective-progress>div{display:flex;gap:5px;margin-top:10px}
+.objective-progress>div>span{height:3px;background:#dce3d2;flex:1;border-radius:3px}
+.objective-progress>div>span.lit{background:#9caa66}
+.move-stats{display:flex;align-items:center;justify-content:center;margin:24px 0 17px}
+.move-stats>div{flex:1;display:flex;align-items:center;flex-direction:column;gap:4px}
+.stat-number{font-family:var(--serif);font-weight:400;font-size:37px;font-variant-numeric:tabular-nums;line-height:1}
+.stat-number.target{color:#99a78b}
+.move-stats small{font-size:6px;letter-spacing:1.15px;color:#8c9a7b;font-weight:500}
+.stat-separator{height:30px;width:1px;background:#dce3d2}
+.reward-guide{display:flex;align-items:center;flex-direction:column;gap:7px}
+.blooms{display:inline-flex;align-items:center;gap:5px;color:#d6ddc8}
+.blooms svg.filled{color:#b29e64;fill:#d2bd7922}
+.reward-guide>span:last-child{font-size:8px;line-height:1.8;color:#a2ab94}
+.garden-story{position:relative;border-top:1px solid #dde4d4;margin-top:29px;padding-top:23px}
+.story-leaf{position:absolute;left:calc(50% - 15px);width:30px;top:-8px;display:flex;justify-content:center;background:var(--paper);color:#9da98e}
+.garden-story p{font-family:var(--serif);font-size:16px;line-height:1.5;font-style:italic;color:#839277;margin:0 0 12px}
+.garden-story>span:last-child{font-size:6px;letter-spacing:1.7px;color:#a0ab92}
+.first-help{background:none;color:#9caa88;font-size:8px;display:flex;align-items:center;gap:4px;justify-content:center;width:100%;padding:22px 0 0}
+.first-help:hover{color:#526a45}
+.hint-note{display:flex;gap:8px;align-items:flex-start;margin-top:19px;color:#8e8051;border:1px solid #d7cfaa;background:#ecebd944;padding:10px;border-radius:4px;text-align:left}
+.hint-note svg{flex-shrink:0;margin-top:2px}
+.hint-note p{font-size:9px;line-height:1.6;margin:0}
+.chapter-trail{margin-top:30px;border-top:1px solid var(--line);padding-top:22px}
+.trail-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:19px}
+.trail-heading .eyebrow{font-size:8px;letter-spacing:1.7px}
+.trail-heading>span:last-child{font-size:8px;color:#9aa68b}
+.trail-levels{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));position:relative;gap:5px}
+.trail-levels:before{content:'';position:absolute;left:6%;right:6%;height:1px;top:19px;background:#dce3d3}
+.trail-level{background:none;display:flex;align-items:center;flex-direction:column;position:relative;z-index:1;padding:0 3px}
+.trail-node{position:relative;width:39px;height:39px;border:1px solid #d6dfcc;background:#eef2e9;border-radius:50%;display:grid;place-items:center;color:#a2ad91;font-family:var(--serif);font-size:15px;transition:background .2s,transform .2s}
+.trail-level:hover .trail-node{transform:translateY(-3px);border-color:#8aa177;color:#526943}
+.trail-level.current .trail-node{background:#567050;color:#f3f4e1;border-color:#567050;box-shadow:0 0 0 4px #f0f3ed,0 0 0 5px #b5c2a2}
+.trail-level.completed .trail-node{color:#80905e;background:#e5ebd9;border-color:#b6c49f}
+.trail-level.completed.current .trail-node{background:#657d50;color:#f0e9c6}
+.trail-name{font-size:8px;color:#96a087;margin-top:12px;line-height:1.4;text-align:center;min-height:22px}
+.trail-level.current .trail-name{color:#506a43}
+.trail-dots{font-size:14px;color:#c2ccb4;line-height:14px}
+.trail-level .blooms{gap:2px;margin-top:3px}
+.trail-level .blooms svg{width:9px;height:9px}
+.page-footer{margin-top:31px;border-top:1px solid var(--line);padding:19px 0;display:flex;align-items:center;justify-content:space-between;gap:16px;color:#a4ad98;font-size:7px}
+.page-footer>span{display:flex;align-items:center;gap:7px}
+.page-footer>span:first-child{letter-spacing:1.5px;font-size:6px}
+.footer-diamond{width:4px;height:7px;border:1px solid #9dab8b;transform:rotate(25deg)}
+.save-dot{background:#a4b78c;width:4px;height:4px;border-radius:50%;margin-left:2px}
+.save-dot.warning{background:#ad743b}
+.primary-button{display:flex;align-items:center;justify-content:center;gap:10px;padding:13px 19px;background:#47673e;color:#f4f3df;font-size:11px;font-weight:500;border:1px solid #47673e;border-radius:4px;transition:background .2s,transform .2s}
+.primary-button:hover{background:#35532d;transform:translateY(-1px)}
+.primary-button>svg:last-child:not(:first-child){margin-left:auto}
+.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:10px 13px;background:#e6ecdf;color:#556d47;font-size:10px;border:1px solid #d4dfc8;border-radius:4px}
+.secondary-button:hover{background:#dae4d0}
+.text-button{background:transparent;padding:9px 0;display:inline-flex;align-items:center;gap:8px;color:#6e875b;font-size:10px}
+.text-button:hover{color:#344f2b}
+.win-panel{margin-top:20px}
+.win-panel>.blooms{gap:9px}
+.win-panel>.blooms svg{width:23px;height:23px}
+.win-panel>p{color:#929c82;font-size:9px;margin:11px 0 20px;line-height:1.5}
+.win-panel>.primary-button{width:100%}
+.win-panel>.text-button{margin-top:6px;font-size:8px}
+.restored-banner{position:absolute;bottom:58px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:10px;white-space:nowrap;background:#183e34e6;backdrop-filter:blur(6px);padding:11px 17px;border:1px solid #a7bc7544;border-radius:30px;color:#e1d6a2;font-family:var(--serif);font-size:16px;box-shadow:0 4px 22px #08251b55;animation:banner-appear .6s ease-out}
+@keyframes banner-appear{from{opacity:0;translate:0 10px}to{opacity:1;translate:0 0}}
+.special-footer{margin-top:26px;padding:21px 0;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:20px}
+.special-footer>div{display:flex;align-items:center;gap:12px;color:#869874;font-family:var(--serif);font-style:italic;font-size:17px}
+.atlas-total{display:flex;flex-direction:column;gap:4px;text-align:right}
+.atlas-total strong{font-family:var(--serif);font-size:40px;font-weight:400}
+.atlas-total strong span{font-size:23px;color:#a4ae95}
+.atlas-total small{font-size:7px;letter-spacing:1.3px;color:#8d9c7b}
+.atlas-intro{display:flex;align-items:center;gap:12px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:13px 0;margin-bottom:26px;color:#7c8c6c}
+.atlas-intro p{font-size:10px;line-height:1.7;margin:0;flex:1}
+.atlas-intro>span{font-size:9px;color:#9ba989;white-space:nowrap}
+.atlas-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:23px}
+.chapter-card{overflow:hidden;border:1px solid #dce3d3;border-radius:5px;background:#f5f7ef}
+.chapter-card-art{width:100%;height:185px;display:block;position:relative;overflow:hidden;background:#24463f;color:#b7c29c}
+.chapter-card-1 .chapter-card-art{background:#344f4b;color:#cbd4ad}
+.chapter-card-2 .chapter-card-art{background:#3a5047;color:#c5c5a8}
+.chapter-card-3 .chapter-card-art{background:#34464d;color:#aebfc0}
+.chapter-card-4 .chapter-card-art{background:#484e52;color:#c7bca4}
+.chapter-card-5 .chapter-card-art{background:#44475b;color:#c1b2c9}
+.chapter-card-art:after{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 50% 100%,#bdc68f18,transparent 70%)}
+.chapter-card-art .sigil{width:125px;height:125px;position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);filter:drop-shadow(0 0 14px #d0c89c44);transition:transform .5s}
+.chapter-card-art:hover .sigil{transform:translate(-50%,-50%) rotate(8deg) scale(1.04)}
+.card-roman{position:absolute;top:18px;left:21px;font-family:var(--serif);font-size:19px;opacity:.6}
+.art-caption{position:absolute;bottom:15px;left:20px;font-size:7px;letter-spacing:1.5px;text-transform:uppercase;opacity:.7}
+.art-arrow{position:absolute;bottom:13px;right:18px;opacity:.6}
+.card-orbit{position:absolute;border:1px solid currentColor;opacity:.07;width:230px;height:230px;border-radius:50%;top:-15px;left:calc(50% - 115px)}
+.chapter-card-body{padding:20px 20px 14px}
+.chapter-card-body .eyebrow{font-size:7px;letter-spacing:1.4px}
+.chapter-card-body h2{font-family:var(--serif);font-size:29px;line-height:1.1;font-weight:500;margin:8px 0 10px}
+.chapter-card-body>p{font-size:10px;line-height:1.8;color:#89977c;margin:0;min-height:53px}
+.card-progress{display:flex;align-items:center;gap:12px;margin:14px 0 8px}
+.card-progress>span{display:block;height:2px;background:#e0e6d7;flex:1}
+.card-progress>span>span{display:block;height:100%;background:#96aa7c}
+.card-progress small{font-size:8px;color:#9ba88a}
+.card-level-list{border-top:1px solid #dce3d3;padding:10px 20px}
+.card-level-list button{display:flex;align-items:center;gap:12px;background:none;width:100%;padding:9px 0;color:#6d835d;font-size:10px;text-align:left}
+.card-level-list button>span{font-size:9px;color:#a3af92}
+.card-level-list button>svg{margin-left:auto}
+.card-level-list button:hover{color:#304d28}
+.page-heading-icon{color:#a1b18b;margin-right:9px}
+.workshop-layout{display:grid;grid-template-columns:1fr 1fr;border:1px solid #d7e0cf;border-radius:7px;overflow:hidden;background:#f6f7ef}
+.workshop-art{position:relative;overflow:hidden;min-height:460px;background:radial-gradient(ellipse at 50% 80%,#48604655,transparent),#173e38;color:#c3c6a2;display:flex;align-items:center;flex-direction:column;justify-content:center;padding:40px;text-align:center}
+.workshop-art>.sigil{width:160px;margin-bottom:30px;color:#bbbf8d;filter:drop-shadow(0 0 25px #bcd99544)}
+.workshop-art>.eyebrow{color:#929e78;font-size:7px;letter-spacing:2.2px}
+.workshop-art h2{font-family:var(--serif);font-weight:400;font-size:39px;line-height:1.2;color:#dce0bf;margin:13px 0}
+.workshop-art h2 i{font-weight:400;color:#b5c5a0}
+.seed-art-number{font-family:var(--serif);font-size:14px;color:#809c7e;margin-top:16px;letter-spacing:3px}
+.workshop-orbit{position:absolute;border:1px solid #b9c69c12;width:430px;height:430px;border-radius:50%;top:calc(50% - 215px);left:calc(50% - 215px);pointer-events:none}
+.workshop-orbit.orbit-two{width:370px;height:370px;top:calc(50% - 185px);left:calc(50% - 185px);border-style:dashed}
+.workshop-form{padding:42px 39px}
+.workshop-form h2{font-family:var(--serif);font-size:34px;line-height:1.1;margin:9px 0 12px;font-weight:500}
+.workshop-form>p{font-size:11px;line-height:1.8;color:#8c9980;margin:0 0 26px}
+.workshop-form label,.backup-section label{display:block;font-size:10px;color:#6e835e;font-weight:500;margin-bottom:9px}
+.select-wrap{position:relative;margin-bottom:24px}
+.select-wrap select{width:100%;padding:12px 32px 12px 12px;border:1px solid #d6e0cd;color:#637b55;border-radius:4px;background:#eef2e8;font-size:10px;appearance:none}
+.select-wrap>svg{position:absolute;right:12px;top:12px;pointer-events:none;color:#829a70}
+.seed-input{display:flex;align-items:center;border:1px solid #d6e0cd;border-radius:4px;background:#eef2e8;padding:3px 7px 3px 12px}
+.seed-input input{padding:9px 0;border:0;outline:none;background:none;color:#536d44;width:100%;min-width:0;letter-spacing:2px;font-size:13px}
+.workshop-form .input-note{font-size:8px;color:#a0ac92;margin:9px 0 28px}
+.workshop-form>.primary-button{width:100%}
+.workshop-promise{display:flex;gap:8px;align-items:center;margin-top:17px;color:#93a37f;font-size:8px;justify-content:center}
+.workshop-presets{margin-top:31px}
+.workshop-presets>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:15px}
+.workshop-presets button{display:flex;gap:11px;align-items:center;text-align:left;background:#e8eedf99;padding:18px 13px;border:1px solid #dce4d1;border-radius:4px}
+.workshop-presets button:hover{background:#e2ead7}
+.workshop-presets .sigil{flex-shrink:0;width:38px;color:#8ea272}
+.workshop-presets button>span{display:flex;flex-direction:column;gap:6px}
+.workshop-presets strong{font-size:11px;font-weight:500;color:#657f55}
+.workshop-presets small{font-size:8px;line-height:1.6;color:#8c9c7b}
+.workshop-presets button>svg:last-child{margin-left:auto;flex-shrink:0;color:#8ea272}
+.journal-intro{display:flex;align-items:center;gap:18px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:26px 10px;margin:0 0 28px}
+.journal-dropcap{font-family:var(--serif);font-size:72px;line-height:1;color:#899f70}
+.journal-intro p{font-family:var(--serif);font-size:20px;line-height:1.5;max-width:550px;color:#809470;margin:0 35px 0 0}
+.journal-intro>div{margin-left:auto;min-width:110px;text-align:center}
+.journal-intro>div strong{font-family:var(--serif);font-size:42px;display:block;font-weight:400;color:#819868}
+.journal-intro>div span{display:block;color:#9caa8c;font-size:7px;letter-spacing:1.2px;margin-top:6px}
+.memory-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}
+.memory{background:#eef2e8;border:1px dashed #d6e0cd;padding:24px;display:flex;flex-direction:column;border-radius:3px}
+.memory.unlocked{background:#f9f9ef;border:1px solid #dce2cf}
+.memory-top{display:flex;align-items:center;justify-content:space-between}
+.memory-top .eyebrow{font-size:7px}
+.memory-top .sigil{width:43px;color:#afb99b}
+.memory.unlocked .memory-top .sigil{color:#879d69}
+.memory h2{font-family:var(--serif);font-size:27px;font-weight:400;line-height:1.1;color:#92a081;margin:18px 0 13px}
+.memory.unlocked h2{color:#526d43}
+.memory>p{font-size:11px;line-height:1.9;color:#9ba88d;flex:1;margin:0 0 18px}
+.memory.unlocked>p{color:#869875}
+.memory .text-button{font-size:9px;justify-content:space-between}
+.final-memory{margin:50px auto 20px;max-width:570px;text-align:center}
+.final-memory>.sigil{width:100px;margin:auto auto 25px;color:#a7a060}
+.final-memory h2{font-family:var(--serif);font-weight:500;font-size:43px;color:#4b683d}
+.final-memory p{font-family:var(--serif);font-size:22px;line-height:1.7;color:#8a9a76}
+.final-memory .primary-button{margin:30px auto 0}
+.modal-backdrop{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;background:#162e2877;backdrop-filter:blur(9px);padding:25px;animation:fade-in .2s ease}
+.dialog{background:#f2f5ec;border:1px solid #ecf0df;border-radius:9px;width:570px;max-height:calc(100dvh - 50px);overflow-y:auto;box-shadow:0 20px 80px #061d2855;padding:31px;outline:none;scrollbar-color:#b8c5a6 transparent;scrollbar-width:thin}
+.dialog.dialog-wide{width:780px}
+.dialog-header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}
+.dialog-header .eyebrow{font-size:7px;letter-spacing:1.6px}
+.dialog-header h2{font-family:var(--serif);font-weight:500;font-size:37px;line-height:1.1;margin:7px 0 0}
+.dialog-intro{font-size:11px;line-height:1.9;color:#8b9b7c;margin:13px 0 25px}
+.guide-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding:8px 0 25px;border-bottom:1px solid var(--line)}
+.guide-steps>div{position:relative;padding:18px;background:#e8efdf;border-radius:4px;border:1px solid #dce6d1}
+.guide-steps>div>span{position:absolute;right:16px;top:19px;font-size:10px;color:#a2b68c}
+.guide-steps svg{width:25px;height:25px;stroke-width:1.3;color:#859a67;margin-bottom:15px}
+.guide-steps h3{font-size:11px;font-weight:600;margin:0 0 9px;color:#5a7348}
+.guide-steps p{font-size:10px;line-height:1.8;color:#819670;margin:0}
+.guide-section-title{font-family:var(--serif);font-size:24px;font-weight:500;margin:24px 0 18px}
+.mechanics-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 30px}
+.mechanics-grid>div{display:flex;align-items:flex-start;gap:12px}
+.mechanic-symbol{display:flex;align-items:center;justify-content:center;width:35px;height:35px;flex:0 0 35px;font-size:28px;line-height:1;background:#e2ead5;border:1px solid #d1ddc4;border-radius:4px;color:#728a58;font-family:monospace}
+.mechanics-grid h4{font-size:10px;margin:0 0 5px;font-weight:500;color:#5a7348}
+.mechanics-grid p{font-size:9px;line-height:1.8;color:#8a9a79;margin:0}
+.guide-bottom{display:flex;gap:32px;border-top:1px solid var(--line);padding-top:20px;margin-top:25px}
+.guide-bottom>div:first-child{flex:1}
+.guide-bottom h3{font-family:var(--serif);font-size:22px;font-weight:500;margin:0 0 9px}
+.guide-bottom p{font-size:10px;color:#879a77;line-height:1.8;margin:0 0 20px}
+.keyboard-guide{display:flex;flex-direction:column;gap:10px;min-width:185px;justify-content:center;padding-bottom:20px}
+.keyboard-guide>span{display:flex;gap:5px;align-items:center;font-size:8px;color:#8c9d7d}
+.keyboard-guide kbd{font-family:inherit;padding:3px 5px;border:1px solid #d5dfcb;border-radius:3px;background:#e9efdf;color:#6b8456;font-size:8px}
+.guide-content>.primary-button{margin-top:5px;margin-left:auto;min-width:190px}
+.setting-row{display:flex;align-items:center;gap:14px;border-bottom:1px solid var(--line);padding:19px 0}
+.setting-row>svg{color:#82996c;flex-shrink:0}
+.setting-row>div{flex:1}
+.setting-row strong{font-size:11px;font-weight:500;color:#5e7750}
+.setting-row p{font-size:9px;line-height:1.6;color:#95a588;margin:5px 0 0}
+.switch{position:relative;width:34px;height:20px;border-radius:12px;background:#d9e3cf;flex:0 0 34px;padding:3px;transition:background .2s}
+.switch>span{display:block;width:14px;height:14px;background:#f9faf1;border-radius:50%;box-shadow:0 1px 4px #52644825;transition:transform .2s}
+.switch.on{background:#839d65}
+.switch.on>span{transform:translateX(14px)}
+.backup-section{margin-top:28px}
+.backup-section h3{font-family:var(--serif);font-size:26px;font-weight:500;color:#567346;margin:0 0 9px}
+.backup-section>p{font-size:10px;line-height:1.9;color:#8e9f7e;margin:0 0 17px}
+.backup-actions{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap}
+.backup-section label.secondary-button{display:inline-flex;align-items:center;gap:7px;font-size:10px;margin:0;cursor:pointer}
+.file-input{position:relative}
+.file-input input{position:absolute;inset:0;opacity:0;width:100%;cursor:pointer}
+.backup-section textarea{display:block;width:100%;min-height:66px;max-height:140px;padding:11px;border:1px solid #d6e2ca;border-radius:4px;background:#f9faf4;color:#647f50;font-size:10px;line-height:1.7;resize:vertical;margin-bottom:15px}
+.backup-section textarea::placeholder{color:#a5b598}
+.settings-footnote{font-family:var(--serif);text-align:center;font-size:16px;font-style:italic;color:#a1af91;margin:27px 0 0}
+.achievement-grid{display:grid;grid-template-columns:1fr;gap:9px}
+.achievement{display:flex;align-items:center;gap:15px;padding:13px 15px;background:#eaf0e1;border:1px solid #e0e8d6;border-radius:5px;color:#a8b798}
+.achievement.achieved{background:#e4ecd7;border-color:#cddaaf;color:#728a52}
+.achievement-icon{width:35px;height:35px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
+.achievement h3{font-size:11px;font-weight:500;margin:0 0 5px}
+.achievement p{font-size:9px;line-height:1.5;margin:0;opacity:.8}
+.achievement>svg:last-child{margin-left:auto}
+.achievement-total{display:flex;justify-content:center;align-items:center;gap:10px;margin-top:23px;color:#859c69;font-size:10px}
+.toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%);z-index:40;display:flex;gap:12px;align-items:center;max-width:calc(100vw - 30px);width:max-content;background:#2d4e38;color:#e4edd6;border:1px solid #829b6177;border-radius:7px;padding:15px 17px;font-size:11px;box-shadow:0 8px 28px #102b2b44;animation:fade-in .25s ease}
+.toast>svg{color:#b6c995;flex-shrink:0}
+.toast>button{background:none;color:#b5c998;padding:3px;display:flex}
+@keyframes fade-in{from{opacity:0}to{opacity:1}}
+.mobile-toggle{display:none}
+.skip-link{position:fixed;top:-80px;left:10px;z-index:100;background:#fff;padding:14px;color:#253b29;text-decoration:none;border:1px solid #6e865d}
+.skip-link:focus{top:10px}
+.focus-mode .topbar,.focus-mode .sidebar,.focus-mode .page-heading,.focus-mode .chapter-trail,.focus-mode .page-footer,.focus-mode .special-footer{display:none}
+.focus-mode .main-content{padding:25px;width:100%;max-width:1350px}
+.focus-mode .app-body{min-height:100vh;align-items:center}
+.focus-mode .play-layout{grid-template-columns:minmax(0,900px) 245px;gap:50px;align-items:center}
+.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
+.high-contrast{--muted:#5b6f51;--line:#bdcbaa}
+.high-contrast .chapter-copy>span,.high-contrast .chapter-copy small,.high-contrast .page-heading p,.high-contrast .objective-copy,.high-contrast .reward-guide>span:last-child,.high-contrast .trail-name,.high-contrast .interaction-note,.high-contrast .page-footer,.high-contrast .garden-story p,.high-contrast .eyebrow{color:#5c704d}
+.high-contrast .garden-stage{border:2px solid #456445}
+.high-contrast .board-tile path{stroke-opacity:1}
+@media(min-width:1600px){.main-content{padding:42px 58px 0}.play-layout{grid-template-columns:minmax(0,1fr) 258px;gap:40px}.garden-stage{max-height:725px}.garden-stage svg{max-height:725px}.sidebar{width:255px;flex-basis:255px;padding-left:23px;padding-right:23px}.page-heading h1{font-size:53px}.garden-notes{padding-top:8px}.objective-sigil{width:105px;margin-top:40px}.garden-notes h2{font-size:35px}.objective-copy{font-size:12px;max-width:250px}.garden-notes>.eyebrow{font-size:8px}.garden-story{margin-top:39px}.garden-story p{font-size:19px}.move-stats{margin:32px 0 22px}.stat-number{font-size:44px}.page-footer{padding:23px 0}.chapter-button{min-height:96px}.chapter-copy strong{font-size:12px}}
+@media(max-width:1250px){.topbar{padding:0 27px}.sidebar{flex-basis:209px;width:209px;padding:30px 12px 0}.chapter-button{gap:8px;padding:10px 7px;min-height:80px}.chapter-icon{width:34px;flex-basis:34px}.chapter-copy strong{font-size:10px}.chapter-copy>span{font-size:7px}.chapter-copy small{font-size:6px}.chapter-active-dot{right:7px;top:14px}.main-content{padding:30px 28px 0}.play-layout{grid-template-columns:minmax(0,1fr) 185px;gap:23px}.main-nav{gap:24px}.page-heading h1{font-size:42px}.objective-sigil{width:72px;margin-top:22px;margin-bottom:14px}.garden-notes h2{font-size:28px}.garden-notes>.eyebrow{font-size:6px}.objective-copy{font-size:9px}.objective-progress{margin-top:20px}.move-stats{margin:19px 0 12px}.garden-story{margin-top:22px;padding-top:19px}.garden-story p{font-size:14px}.first-help{font-size:7px;padding-top:15px}.garden-toolbar{gap:5px}.tool-group,.toolbar-end{gap:8px}.garden-toolbar kbd{display:none}.atlas-grid,.memory-grid{gap:16px}.chapter-card-body{padding:17px}.chapter-card-body h2{font-size:25px}.chapter-card-body>p{font-size:9px}.workshop-form{padding:32px 26px}.workshop-presets>div{gap:12px}.workshop-presets button{padding:14px 10px;gap:8px}.workshop-presets .sigil{width:30px}.workshop-presets strong{font-size:10px}.workshop-presets small{font-size:7px}.workshop-presets button>svg:last-child{width:13px}.journal-intro p{font-size:18px;margin-right:0}.memory{padding:18px}.memory h2{font-size:25px}}
+@media(max-width:1060px){.topbar{height:78px;padding:0 23px}.brand{min-width:178px;gap:12px}.brand-type{font-size:27px}.brand-type small{font-size:6px}.topbar-tools{gap:3px}.main-nav{gap:24px}.main-nav button{font-size:10px;gap:6px}.main-nav button>svg{width:14px}.sidebar{width:177px;flex-basis:177px;padding:26px 9px 0}.sidebar-title{margin:0 7px}.sidebar-title .eyebrow{font-size:6px}.sidebar-count{font-size:7px}.chapter-icon{width:29px;flex-basis:29px}.chapter-copy strong{font-size:9px}.chapter-copy>span{font-size:6px}.chapter-copy small{font-size:6px;letter-spacing:1px}.chapter-button{gap:6px;min-height:75px;padding:8px 6px}.sidebar-link{font-size:8px;gap:7px;padding-left:8px;padding-right:8px}.sidebar-link svg{width:14px}.sidebar-note p{font-size:13px}.small-count{font-size:7px}.main-content{padding:28px 25px 0}.page-heading{margin-bottom:24px}.page-heading h1{font-size:39px}.page-heading p{font-size:10px}.play-layout{grid-template-columns:minmax(0,1fr) 174px;gap:19px}.stage-top{inset:17px 17px auto}.stage-bottom{left:17px;right:17px;bottom:16px}.stage-coordinate{font-size:7px}.stage-coordinate small{font-size:6px}.stage-weather{font-size:11px}.light-status{font-size:7px;gap:5px}.garden-notes h2{font-size:26px}.objective-sigil{width:58px;margin:16px auto 12px}.objective-progress{margin-top:16px}.move-stats{margin:16px 0 11px}.stat-number{font-size:32px}.reward-guide>span:last-child{font-size:7px}.garden-story{margin-top:19px;padding-top:17px}.garden-story p{font-size:13px}.garden-story>span:last-child{font-size:5px}.first-help{font-size:6px;padding-top:14px}.interaction-note{font-size:6px;gap:7px}.interaction-note>span{gap:4px}.trail-name{font-size:7px}.trail-levels{gap:3px}.page-footer>span:first-child{font-size:5px}.page-footer{font-size:6px}.atlas-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.memory-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.workshop-art{padding:30px 20px;min-height:455px}.workshop-art h2{font-size:33px}.workshop-form{padding:30px 24px}.workshop-form h2{font-size:28px}.workshop-form>p{font-size:10px}.workshop-form .eyebrow{font-size:7px}.workshop-presets>div{grid-template-columns:1fr;gap:10px}.workshop-presets button{padding:14px 18px}.workshop-presets .sigil{width:42px}.workshop-presets strong{font-size:11px}.workshop-presets small{font-size:9px}.workshop-presets button>svg:last-child{width:18px}}
+@media(max-width:900px){.main-nav{gap:17px}.sidebar{width:167px;flex-basis:167px}.main-content{padding:27px 24px 0}.play-layout{grid-template-columns:1fr;gap:23px}.garden-notes{display:grid;grid-template-columns:65px 1fr 120px;text-align:left;gap:0 18px;padding:20px 0;border-top:1px solid var(--line);position:relative;align-items:center}.garden-notes .level-counter{display:none}.garden-notes .objective-sigil{grid-column:1;grid-row:1/4;width:65px;margin:0}.garden-notes>.eyebrow{grid-column:2;grid-row:1;font-size:6px;margin-top:0;align-self:end}.garden-notes>h2{grid-column:2;grid-row:2;font-size:26px;margin:7px 0}.garden-notes>.objective-copy{grid-column:2;grid-row:3;font-size:9px;max-width:none;margin:0;align-self:start;line-height:1.7}.garden-notes .move-stats{grid-column:3;grid-row:1/3;margin:0 0 10px;align-self:end}.garden-notes .reward-guide{grid-column:3;grid-row:3;gap:5px;align-self:start}.garden-notes .reward-guide>span:last-child{font-size:6px;text-align:center}.garden-notes .objective-progress{grid-column:1/4;grid-row:4;margin-top:18px}.garden-notes .garden-story,.garden-notes .first-help{display:none}.garden-notes .hint-note{grid-column:1/4;grid-row:5}.garden-notes .win-panel{grid-column:3;grid-row:1/4;margin:0;text-align:center}.win-panel>p{display:none}.win-panel>.blooms svg{width:18px;height:18px}.win-panel>.primary-button{font-size:9px;padding:10px;margin-top:10px;gap:5px}.win-panel>.text-button{font-size:7px}.chapter-trail{margin-top:5px}.garden-toolbar{padding-top:16px}.interaction-note{font-size:8px}.stage-coordinate{font-size:8px}.stage-coordinate small{font-size:7px}.light-status{font-size:9px}.stage-weather{font-size:13px}.page-heading h1{font-size:39px}.workshop-layout{grid-template-columns:1fr}.workshop-art{min-height:300px;padding:28px}.workshop-art>.sigil{width:110px;margin-bottom:16px}.workshop-art h2{font-size:30px}.workshop-art h2 br{display:none}.workshop-art h2 i{margin-left:8px}.workshop-form{padding:32px}.workshop-form h2{font-size:32px}.workshop-form>p{font-size:11px}.journal-intro>div{display:none}.guide-link{font-size:8px}.guide-link>svg:first-child{display:none}.focus-mode .play-layout{grid-template-columns:1fr;gap:10px}.focus-mode .garden-stage{max-width:850px;margin:auto}.focus-mode .garden-notes{display:none}}
+@media(max-width:700px){.topbar{height:74px;padding:0 19px;gap:10px;flex-wrap:wrap}.brand{min-width:0;gap:10px}.brand-type{font-size:25px;letter-spacing:4px}.brand-type small{font-size:5.7px;letter-spacing:1.6px}.brand-mark{transform:scale(.83);margin:0 2px}.topbar-tools{gap:3px}.topbar-tools .icon-button{width:30px}.topbar-tools .ambience-button{display:none}.main-nav{position:absolute;top:74px;left:0;right:0;height:47px;justify-content:center;gap:36px;border-bottom:1px solid var(--line);background:#f2f5ed;z-index:4}.main-nav button{font-size:10px;gap:7px}.main-nav button>svg{width:14px}.app-body{padding-top:47px;flex-direction:column}.sidebar{width:100%;flex-basis:auto;border-right:0;border-bottom:1px solid var(--line);padding:0 20px;display:block;background:#e9eee1}.sidebar-title{height:43px;margin:0;justify-content:flex-start;gap:8px}.sidebar-title .eyebrow{font-size:7px;letter-spacing:1.3px}.mobile-toggle{display:flex;width:25px;height:25px}.sidebar-count{margin-left:auto;font-size:8px}.journey-progress,.chapter-list,.sidebar>.sidebar-link,.sidebar-bottom{display:none}.mobile-open .chapter-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding-bottom:13px}.mobile-open .chapter-icon{width:39px;flex-basis:39px}.mobile-open .chapter-copy strong{font-size:11px}.mobile-open .chapter-copy small{font-size:7px}.mobile-open .chapter-copy>span{font-size:8px}.mobile-open .sidebar>.sidebar-link{display:flex;margin:0 0 10px;padding:13px}.mobile-open .sidebar-bottom{display:flex;border:0;margin:0;padding:0 0 12px}.mobile-open .sidebar-note{display:none}.mobile-open .sidebar-link{font-size:10px}.main-content{width:100%;padding:26px 20px 0}.page-heading{margin-bottom:22px;min-height:auto}.page-heading h1{font-size:39px;margin-top:9px}.page-heading p{font-size:10px;max-width:85%}.breadcrumb{font-size:7px;gap:7px;letter-spacing:1.2px}.title-flower{font-size:15px;margin-left:11px}.guide-link{font-size:0;align-self:center;padding:10px;gap:0}.guide-link svg:first-child{display:block;width:18px;height:18px}.guide-link svg:last-child{display:none}.stage-top{inset:19px 20px auto}.stage-coordinate{font-size:7px;letter-spacing:1.3px}.stage-coordinate small{font-size:6px}.stage-focus{height:27px;width:27px}.stage-bottom{left:20px;right:20px;bottom:17px}.garden-stage{border-radius:5px}.light-status{font-size:8px}.stage-weather{font-size:12px}.garden-toolbar button{font-size:10px}.garden-toolbar{padding:14px 0 12px}.tool-group,.toolbar-end{gap:11px}.toolbar-end .hint-button{padding:9px 10px}.garden-toolbar kbd{display:none}.interaction-note{font-size:7px;gap:7px;margin-top:0}.garden-notes{grid-template-columns:60px 1fr 117px;gap:0 13px;padding:19px 0}.garden-notes .objective-sigil{width:60px}.garden-notes>.eyebrow{font-size:5.5px;letter-spacing:1px}.garden-notes h2{font-size:25px}.garden-notes>.objective-copy{font-size:8px}.garden-notes .reward-guide>span:last-child{font-size:5.5px}.garden-notes .stat-number{font-size:29px}.garden-notes .move-stats small{font-size:5px;letter-spacing:.8px}.garden-notes .stat-separator{height:25px}.chapter-trail{padding-top:18px}.trail-heading{margin-bottom:18px}.trail-heading .eyebrow{font-size:7px}.trail-heading>span:last-child{font-size:7px}.trail-node{width:33px;height:33px;font-size:14px}.trail-levels:before{top:16px}.trail-name{font-size:6px;margin-top:11px;min-height:26px;overflow-wrap:anywhere}.trail-level.current .trail-node{box-shadow:0 0 0 3px #f0f3ed,0 0 0 4px #b5c2a2}.trail-level .blooms svg{width:7px;height:7px}.page-footer{margin-top:24px;gap:8px;padding:17px 0}.page-footer>span:first-child{font-size:5px;letter-spacing:.9px}.page-footer>span:last-child{font-size:5px}.atlas-total strong{font-size:35px}.atlas-total small{font-size:6px;white-space:nowrap}.atlas-intro{gap:9px}.atlas-intro p{font-size:9px}.atlas-intro>span{display:none}.atlas-intro>svg{flex-shrink:0}.atlas-grid{gap:13px}.chapter-card-art{height:145px}.chapter-card-art .sigil{width:105px}.chapter-card-body{padding:15px 13px 11px}.chapter-card-body .eyebrow{font-size:5px;letter-spacing:1px}.chapter-card-body h2{font-size:25px;margin:7px 0 9px}.chapter-card-body>p{font-size:8px;min-height:58px}.chapter-card-body .text-button{font-size:8px}.art-caption{font-size:5px;left:14px}.art-arrow{right:13px}.card-roman{left:14px;top:14px;font-size:16px}.card-level-list{padding:8px 13px}.card-level-list button{font-size:8px;gap:8px}.page-heading-icon{width:28px}.workshop-form{padding:27px}.workshop-presets{margin-top:27px}.workshop-presets>div{gap:10px}.workshop-presets .sigil{width:45px}.workshop-presets button{gap:14px}.workshop-presets strong{font-size:12px}.workshop-presets small{font-size:9px}.memory-grid{gap:13px}.memory{padding:17px}.memory h2{font-size:25px}.memory>p{font-size:10px}.memory .text-button{font-size:8px;gap:5px}.memory-top .sigil{width:34px}.memory-top .eyebrow{font-size:6px;letter-spacing:1px}.journal-intro{gap:13px;padding:20px 0}.journal-intro p{font-size:18px}.journal-dropcap{font-size:65px}.special-footer>div{font-size:16px}.special-footer .quiet-button{font-size:8px}.modal-backdrop{padding:14px}.dialog{padding:25px 21px;max-height:calc(100dvh - 28px)}.dialog-header h2{font-size:33px}.dialog-header .eyebrow{font-size:6px}.guide-steps{gap:9px}.guide-steps>div{padding:13px}.guide-steps h3{font-size:10px}.guide-steps p{font-size:9px}.guide-steps svg{width:23px;height:23px}.guide-steps>div>span{right:12px;font-size:8px}.mechanics-grid{gap:18px 16px}.mechanics-grid>div{gap:9px}.mechanic-symbol{flex-basis:28px;width:28px;height:28px;font-size:23px}.mechanics-grid h4{font-size:9px}.mechanics-grid p{font-size:8px}.guide-bottom{gap:20px}.keyboard-guide{min-width:160px}.guide-bottom h3{font-size:22px}.guide-bottom p{font-size:9px}.keyboard-guide>span{font-size:7px}.toast{font-size:10px;padding:13px 14px;bottom:17px;gap:9px}.focus-mode .app-body{padding:0}.focus-mode .main-content{padding:12px}.restored-banner{font-size:13px;padding:9px 13px;bottom:50px}.final-memory h2{font-size:35px}.final-memory p{font-size:19px}.high-contrast .chapter-copy>span{font-size:8px}}
+@media(max-width:420px){.main-content{padding-left:15px;padding-right:15px}.main-nav{gap:26px}.page-heading h1{font-size:35px}.page-heading p{font-size:9px;max-width:95%}.garden-notes{grid-template-columns:44px 1fr 107px;gap:0 10px}.garden-notes .objective-sigil{width:44px}.garden-notes>h2{font-size:23px}.garden-notes>.eyebrow{font-size:5px}.garden-notes>.objective-copy{font-size:8px}.garden-notes .reward-guide>span:last-child{font-size:5px}.toolbar-end .hint-button{font-size:9px;padding:9px 8px}.tool-group,.toolbar-end{gap:7px}.garden-toolbar button{font-size:9px;gap:5px}.garden-toolbar button svg{width:15px}.interaction-note{font-size:6px;gap:6px}.stage-coordinate{font-size:6px}.stage-coordinate small{font-size:5px}.stage-top{inset:16px 15px auto}.stage-bottom{left:15px;right:15px;bottom:14px}.stage-weather{font-size:11px}.light-status{font-size:7px}.light-status strong{font-size:9px}.trail-node{width:30px;height:30px}.trail-levels:before{top:15px}.trail-name{font-size:5px}.page-footer>span:first-child{font-size:4.5px}.page-footer>span:last-child{font-size:4.5px}.chapter-card-body h2{font-size:22px}.chapter-card-body>p{font-size:7px;min-height:54px}.chapter-card-body .text-button{font-size:7px}.memory-grid{grid-template-columns:1fr}.memory h2{font-size:29px}.memory>p{font-size:11px}.guide-steps{grid-template-columns:1fr;gap:9px}.guide-steps>div{padding:14px 14px 14px 53px;min-height:80px}.guide-steps svg{position:absolute;left:15px;top:17px}.guide-steps>div>span{display:none}.guide-steps p{font-size:9px}.guide-bottom{flex-direction:column;gap:0}.keyboard-guide{padding:0 0 20px}.guide-content>.primary-button{width:100%}.dialog-header h2{font-size:30px}.mechanics-grid{column-gap:10px}.mechanic-symbol{flex-basis:24px;width:24px;height:24px;font-size:21px}.workshop-art h2{font-size:29px}.workshop-art h2 br{display:block}.workshop-art h2 i{margin-left:0}}
+/* Reading comfort: keep the field guide quiet without making its controls microscopic. */
+@media(min-width:1061px){
+ .chapter-copy strong{font-size:12px}.chapter-copy small{font-size:8px}.chapter-copy>span{font-size:9px}.chapter-button{min-height:88px}.chapter-icon{width:37px;flex-basis:37px}.chapter-button{gap:10px}.sidebar-link{font-size:11px}.sidebar-count,.small-count{font-size:10px}.sidebar-title .eyebrow{font-size:8px}
+ .main-nav button{font-size:13px}.page-heading p{font-size:12px}.breadcrumb{font-size:9px}.quiet-button{font-size:11px}.garden-notes>.eyebrow{font-size:8px;letter-spacing:1.1px}.objective-copy{font-size:12px;line-height:1.8}.objective-progress>span,.objective-progress strong{font-size:11px}.move-stats small{font-size:7px}.reward-guide>span:last-child{font-size:10px}.garden-story p{font-size:17px}.garden-story>span:last-child{font-size:7px}.first-help{font-size:9px;line-height:1.6}.level-counter{font-size:9px}.garden-toolbar button{font-size:12px}.interaction-note{font-size:9px}.stage-coordinate{font-size:9px}.stage-coordinate small{font-size:8px}.light-status{font-size:10px}.trail-name{font-size:9px}.trail-heading .eyebrow,.trail-heading>span:last-child{font-size:9px}.page-footer{font-size:9px}.page-footer>span:first-child{font-size:7px}
+ .objective-sigil{width:76px;margin:23px auto 14px}.garden-story{margin-top:23px}.move-stats{margin:22px 0 16px}.garden-notes h2{font-size:31px}
+ .chapter-card-body .eyebrow{font-size:8px}.chapter-card-body>p{font-size:12px}.chapter-card-body .text-button{font-size:12px}.atlas-intro p{font-size:12px}.atlas-intro>span{font-size:11px}.art-caption{font-size:8px}.chapter-card-body>p{min-height:65px}.card-level-list button{font-size:12px}.workshop-form>p{font-size:13px}.workshop-form label{font-size:12px}.select-wrap select{font-size:12px}.workshop-form .input-note,.workshop-promise{font-size:10px}.workshop-presets strong{font-size:12px}.workshop-presets small{font-size:10px}.memory>p{font-size:13px}.memory .text-button{font-size:11px}
+}
+.dialog-intro{font-size:13px}.guide-steps h3{font-size:13px}.guide-steps p{font-size:12px}.mechanics-grid h4{font-size:12px}.mechanics-grid p{font-size:11px}.guide-bottom p{font-size:12px}.keyboard-guide>span{font-size:10px}.setting-row strong{font-size:13px}.setting-row p{font-size:11px}.backup-section>p{font-size:12px}.backup-section label{font-size:12px}.achievement h3{font-size:13px}.achievement p{font-size:11px}.achievement-total{font-size:12px}.primary-button{font-size:12px}.secondary-button{font-size:11px}.settings-content .backup-actions label.secondary-button{font-size:11px}
+@media(max-width:700px){
+ .main-nav button{font-size:11px}.page-heading p{font-size:11px}.stage-coordinate{font-size:8px}.stage-coordinate small{font-size:7px}.stage-weather{font-size:12px}.light-status{font-size:9px}.interaction-note{font-size:8px;flex-wrap:wrap;row-gap:3px}.interaction-note>span:last-child,.interaction-note>i:last-of-type{display:none}.garden-toolbar button{font-size:11px}.toolbar-end .hint-button{font-size:10px}.garden-notes{grid-template-columns:43px 1fr 108px;gap:0 10px}.garden-notes .objective-sigil{width:43px}.garden-notes>.eyebrow{font-size:6px;letter-spacing:.7px}.garden-notes>.objective-copy{font-size:10px}.garden-notes>h2{font-size:26px}.garden-notes .reward-guide>span:last-child{font-size:7px;line-height:1.6}.garden-notes .move-stats small{font-size:6px}.trail-name{font-size:7px;line-height:1.4;min-height:28px}.trail-heading .eyebrow,.trail-heading>span:last-child{font-size:8px}.page-footer>span:first-child{display:none}.page-footer>span:last-child{font-size:8px;margin:auto}.trail-levels{row-gap:17px}.stage-top{inset:15px 16px auto}.stage-bottom{left:16px;right:16px;bottom:13px}.chapter-card-body>p{font-size:10px;min-height:72px}.chapter-card-body .text-button{font-size:9px}.atlas-intro p{font-size:11px}.card-level-list button{font-size:10px}.workshop-form>p{font-size:12px}.workshop-form label{font-size:11px}.select-wrap select{font-size:11px}.workshop-form .input-note{font-size:10px}.workshop-promise{font-size:9px}.memory>p{font-size:12px}.memory .text-button{font-size:10px}.guide-steps p{font-size:11px}.guide-steps h3{font-size:12px}.mechanics-grid{grid-template-columns:1fr}.mechanics-grid p{font-size:12px}.mechanics-grid h4{font-size:13px}.guide-bottom{flex-direction:column;gap:0}.keyboard-guide{padding-top:0}.guide-bottom p{font-size:12px}.guide-content>.primary-button{width:100%}.guide-steps{grid-template-columns:1fr;gap:9px}.guide-steps>div{padding:15px 15px 15px 56px;min-height:82px}.guide-steps svg{position:absolute;left:17px;top:19px}.guide-steps>div>span{display:none}.toast{font-size:12px}.win-panel>.primary-button{font-size:10px}
+}
diff --git a/projects/lumen-atelier-8f3c/src/App.tsx b/projects/lumen-atelier-8f3c/src/App.tsx
new file mode 100644
index 00000000..61384c30
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/App.tsx
@@ -0,0 +1,421 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
+import { ArrowDownToLine, ArrowRight, ArrowUpFromLine, BookOpen, Check, ChevronDown, ChevronRight, CircleHelp, Compass, Copy, Flower2, Grid2X2, Headphones, Leaf, Lightbulb, Maximize, Moon, RotateCcw, Settings2, Shuffle, Sparkles, Sprout, Sun, Trophy, Undo2, Volume2, VolumeX, WandSparkles, Wind, X } from 'lucide-react'
+import Board from './Board'
+import { CHAPTERS, LEVELS, dailyLevel, generateLevel, getHint, rotateTile, simulate } from './game'
+import type { Level, Tile } from './game'
+import { exportProgress, importProgress, loadProgress, saveProgress } from './progress'
+import type { Progress } from './progress'
+import { GardenAudio } from './audio'
+import './App.css'
+
+type Page = 'play' | 'atlas' | 'daily' | 'workshop' | 'journal'
+type Modal = 'guide' | 'settings' | 'achievements' | null
+type Snapshot = { tiles: Tile[]; moves: number }
+type Session = Snapshot & { level: Level; history: Snapshot[]; hints: number; hintId: string | null; hintRotation: number | null; won: boolean }
+const SESSION_KEY = 'lumen-garden-session-v1'
+const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI']
+const MECHANICS = ['Light & turns', 'Branching paths', 'Rooted stones', 'One-way gates', 'Woven channels', 'Paired portals']
+const today = () => new Date().toISOString().slice(0, 10)
+const countCompleted = (progress: Progress) => LEVELS.filter(level => progress.completed[level.id]).length
+
+function makeSession(level: Level, restore = false): Session {
+ const base: Session = { level, tiles: level.tiles.map(tile => ({ ...tile })), moves: 0, history: [], hints: 0, hintId: null, hintRotation: null, won: false }
+ if (restore) {
+ try {
+ const saved = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null')
+ if (saved?.id === level.id && Array.isArray(saved.rotations) && saved.rotations.length === level.tiles.length && saved.rotations.every((n: unknown) => Number.isInteger(n) && Number(n) >= 0 && Number(n) <= 3) && Number.isInteger(saved.moves) && saved.moves >= 0 && Number.isInteger(saved.hints) && saved.hints >= 0) {
+ base.tiles = base.tiles.map((tile, i) => ({ ...tile, rotation: tile.fixed || ['source', 'crystal', 'rock'].includes(tile.kind) ? tile.rotation : saved.rotations[i] }))
+ base.moves = saved.moves
+ base.hints = saved.hints
+ base.won = simulate(base.tiles).solved
+ }
+ } catch { /* A damaged session must never prevent a fresh garden. */ }
+ }
+ return base
+}
+
+function parseRoute(hash: string, progress: Progress): { page: Page; level: Level } {
+ const parts = hash.replace(/^#\/?/, '').split('/')
+ const fallback = LEVELS.find(level => level.id === progress.currentLevel) || LEVELS[0]
+ if (parts[0] === 'daily') return { page: 'daily', level: dailyLevel(/^\d{4}-\d{2}-\d{2}$/.test(parts[1] || '') ? parts[1] : today()) }
+ if (parts[0] === 'seed') {
+ const seed = /^\d{1,10}$/.test(parts[1] || '') ? Number(parts[1]) >>> 0 : 108
+ const chapter = /^[0-5]$/.test(parts[2] || '') ? Number(parts[2]) : 0
+ return { page: 'play', level: generateLevel(seed, chapter) }
+ }
+ if (['atlas', 'workshop', 'journal'].includes(parts[0])) return { page: parts[0] as Page, level: fallback }
+ return { page: 'play', level: LEVELS.find(level => level.id === parts[1]) || fallback }
+}
+
+function Sigil({ chapter = 0, className = '' }: { chapter?: number; className?: string }) {
+ return
+
+
+ {Array.from({ length: 4 + chapter }, (_, i) => )}
+
+
+}
+
+function Blooms({ count, className = '' }: { count: number; className?: string }) {
+ return {[1, 2, 3].map(n => )}
+}
+
+function SettingRow({ title, description, enabled, onToggle, icon }: { title: string; description: string; enabled: boolean; onToggle: () => void; icon: ReactNode }) {
+ return
+}
+
+function Dialog({ title, children, onClose, wide = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean }) {
+ const ref = useRef(null)
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null
+ const element = ref.current
+ element?.focus()
+ const trap = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') onClose()
+ if (event.key !== 'Tab' || !element) return
+ const items = Array.from(element.querySelectorAll('button:not(:disabled),a[href],input,select,textarea,[tabindex="0"]'))
+ const first = items[0], last = items[items.length - 1]
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === element)) { event.preventDefault(); last?.focus() }
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
+ }
+ document.addEventListener('keydown', trap)
+ const oldOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => { document.removeEventListener('keydown', trap); document.body.style.overflow = oldOverflow; previous?.focus() }
+ }, [onClose])
+ return { if (event.target === event.currentTarget) onClose() }}>
+
+
+}
+
+const memories = [
+ ['The first keeper', 'Before there were roads between the stars, there were gardens. Their keepers left a light burning for anyone who had lost their way. You have found the first.'],
+ ['What light remembers', 'The gardener never planted a seed alone. One light became two; two became a thousand. Here, generosity was a kind of architecture.'],
+ ['The patient stones', 'Some stones have stood in the same place for centuries. Do not ask them to move. Listen to the shape of the space they leave you.'],
+ ['A river only forward', 'A traveler asked the keeper how to go back. She offered a seed instead. Some paths only open when we stop looking behind us.'],
+ ['Almost touching', 'Two rivers of light cross without meeting. Perhaps this is what it means to share a sky: to make room for another journey beside your own.'],
+ ['The distance between', 'The last garden was never far away. It was waiting on the other side of an impossible door. You brought the light. The garden remembered the rest.'],
+]
+
+export default function App() {
+ const [initial] = useState(() => {
+ const saved = loadProgress()
+ const route = parseRoute(window.location.hash, saved)
+ const currentLevel = route.page === 'play' && LEVELS.some(level => level.id === route.level.id)
+ ? route.level.id : saved.currentLevel
+ return { route, progress: { ...saved, currentLevel } }
+ })
+ const [progress, setProgress] = useState(initial.progress)
+ const [page, setPage] = useState(initial.route.page)
+ const [session, setSession] = useState(() => makeSession(initial.route.level, true))
+ const [modal, setModal] = useState(null)
+ const [toast, setToast] = useState('')
+ const [focusMode, setFocusMode] = useState(false)
+ const [atlasChapter, setAtlasChapter] = useState(null)
+ const [workshopChapter, setWorkshopChapter] = useState(2)
+ const [seed, setSeed] = useState('314159')
+ const [importText, setImportText] = useState('')
+ const [backupText, setBackupText] = useState('')
+ const [progressStorageOk, setProgressStorageOk] = useState(true)
+ const [sessionStorageOk, setSessionStorageOk] = useState(true)
+ const storageOk = progressStorageOk && sessionStorageOk
+ const ambient = progress.ambience
+ const [mobileChapters, setMobileChapters] = useState(false)
+ const audio = useRef(null)
+ const gameArea = useRef(null)
+ const progressRef = useRef(progress)
+ const stateRef = useRef(session)
+ const simulation = useMemo(() => simulate(session.tiles), [session.tiles])
+ const chapter = CHAPTERS[session.level.chapter] || CHAPTERS[0]
+ const completed = countCompleted(progress)
+ const totalBlooms = LEVELS.reduce((sum, level) => sum + (progress.completed[level.id]?.stars || 0), 0)
+ const isGarden = page === 'play' || page === 'daily'
+ const isCampaign = LEVELS.some(level => level.id === session.level.id)
+ const chapterLevels = LEVELS.filter(level => level.chapter === session.level.chapter)
+ const chapterComplete = chapterLevels.filter(level => progress.completed[level.id]).length
+ const activeLevelNumber = isCampaign ? session.level.index % 8 + 1 : null
+ const earnedStars = session.hints > 0 ? 1 : session.moves <= session.level.par ? 3 : 2
+ const allRecords = Object.values(progress.completed)
+ const achievements = [
+ { title: 'A little light', detail: 'Restore your first garden.', achieved: completed >= 1, icon: Sprout },
+ { title: 'In full bloom', detail: 'Earn three blooms in a garden.', achieved: allRecords.some(record => record.stars === 3), icon: Flower2 },
+ { title: 'The patient keeper', detail: 'Restore eight campaign gardens.', achieved: completed >= 8, icon: Leaf },
+ { title: 'Morning ritual', detail: 'Restore a daily garden.', achieved: Object.keys(progress.completed).some(id => id.startsWith('daily-')), icon: Sun },
+ { title: 'Uncharted paths', detail: 'Restore a workshop garden.', achieved: Object.keys(progress.completed).some(id => !id.startsWith('daily-') && !LEVELS.some(level => level.id === id)), icon: Compass },
+ { title: 'A sky of gardens', detail: 'Restore all 48 campaign gardens.', achieved: completed === 48, icon: Sparkles },
+ { title: 'Golden hands', detail: 'Earn 72 campaign blooms.', achieved: totalBlooms >= 72, icon: Trophy },
+ { title: 'A perfect constellation', detail: 'Earn all 144 campaign blooms.', achieved: totalBlooms === 144, icon: Moon },
+ ]
+
+ useEffect(() => { progressRef.current = progress }, [progress])
+ useEffect(() => { stateRef.current = session }, [session])
+ useEffect(() => {
+ const handleHash = () => {
+ const route = parseRoute(window.location.hash, progressRef.current)
+ setPage(route.page)
+ setMobileChapters(false)
+ if (route.page === 'play' || route.page === 'daily') {
+ setSession(makeSession(route.level, true))
+ if (LEVELS.some(level => level.id === route.level.id)) setProgress(current => ({ ...current, currentLevel: route.level.id }))
+ }
+ }
+ window.addEventListener('hashchange', handleHash)
+ return () => window.removeEventListener('hashchange', handleHash)
+ }, [])
+ useEffect(() => {
+ const engine = new GardenAudio()
+ audio.current = engine
+ return () => { engine.dispose(); audio.current = null }
+ }, [])
+ useEffect(() => {
+ const saved = saveProgress(progress)
+ let active = true
+ queueMicrotask(() => { if (active) setProgressStorageOk(saved) })
+ audio.current?.setSound(progress.sound)
+ audio.current?.setAmbience(progress.ambience)
+ return () => { active = false }
+ }, [progress])
+ useEffect(() => {
+ if (!isGarden) return
+ let saved = true
+ try { localStorage.setItem(SESSION_KEY, JSON.stringify({ id: session.level.id, rotations: session.tiles.map(tile => tile.rotation), moves: session.moves, hints: session.hints })) } catch { saved = false }
+ let active = true
+ queueMicrotask(() => { if (active) setSessionStorageOk(saved) })
+ return () => { active = false }
+ }, [session, isGarden])
+ useEffect(() => {
+ if (!toast) return
+ const timeout = window.setTimeout(() => setToast(''), 4200)
+ return () => window.clearTimeout(timeout)
+ }, [toast])
+
+ function navigate(path: string) {
+ setModal(null)
+ setFocusMode(false)
+ setMobileChapters(false)
+ if (window.location.hash === `#/${path}`) {
+ const route = parseRoute(`#/${path}`, progress)
+ setPage(route.page)
+ setSession(makeSession(route.level, true))
+ } else window.location.hash = `/${path}`
+ window.scrollTo({ top: 0, behavior: 'instant' })
+ }
+
+ function rememberWin(next: Session) {
+ const stars = next.hints > 0 ? 1 : next.moves <= next.level.par ? 3 : 2
+ setProgress(current => {
+ const old = current.completed[next.level.id]
+ const updated = { ...current, completed: { ...current.completed, [next.level.id]: { moves: old ? Math.min(old.moves, next.moves) : next.moves, stars: old ? Math.max(old.stars, stars) : stars, hints: old ? Math.min(old.hints, next.hints) : next.hints, date: today() } } }
+ return updated
+ })
+ audio.current?.play('complete')
+ }
+
+ function commitRotation(id: string, delta = 1, target?: number) {
+ const current = stateRef.current
+ if (current.won) return
+ const tile = current.tiles.find(item => item.id === id)
+ if (!tile || tile.fixed || ['source', 'crystal', 'rock'].includes(tile.kind)) return
+ const difference = target === undefined ? 1 : Math.min((target - tile.rotation + 4) % 4, (tile.rotation - target + 4) % 4)
+ const tiles = current.tiles.map(item => item.id === id ? target === undefined ? rotateTile(item, delta) : { ...item, rotation: target } : item)
+ const next: Session = { ...current, tiles, moves: current.moves + difference, history: [...current.history.slice(-499), { tiles: current.tiles, moves: current.moves }], hintId: null, hintRotation: null, won: simulate(tiles).solved }
+ stateRef.current = next
+ setSession(next)
+ if (next.won) rememberWin(next)
+ else audio.current?.play('rotate')
+ }
+
+ function undo() {
+ const current = stateRef.current
+ const previous = current.history.at(-1)
+ if (!previous) return
+ const next = { ...current, ...previous, history: current.history.slice(0, -1), won: false, hintId: null, hintRotation: null }
+ stateRef.current = next
+ setSession(next)
+ audio.current?.play('rotate')
+ }
+
+ function restart() {
+ const next = makeSession(stateRef.current.level)
+ stateRef.current = next
+ setSession(next)
+ audio.current?.play('reset')
+ setToast('A fresh start. The light is waiting for you.')
+ }
+
+ function hint() {
+ const current = stateRef.current
+ if (current.won) return
+ if (current.hintId && current.hintRotation !== null) {
+ commitRotation(current.hintId, 1, current.hintRotation)
+ return
+ }
+ const nextHint = getHint(current.level, current.tiles)
+ if (!nextHint) return
+ const next = { ...current, hintId: nextHint.tileId, hintRotation: nextHint.rotation, hints: current.hints + 1 }
+ stateRef.current = next
+ setSession(next)
+ audio.current?.play('hint')
+ setToast('Try the highlighted stone. Tap the hint again to turn it into place.')
+ }
+
+ useEffect(() => {
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.ctrlKey || event.metaKey || event.altKey || modal || !isGarden || (event.target instanceof HTMLElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName))) return
+ if (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'u') { event.preventDefault(); undo() }
+ if (event.key.toLowerCase() === 'r') { event.preventDefault(); restart() }
+ if (event.key.toLowerCase() === 'h') { event.preventDefault(); hint() }
+ if (event.key === '?') setModal('guide')
+ if (event.key === 'Escape') setFocusMode(false)
+ }
+ window.addEventListener('keydown', handleKey)
+ return () => window.removeEventListener('keydown', handleKey)
+ })
+
+ function changeSetting(key: K, value: Progress[K]) {
+ const next = { ...progress, [key]: value }
+ setProgress(next)
+ setProgressStorageOk(saveProgress(next))
+ if (key === 'sound') audio.current?.setSound(Boolean(value))
+ }
+
+ function toggleAmbience() {
+ const next = !ambient
+ changeSetting('ambience', next)
+ audio.current?.setAmbience(next)
+ }
+
+ async function copyText(value: string, message: string) {
+ try { await navigator.clipboard.writeText(value); setToast(message) }
+ catch { setBackupText(value); setModal('settings'); setToast('Copy the text in the backup field below.') }
+ }
+
+ function shareGarden() {
+ const hash = session.level.id.startsWith('daily-') ? `/daily/${session.level.id.slice(6)}` : window.location.hash.slice(1) || `/play/${session.level.id}`
+ void copyText(`${window.location.href.split('#')[0]}#${hash}`, 'Garden link copied. Share a little light.')
+ }
+
+ function nextGarden() {
+ if (!isCampaign) { navigate(page === 'daily' ? 'atlas' : 'workshop'); return }
+ const next = LEVELS[session.level.index + 1]
+ if (next) navigate(`play/${next.id}`)
+ else navigate('journal')
+ }
+
+ function launchWorkshop() {
+ if (!/^\d{1,10}$/.test(seed) || Number(seed) > 4294967295) { setToast('Choose a seed from 0 to 4,294,967,295.'); return }
+ navigate(`seed/${Number(seed)}/${workshopChapter}`)
+ }
+
+ const onCloseModal = useMemo(() => () => setModal(null), [])
+
+ return
+
{ event.preventDefault(); document.getElementById('main-content')?.focus() }}>Skip to garden
+
+
+
+
+ YOUR EXPEDITION setMobileChapters(!mobileChapters)}> {String(completed).padStart(2, '0')} / 48
+
+
+ {CHAPTERS.map((item, i) => {
+ const levels = LEVELS.filter(level => level.chapter === i)
+ const done = levels.filter(level => progress.completed[level.id]).length
+ const active = isGarden && session.level.chapter === i
+ return navigate(`play/${(levels.find(level => !progress.completed[level.id]) || levels[0]).id}`)}>
+ {done === 8 && }
+ CHAPTER {ROMAN[i]} {item.title} {done === 8 ? 'In full bloom' : `${done} of 8 gardens restored`}
+ {active && }
+
+ })}
+
+ navigate('atlas')}> All gardens
+ navigate('journal')}> The keeper’s journal{CHAPTERS.filter((_, i) => LEVELS.some(level => level.chapter === i && progress.completed[level.id])).length}/6 setModal('achievements')}> Collected blooms{totalBlooms} Nothing here is in a hurry. Neither should you be.
+
+
+
+ {isGarden && <>
+ navigate('atlas')}>{page === 'daily' ? 'THE DAILY RITUAL' : !isCampaign ? 'AN UNCHARTED GARDEN' : `CHAPTER ${ROMAN[session.level.chapter]}`} {page === 'daily' ? session.level.id.slice(6) : chapter.title} {session.level.title}✳ {session.level.subtitle}
setModal('guide')}> Field guide
+
+
+
+
+
{page === 'daily' ? 'TODAY’S GARDEN' : isCampaign ? `GARDEN ${String(session.level.index + 1).padStart(2, '0')}` : 'THE WORKSHOP'}{session.level.size} × {session.level.size} · {MECHANICS[session.level.chapter]}
setFocusMode(!focusMode)} title={focusMode ? 'Leave focus mode' : 'Focus mode'} aria-label={focusMode ? 'Leave focus mode' : 'Focus mode'}>{focusMode ? : }
+
+
0 ? 'lit' : ''}`} />{simulation.litCrystals} / {simulation.totalCrystals} crystals awake {session.won ? 'The garden remembers.' : 'Follow the light.'}
+ {session.won &&
A little corner of the world, alight.
}
+
+ Undo Z Restart R
changeSetting('sound', !progress.sound)} title="Toggle sound" aria-label={progress.sound ? 'Mute sound effects' : 'Enable sound effects'}>{progress.sound ? : } {session.hintId ? 'Turn this stone' : 'A gentle nudge'} H
+ Click a stone to rotateShift + click to turn back Take your time.
+
+
+
+ {page === 'daily' ? 'ONE GARDEN, EVERY DAY' : isCampaign ? `GARDEN ${String(activeLevelNumber).padStart(2, '0')}` : 'A GARDEN OF YOUR OWN'} {isCampaign && / 08 }
+
+ {session.won ? 'LIGHT HAS FOUND ITS WAY' : 'A SMALL ACT OF RESTORATION'}
+ {session.won ? 'A garden, reborn.' : 'Let there be light.'}
+ {session.won ? completed === 48 ? 'Every garden is awake. You have carried the light all the way home.' : 'Every crystal is awake. Somewhere, an old keeper is smiling.' : 'Turn the stone channels. Guide the light from its source to every sleeping crystal.'}
+ {session.won ? {earnedStars === 3 ? 'A beautiful, effortless restoration.' : earnedStars === 2 ? 'You found your own way through.' : 'A little help. A little light.'}
{isCampaign ? session.level.index === 47 ? 'Read the final memory' : 'Next garden' : page === 'daily' ? 'Explore the journey' : 'Create another garden'} Share this garden : <>
+ {simulation.litCrystals === simulation.totalCrystals ? 'All crystals restored' : 'Crystals restored'}{simulation.litCrystals} / {simulation.totalCrystals} {Array.from({ length: simulation.totalCrystals }, (_, i) => )}
+ {String(session.moves).padStart(2, '0')} YOUR TURNS
{String(session.level.par).padStart(2, '0')} GARDENER’S GUIDE
+ Within {session.level.par} turns, without hints. Every solution is a good solution.
+ >}
+ “{session.level.story}”
— THE KEEPER’S NOTES
+ {session.hintId && !session.won && The marked stone needs a turn. Tap “Turn this stone” to place it.
}
+ {!session.won && session.level.index === 0 && isCampaign && setModal('guide')}>New here? Open the field guide }
+
+
+
+ {isCampaign ? {chapter.title.toUpperCase()} {chapterComplete} of 8 restored
{chapterLevels.map((level, i) => navigate(`play/${level.id}`)} className={`trail-level ${session.level.id === level.id ? 'current' : ''} ${progress.completed[level.id] ? 'completed' : ''}`} aria-label={`Garden ${i + 1}: ${level.title}${progress.completed[level.id] ? ', restored' : ''}`} aria-current={session.level.id === level.id ? 'step' : undefined}>{progress.completed[level.id] ? : {String(i + 1).padStart(2, '0')} } {level.title} {progress.completed[level.id] ? : ··· } )}
: {page === 'daily' ? 'The same sky. The same garden. A new possibility every day.' : 'Every seed is a different place to begin.'}
Share garden }
+ >}
+
+ {page === 'atlas' && <>
+ AN EXPEDITION IN SIX CHAPTERS A world waiting for light. Forty-eight quiet places. A thousand ways to begin.
{completed}/48 GARDENS RESTORED
+ Every chapter is open. Follow the journey, or wander wherever your curiosity takes you.
{totalBlooms} / 144 blooms
+ {CHAPTERS.map((item, i) => {
+ const levels = LEVELS.filter(level => level.chapter === i)
+ const done = levels.filter(level => progress.completed[level.id]).length
+ return
{ setAtlasChapter(atlasChapter === i ? null : i) }} aria-label={`Explore ${item.title}`} aria-expanded={atlasChapter === i}>{ROMAN[i]}
{MECHANICS[i]} CHAPTER {ROMAN[i]} · 8 GARDENS {item.title} {item.description}
{done} / 8
navigate(`play/${(levels.find(level => !progress.completed[level.id]) || levels[0]).id}`)}>{done === 8 ? 'Return to the garden' : done > 0 ? 'Continue exploring' : 'Begin this chapter'} {atlasChapter === i && {levels.map((level, n) => navigate(`play/${level.id}`)}>{String(n + 1).padStart(2, '0')} {level.title}{progress.completed[level.id] ? : } )}
}
+ })}
+ >}
+
+ {page === 'workshop' && <>
+ BEYOND THE KNOWN GARDENS Make room for wonder. A seed, a little curiosity, and a garden that has never been yours before.
+
POSSIBILITY TAKES ROOT One seed.Endless beginnings. Nº {seed || '0'} YOUR GARDEN RECIPE Plant a new puzzle. Choose a chapter’s rules and give your garden a seed. The same recipe always grows the same puzzle, ready to share.
Garden style setWorkshopChapter(Number(event.target.value))}>{CHAPTERS.map((item, i) => {ROMAN[i]} · {item.title} — {MECHANICS[i]} )}
Seed number setSeed(event.target.value.replace(/\D/g, ''))} onKeyDown={event => { if (event.key === 'Enter') launchWorkshop() }} /> setSeed(String(crypto.getRandomValues(new Uint32Array(1))[0]))}>
Any whole number from 0 to 4,294,967,295.
Grow this gardenEvery garden has a verified path to the light.
+ A FEW SEEDS TO GET YOU STARTED {[{ seed: 1729, chapter: 0, title: 'An easy morning', description: 'A small, quiet place to find your rhythm.' }, { seed: 271828, chapter: 3, title: 'The long way home', description: 'One-way paths reward a patient eye.' }, { seed: 1618033, chapter: 5, title: 'Somewhere, elsewhere', description: 'Step through a door in the light.' }].map(item =>
navigate(`seed/${item.seed}/${item.chapter}`)}>{item.title} {item.description} )}
+ >}
+
+ {page === 'journal' && <>
+ FRAGMENTS FROM AN OLD FIELD BOOK The keeper’s journal. Some things return slowly. A garden. A memory. A little hope.
+ Y ou arrived with nothing but a lantern and a feeling that something here was still alive. With every garden you restore, another page finds its way back into this book.
{completed} GARDENS REMEMBERED
+ {memories.map(([title, text], i) => {
+ const unlocked = LEVELS.some(level => level.chapter === i && progress.completed[level.id])
+ return
MEMORY {ROMAN[i]}
{unlocked ? title : 'A page yet to return.'} {unlocked ? text : `Restore a garden in ${CHAPTERS[i].title} to recover this memory.`}
navigate(`play/${LEVELS[i * 8].id}`)}>{unlocked ? 'Return to this chapter' : 'Find this memory'}
+ })}
+ {completed === 48 && THE LAST PAGE The keeper was always you. Forty-eight gardens. Forty-eight small acts of care. There is no grand secret at the end of this journey, only this: a world becomes beautiful when someone chooses to tend it.
Thank you for bringing the light.
navigate('workshop')}>There are more gardens to find }
+ >}
+
+ A QUIET ADVENTURE IN LIGHT{storageOk ? 'Your journey is saved on this device' : 'Storage unavailable — keep a backup in Settings'}
+
+
+
+ {modal &&
+ {modal === 'guide' && Nothing is lost. Nothing is timed. Turn the channels until the light reaches every crystal.
01 Find the source The golden lantern is always alight. Follow its channel into the garden.
02 Turn the stones Click a stone to rotate it clockwise. Its channel must meet the next stone’s channel.
03 Wake every crystal Connect all the crystals to the source. Light travels automatically along joined channels.
The shapes of a garden {[{ symbol: '└', name: 'Channels', text: 'Straight stones and corners carry light through their open ends.' }, { symbol: '┬', name: 'Branches', text: 'A branch sends light down every connected arm. Find more than one path.' }, { symbol: '◈', name: 'Rooted stones', text: 'Stones marked with small pins cannot turn. Sources and crystals stay still, too.' }, { symbol: '↑', name: 'One-way gates', text: 'Light can only pass in the arrow’s direction. Turning the gate changes its direction.' }, { symbol: '╪', name: 'Bridges', text: 'Two channels cross but never join. North connects south; east connects west.' }, { symbol: '◎', name: 'Portals', text: 'Matching letters share a doorway. Light entering one leaves through its partner.' }].map(item =>
)}
A little help is always here. Hints first mark a stone, then offer to turn it for you. Earn three blooms within the gardener’s guide without hints; two for any unassisted solution; one with a helping hand. Every garden remains open.
Tab Select stoneEnter / Space TurnShift + click / Enter · ReverseZ Undo R Restart H Hint
Let’s find the light }
+ {modal === 'settings' && A little space, just the way you like it.
changeSetting('sound', !progress.sound)} icon={ } /> } /> changeSetting('reduceMotion', !progress.reduceMotion)} icon={ } /> changeSetting('highContrast', !progress.highContrast)} icon={ } />Keep your journey close. Your progress stays in this browser. Copy a backup to take your completed gardens to another device.
{backupText && <>
Copy this backup or garden link Made for unhurried minds. No accounts, no ads, no clocks.
}
+ {modal === 'achievements' && {totalBlooms} of 144 campaign blooms collected. Every little light counts.
{achievements.map(item =>
{item.title} {item.detail}
{item.achieved &&
}
)}
{achievements.filter(item => item.achieved).length} of {achievements.length} keepsakes found
}
+ }
+ {toast &&
{toast} setToast('')}>
}
+
+}
diff --git a/projects/lumen-atelier-8f3c/src/Board.css b/projects/lumen-atelier-8f3c/src/Board.css
new file mode 100644
index 00000000..cf5a480d
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/Board.css
@@ -0,0 +1,108 @@
+.garden-scene {
+ width: 100%;
+ position: relative;
+ isolation: isolate;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.garden-board {
+ display: block;
+ width: 100%;
+ height: auto;
+ overflow: visible;
+ user-select: none;
+ touch-action: manipulation;
+}
+
+.orbit-line {
+ fill: none;
+ stroke: #90a788;
+ stroke-opacity: .14;
+ stroke-width: .7px;
+ stroke-dasharray: 2 9;
+}
+
+.orbit-line--inner {
+ stroke-opacity: .065;
+ stroke-dasharray: none;
+}
+
+.garden-tile .tile-lift {
+ transition: transform 160ms ease, filter 220ms ease;
+ pointer-events: none;
+}
+
+.garden-tile--rotatable:has(.tile-hit:hover) .tile-lift,
+.garden-tile--rotatable:has(.tile-hit:focus-visible) .tile-lift {
+ transform: translateY(-4px);
+ filter: brightness(1.055);
+}
+
+.garden-tile--rotatable .tile-hit { cursor: pointer; }
+.garden-tile .tile-hit[aria-disabled='true'] { cursor: default; }
+.garden-tile .tile-hit { outline: none; }
+.garden-tile .tile-hit:focus { outline: none; }
+
+.tile-hit .tile-interaction-outline {
+ transition: stroke 150ms ease, fill 150ms ease;
+}
+
+.tile-hit:focus-visible .tile-interaction-outline {
+ stroke: #ffecb4;
+ stroke-width: 2.5px;
+ fill: #fff0b41a;
+}
+
+.garden-tile--hint .tile-interaction-outline {
+ stroke: #ffe7a0;
+ stroke-width: 2px;
+ stroke-dasharray: 5 3;
+ fill: #fff0b412;
+ animation: hint-outline 2s ease-in-out infinite;
+}
+
+.tile-hint-mark { animation: hint-nod 1.7s ease-in-out infinite; }
+.channel-bloom { opacity: .8; }
+.channel-light { animation: light-arrive 550ms ease both; }
+.sun-halo { opacity: .85; }
+.garden-mote { animation: drift-mote 8s ease-in-out infinite; pointer-events: none; }
+.garden-crystal--lit .crystal-float { animation: crystal-float 4s ease-in-out infinite; }
+.garden-scene--awakened .garden-mote { fill: #f2d893; }
+.garden-scene--awakened .scene-atmosphere { filter: brightness(1.15); }
+
+@keyframes drift-mote {
+ 0%, 100% { opacity: .1; transform: translate(0, 4px); }
+ 50% { opacity: .75; transform: translate(5px, -6px); }
+}
+
+@keyframes light-arrive {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes crystal-float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-3px); }
+}
+
+@keyframes hint-nod {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-4px); }
+}
+
+@keyframes hint-outline {
+ 0%, 100% { stroke-opacity: .7; }
+ 50% { stroke-opacity: 1; }
+}
+
+@media (hover: none) {
+ .garden-tile--rotatable:has(.tile-hit:hover) .tile-lift { transform: none; filter: none; }
+ .garden-tile--rotatable:has(.tile-hit:active) .tile-lift { transform: translateY(-3px); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .garden-board *, .garden-board *::before, .garden-board *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/projects/lumen-atelier-8f3c/src/Board.tsx b/projects/lumen-atelier-8f3c/src/Board.tsx
new file mode 100644
index 00000000..3b2e4d2d
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/Board.tsx
@@ -0,0 +1,231 @@
+import { useId, useRef, type KeyboardEvent } from 'react'
+import { ports, type Level, type Tile, type simulate } from './game'
+import './Board.css'
+
+type BoardProps = {
+ level: Level
+ tiles: Tile[]
+ simulation: ReturnType
+ onRotate: (id: string, delta?: number) => void
+ hintId: string | null
+ disabled?: boolean
+}
+
+type Point = [number, number]
+const directionNames = ['north', 'east', 'south', 'west']
+const tileNames: Record = {
+ source: 'Sun well', crystal: 'Garden crystal', straight: 'Straight channel',
+ elbow: 'Curved channel', tee: 'Three-way channel', cross: 'Four-way channel',
+ bridge: 'Channel bridge', gate: 'One-way gate', portal: 'Moon gate', rock: 'Wild garden',
+}
+const isRotatable = (tile: Tile) => !tile.fixed && !['source', 'crystal', 'rock'].includes(tile.kind)
+
+function channelPaths(tile: Tile, half: number) {
+ const p: Point[] = [[0, -half], [half, 0], [0, half], [-half, 0]]
+ const directions = ports(tile)
+ if (directions.length === 2 && tile.kind !== 'gate') {
+ const [a, b] = directions
+ return [{ d: `M ${p[a]} Q 0 0 ${p[b]}`, directions }]
+ }
+ if (tile.kind === 'bridge') {
+ return [
+ { d: `M 0 ${-half} L 0 ${half}`, directions: [0, 2] },
+ { d: `M ${-half} 0 L ${-half * .3} 0 Q 0 ${-half * .45} ${half * .3} 0 L ${half} 0`, directions: [1, 3] },
+ ]
+ }
+ return directions.map(direction => ({ d: `M ${p[direction]} L 0 0`, directions: [direction] }))
+}
+
+function Plant({ x = 0, y = 0, scale = 1, flower = false }: { x?: number; y?: number; scale?: number; flower?: boolean }) {
+ return
+
+
+
+ {flower && }
+
+}
+
+export default function Board({ level, tiles, simulation, onRotate, hintId, disabled = false }: BoardProps) {
+ const unique = useId().replace(/:/g, '')
+ const tileRefs = useRef>({})
+ const gridSize = level.size
+ const step = Math.min(57, 304 / gridSize)
+ const half = step * .465
+ const rise = half * 1.04
+ const centerY = 354
+ const left = 400 - gridSize * step
+ const right = 400 + gridSize * step
+ const top = centerY - gridSize * step * .52
+ const bottom = centerY + gridSize * step * .52
+ const depth = 23
+ const activeCount = simulation.lit.size
+ const id = (name: string) => `${unique}-${name}`
+ const coords = (x: number, y: number): Point => [400 + (x - y) * step, centerY + (x + y - gridSize + 1) * step * .52]
+ const sorted = [...tiles].sort((a, b) => a.x + a.y - b.x - b.y || a.x - b.x)
+
+ function handleKey(event: KeyboardEvent, tile: Tile) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ if (!disabled && isRotatable(tile)) onRotate(tile.id, event.shiftKey ? -1 : 1)
+ return
+ }
+ const moves: Record = { ArrowUp: [0, -1], ArrowRight: [1, 0], ArrowDown: [0, 1], ArrowLeft: [-1, 0] }
+ const move = moves[event.key]
+ if (!move) return
+ event.preventDefault()
+ let [x, y] = [tile.x + move[0], tile.y + move[1]]
+ while (x >= 0 && y >= 0 && x < gridSize && y < gridSize) {
+ const next = tiles.find(item => item.x === x && item.y === y && isRotatable(item))
+ if (next) { tileRefs.current[next.id]?.focus(); return }
+ x += move[0]
+ y += move[1]
+ }
+ }
+
+ return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 20 }, (_, index) => )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sorted.map(tile => {
+ const [x, y] = coords(tile.x, tile.y)
+ const lit = simulation.lit.has(tile.id)
+ const litPorts = simulation.litPorts.get(tile.id) ?? new Set()
+ const tilePorts = ports(tile)
+ const rotatable = isRotatable(tile)
+ const paths = channelPaths(tile, half + 1)
+ const selected = hintId === tile.id
+ const label = `${tileNames[tile.kind]}, row ${tile.y + 1}, column ${tile.x + 1}. ${tile.kind === 'rock' ? '' : `${lit ? 'Lit' : 'Unlit'}. Channels ${tilePorts.map(direction => directionNames[direction]).join(' and ')}.`}${rotatable ? ' Press Enter to rotate clockwise, Shift Enter counterclockwise.' : ' Fixed.'}`
+ const crystalColor = lit ? '#f5d68f' : '#8caab0'
+ const tinySeed = ((tile.x * 13 + tile.y * 29) % 7)
+ return
+
+
+
+
+
+
+
+ {tinySeed < 2 && }
+ {paths.map((path, pathIndex) =>
+
+
+
+ {path.directions.every(direction => litPorts.has(direction)) && <>
+
+
+
+ >}
+ )}
+ {tile.kind === 'gate' && }
+ {tile.kind === 'bridge' && }
+ {tile.fixed && !['rock', 'source', 'crystal'].includes(tile.kind) && }
+
+
+ {tile.kind === 'rock' &&
+
+
+
+
+
+
+
+
+
+ }
+
+ {tile.kind === 'source' &&
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+ {tile.kind === 'crystal' &&
+
+
+ {lit && }
+
+
+
+
+
+ {lit && }
+
+
+ }
+
+ {tile.kind === 'portal' &&
+
+
+
+
+
+ }
+
+
+ { tileRefs.current[tile.id] = node }} className="tile-hit" role={rotatable ? 'button' : 'img'} tabIndex={rotatable && !disabled ? 0 : undefined} aria-label={label} aria-disabled={rotatable ? disabled : undefined} onClick={event => { if (rotatable && !disabled) onRotate(tile.id, event.shiftKey ? -1 : 1) }} onContextMenu={event => { if (rotatable && !disabled) { event.preventDefault(); onRotate(tile.id, -1) } }} onKeyDown={event => handleKey(event, tile)}>
+ {label}
+
+ {selected && }
+
+
+ })}
+
+
+
+
+
+
+
+
+}
diff --git a/projects/lumen-atelier-8f3c/src/audio.ts b/projects/lumen-atelier-8f3c/src/audio.ts
new file mode 100644
index 00000000..23efb7a5
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/audio.ts
@@ -0,0 +1,142 @@
+type GardenEvent = 'rotate' | 'hint' | 'complete' | 'reset'
+type AudioWindow = Window & { webkitAudioContext?: typeof AudioContext }
+
+/** A tiny procedural instrument. All audio starts with a player interaction. */
+export class GardenAudio {
+ private context: AudioContext | null = null
+ private effects: GainNode | null = null
+ private droneGain: GainNode | null = null
+ private drones: OscillatorNode[] = []
+ private voices = new Set()
+ private sound = true
+ private ambience = false
+ private noteIndex = 0
+ private disposed = false
+
+ setSound(enabled: boolean): void {
+ this.sound = enabled
+ if (this.context && this.effects) {
+ this.effects.gain.setTargetAtTime(enabled ? 0.24 : 0, this.context.currentTime, 0.025)
+ }
+ }
+
+ setAmbience(enabled: boolean): void {
+ this.ambience = enabled
+ // Loading a saved preference may call this before any click. Defer that case.
+ if (enabled && typeof navigator !== 'undefined' && navigator.userActivation?.isActive) {
+ this.ensureContext()
+ }
+ this.updateAmbience()
+ }
+
+ play(event: GardenEvent): void {
+ if (!this.sound && !this.ambience) return
+ const context = this.ensureContext()
+ if (!context) return
+ this.updateAmbience()
+ if (!this.sound) return
+ const now = context.currentTime + 0.012
+ if (event === 'rotate') {
+ const notes = [293.66, 329.63, 369.99, 440, 493.88]
+ this.pluck(notes[this.noteIndex++ % notes.length], now, 0.65, 0.23)
+ } else if (event === 'hint') {
+ this.pluck(440, now, 0.95, 0.22)
+ this.pluck(659.25, now + 0.16, 1.1, 0.16)
+ } else if (event === 'complete') {
+ for (const [index, frequency] of [293.66, 369.99, 440, 587.33, 739.99].entries()) {
+ this.pluck(frequency, now + index * 0.14, 1.8, 0.21)
+ }
+ } else {
+ this.pluck(220, now, 0.7, 0.16)
+ this.pluck(293.66, now + 0.08, 0.8, 0.12)
+ }
+ }
+
+ private ensureContext(): AudioContext | null {
+ if (this.disposed || typeof window === 'undefined') return null
+ try {
+ if (!this.context) {
+ // Browser activation prevents preference restoration from causing autoplay.
+ if (typeof navigator !== 'undefined' && navigator.userActivation && !navigator.userActivation.isActive) return null
+ const Constructor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext
+ if (!Constructor) return null
+ this.context = new Constructor()
+ this.effects = this.context.createGain()
+ this.effects.gain.value = this.sound ? 0.24 : 0
+ this.effects.connect(this.context.destination)
+ }
+ if (this.context.state === 'suspended') void this.context.resume().catch(() => {})
+ return this.context
+ } catch {
+ return null
+ }
+ }
+
+ private pluck(frequency: number, start: number, duration: number, volume: number): void {
+ const context = this.context
+ if (!context || !this.effects) return
+ try {
+ // A quiet second partial adds the wooden, bell-like attack.
+ for (const [partial, strength] of [[1, 1], [2, 0.16]]) {
+ const oscillator = context.createOscillator()
+ const envelope = context.createGain()
+ oscillator.type = 'sine'
+ oscillator.frequency.value = frequency * partial
+ envelope.gain.setValueAtTime(0, start)
+ envelope.gain.linearRampToValueAtTime(volume * strength, start + 0.012)
+ envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration)
+ oscillator.connect(envelope)
+ envelope.connect(this.effects)
+ this.voices.add(oscillator)
+ oscillator.onended = () => {
+ oscillator.disconnect()
+ envelope.disconnect()
+ this.voices.delete(oscillator)
+ }
+ oscillator.start(start)
+ oscillator.stop(start + duration + 0.02)
+ }
+ } catch {
+ // Unsupported or closing audio hardware should never interrupt a move.
+ }
+ }
+
+ private updateAmbience(): void {
+ const context = this.context
+ if (!context || context.state === 'closed') return
+ try {
+ if (this.ambience && !this.droneGain) {
+ this.droneGain = context.createGain()
+ this.droneGain.gain.value = 0
+ this.droneGain.connect(context.destination)
+ for (const frequency of [110, 164.81, 220.25]) {
+ const oscillator = context.createOscillator()
+ oscillator.type = 'sine'
+ oscillator.frequency.value = frequency
+ oscillator.connect(this.droneGain)
+ oscillator.start()
+ this.drones.push(oscillator)
+ }
+ }
+ this.droneGain?.gain.setTargetAtTime(this.ambience ? 0.009 : 0, context.currentTime, 0.6)
+ } catch {
+ // Audio is an enhancement, never a game requirement.
+ }
+ }
+
+ dispose(): void {
+ this.disposed = true
+ for (const oscillator of [...this.voices, ...this.drones]) {
+ try { oscillator.stop() } catch { /* Already stopped. */ }
+ oscillator.disconnect()
+ }
+ this.voices.clear()
+ this.drones = []
+ this.effects?.disconnect()
+ this.droneGain?.disconnect()
+ if (this.context && this.context.state !== 'closed') void this.context.close().catch(() => {})
+ this.context = null
+ this.effects = null
+ this.droneGain = null
+ }
+}
diff --git a/projects/lumen-atelier-8f3c/src/game.ts b/projects/lumen-atelier-8f3c/src/game.ts
new file mode 100644
index 00000000..4f2c46b7
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/game.ts
@@ -0,0 +1,343 @@
+/** A deterministic, renderer-independent light-routing puzzle engine. */
+export type TileKind = 'source' | 'crystal' | 'straight' | 'elbow' | 'tee' | 'cross' | 'bridge' | 'gate' | 'portal' | 'rock'
+export interface Tile {
+ id: string
+ x: number
+ y: number
+ kind: TileKind
+ rotation: number
+ fixed?: boolean
+ pair?: string
+}
+export interface Level {
+ id: string
+ index: number
+ chapter: number
+ title: string
+ subtitle: string
+ story: string
+ size: number
+ tiles: Tile[]
+ solution: Record
+ par: number
+}
+export interface Chapter {
+ id: number
+ title: string
+ subtitle: string
+ color: string
+ description: string
+ mechanic: string
+}
+export const CHAPTERS: Chapter[] = [
+ { id: 0, title: 'The First Light', subtitle: 'Every garden begins with a spark.', color: '#f5c76b', description: 'Wake a forgotten garden, one small constellation at a time.', mechanic: 'Turn the stones to carry light from the sun to every crystal.' },
+ { id: 1, title: 'The Glasswood', subtitle: 'A single light can become many.', color: '#80cbb2', description: 'In the glasswood, even the smallest branch remembers the sun.', mechanic: 'Three-way stones split the light. Every crystal must awaken.' },
+ { id: 2, title: 'Silent Monuments', subtitle: 'Listen to what cannot be moved.', color: '#9cbbe8', description: 'The old keepers left their stones in place. Discover the paths between them.', mechanic: 'Stones with a lock cannot turn. Build your route around their position.' },
+ { id: 3, title: 'The Golden Current', subtitle: 'Some rivers flow only one way.', color: '#efad78', description: 'Follow the golden current through the ruins of the water garden.', mechanic: 'Arrow stones carry light in one direction. Light must enter behind the arrow.' },
+ { id: 4, title: 'The Woven Sky', subtitle: 'Two paths meet, but never mingle.', color: '#b7a1de', description: 'High above the garden, ribbons of light weave a quiet sky.', mechanic: 'Bridge stones keep the two crossing paths separate. Light goes straight through.' },
+ { id: 5, title: 'The Far Gardens', subtitle: 'Distance is only another doorway.', color: '#e6a5bd', description: 'Beyond the last arch, distant gardens share the same heart.', mechanic: 'Matching portals carry light between distant stones. Connect both ends.' },
+]
+
+const STEPS = [[0, -1], [1, 0], [0, 1], [-1, 0]] as const
+const BASE_PORTS: Record = {
+ source: [1], crystal: [3], straight: [0, 2], elbow: [0, 1], tee: [0, 1, 3],
+ cross: [0, 1, 2, 3], bridge: [0, 1, 2, 3], gate: [0, 2], portal: [0], rock: [],
+}
+const mod = (n: number, m = 4) => ((n % m) + m) % m
+const key = (x: number, y: number) => `${x},${y}`
+export const ports = (tile: Tile): number[] => BASE_PORTS[tile.kind].map((port) => mod(port + tile.rotation))
+export function rotateTile(tile: Tile, delta = 1): Tile {
+ return tile.fixed || tile.kind === 'rock' || tile.kind === 'source' || tile.kind === 'crystal'
+ ? tile : { ...tile, rotation: mod(tile.rotation + delta) }
+}
+
+export function simulate(tiles: Tile[]): {
+ lit: Set
+ litPorts: Map>
+ litCrystals: number
+ totalCrystals: number
+ solved: boolean
+} {
+ const cells = new Map(tiles.map((tile) => [key(tile.x, tile.y), tile]))
+ const pairs = new Map()
+ for (const tile of tiles) {
+ if (tile.kind === 'portal' && tile.pair) pairs.set(tile.pair, [...(pairs.get(tile.pair) ?? []), tile])
+ }
+ const lit = new Set()
+ const litPorts = new Map>()
+ // Entry -1 starts a source; entry 4 arrives through a portal's inner doorway.
+ const queue: { tile: Tile; entry: number }[] = tiles.filter((tile) => tile.kind === 'source').map((tile) => ({ tile, entry: -1 }))
+ const visited = new Set()
+ for (let cursor = 0; cursor < queue.length; cursor++) {
+ const { tile, entry } = queue[cursor]
+ const state = `${tile.id}:${entry}`
+ if (visited.has(state) || tile.kind === 'rock') continue
+ visited.add(state)
+ const sockets = ports(tile)
+ if (entry >= 0 && entry < 4 && !sockets.includes(entry)) continue
+ if (tile.kind === 'gate' && entry !== mod(2 + tile.rotation)) continue
+ lit.add(tile.id)
+ const illuminated = litPorts.get(tile.id) ?? new Set()
+ litPorts.set(tile.id, illuminated)
+ if (entry >= 0 && entry < 4) illuminated.add(entry)
+ let exits = sockets
+ if (tile.kind === 'crystal') exits = []
+ if (tile.kind === 'gate') exits = [mod(tile.rotation)]
+ if (tile.kind === 'bridge') exits = entry < 4 && entry >= 0 ? [mod(entry + 2)] : []
+ if (tile.kind === 'portal' && entry !== 4) {
+ exits = []
+ const partners = tile.pair ? pairs.get(tile.pair) : undefined
+ if (partners?.length === 2) {
+ const partner = partners.find((candidate) => candidate.id !== tile.id)
+ if (partner) queue.push({ tile: partner, entry: 4 })
+ }
+ }
+ for (const exit of exits) {
+ illuminated.add(exit)
+ const [dx, dy] = STEPS[exit]
+ const next = cells.get(key(tile.x + dx, tile.y + dy))
+ if (next && ports(next).includes(mod(exit + 2))) queue.push({ tile: next, entry: mod(exit + 2) })
+ }
+ }
+ const crystals = tiles.filter((tile) => tile.kind === 'crystal')
+ const litCrystals = crystals.filter((tile) => lit.has(tile.id)).length
+ return { lit, litPorts, litCrystals, totalCrystals: crystals.length, solved: crystals.length > 0 && litCrystals === crystals.length }
+}
+
+function equivalent(tile: Tile, rotation: number): boolean {
+ if (tile.kind === 'cross' || tile.kind === 'bridge' || tile.kind === 'rock') return true
+ return mod(tile.rotation - rotation, tile.kind === 'straight' ? 2 : 4) === 0
+}
+export function getHint(level: Level, tiles: Tile[]): { tileId: string; rotation: number } | null {
+ const state = simulate(tiles)
+ if (state.solved) return null
+ const candidates = tiles.filter((tile) => !tile.fixed && level.solution[tile.id] !== undefined && !equivalent(tile, level.solution[tile.id]))
+ // Prefer the illuminated frontier, so a hint usually makes visible progress.
+ const frontier = candidates.find((tile) => state.lit.has(tile.id) || STEPS.some(([dx, dy]) => {
+ const neighbor = tiles.find((other) => other.x === tile.x + dx && other.y === tile.y + dy)
+ return neighbor && state.lit.has(neighbor.id)
+ }))
+ const tile = frontier ?? candidates[0]
+ return tile ? { tileId: tile.id, rotation: level.solution[tile.id] } : null
+}
+
+function random(seed: number): () => number {
+ let state = seed >>> 0
+ return () => {
+ state += 0x6d2b79f5
+ let n = Math.imul(state ^ state >>> 15, state | 1)
+ n ^= n + Math.imul(n ^ n >>> 7, n | 61)
+ return ((n ^ n >>> 14) >>> 0) / 4294967296
+ }
+}
+function shuffled(values: T[], rng: () => number): T[] {
+ const result = [...values]
+ for (let i = result.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1))
+ ;[result[i], result[j]] = [result[j], result[i]]
+ }
+ return result
+}
+type Point = { x: number; y: number }
+type Node = Point & { neighbors: Set; parent?: string; special?: 'source' | 'bridge' | 'portal'; pair?: string }
+function direction(a: Point, b: Point): number {
+ return a.x === b.x ? (b.y < a.y ? 0 : 2) : (b.x > a.x ? 1 : 3)
+}
+function pathFrom(start: Point, length: number, size: number, occupied: Set, rng: () => number, allowed: (p: Point) => boolean = () => true): Point[] {
+ const path = [start]
+ const used = new Set(occupied)
+ used.add(key(start.x, start.y))
+ let attempts = 0
+ const search = (): boolean => {
+ if (path.length >= length) return true
+ if (++attempts > 18000) return false
+ const current = path[path.length - 1]
+ for (const [dx, dy] of shuffled([...STEPS], rng)) {
+ const next = { x: current.x + dx, y: current.y + dy }
+ const nextKey = key(next.x, next.y)
+ if (next.x < 0 || next.y < 0 || next.x >= size || next.y >= size || used.has(nextKey) || !allowed(next)) continue
+ path.push(next)
+ used.add(nextKey)
+ if (search()) return true
+ path.pop()
+ used.delete(nextKey)
+ }
+ return false
+ }
+ if (search()) return path
+ // A shorter path is preferable to a costly unbounded generator search.
+ return length > 3 ? pathFrom(start, length - 1, size, occupied, rng, allowed) : []
+}
+function addPath(nodes: Map, path: Point[]): void {
+ for (let i = 0; i < path.length; i++) {
+ const point = path[i]
+ const id = key(point.x, point.y)
+ if (!nodes.has(id)) nodes.set(id, { ...point, neighbors: new Set(), ...(i > 0 ? { parent: key(path[i - 1].x, path[i - 1].y) } : {}) })
+ if (i > 0) {
+ const previous = key(path[i - 1].x, path[i - 1].y)
+ nodes.get(id)!.neighbors.add(previous)
+ nodes.get(previous)!.neighbors.add(id)
+ }
+ }
+}
+function branch(nodes: Map, size: number, length: number, rng: () => number, allowed: (p: Point) => boolean = () => true): boolean {
+ const occupied = new Set(nodes.keys())
+ for (const node of shuffled([...nodes.values()].filter((n) => !n.special && n.neighbors.size === 2 && allowed(n)), rng)) {
+ const path = pathFrom(node, length, size, occupied, rng, allowed)
+ if (path.length >= 3) {
+ addPath(nodes, path)
+ return true
+ }
+ }
+ return false
+}
+function rotationFor(kind: TileKind, sockets: number[]): number {
+ for (let rotation = 0; rotation < 4; rotation++) {
+ const candidate = BASE_PORTS[kind].map((port) => mod(port + rotation))
+ if (candidate.length === sockets.length && candidate.every((port) => sockets.includes(port))) return rotation
+ }
+ throw new Error(`Cannot orient ${kind} with sockets ${sockets.join(',')}`)
+}
+function transform(point: Point, size: number, turns: number, mirror: boolean): Point {
+ let { x, y } = point
+ if (mirror) x = size - 1 - x
+ for (let i = 0; i < turns; i++) [x, y] = [size - 1 - y, x]
+ return { x, y }
+}
+function createBoard(seed: number, chapter: number, local: number): Pick {
+ const rng = random(seed)
+ const size = chapter === 0 ? 5 : chapter === 1 && local < 4 ? 5 : chapter < 4 ? 6 : 7
+ const nodes = new Map()
+ if (chapter === 4) {
+ // A continuous ribbon visits the central bridge twice on independent axes.
+ const ribbon = [[0, 3], [1, 3], [2, 3], [3, 3], [4, 3], [5, 3], [5, 2], [5, 1], [4, 1], [3, 1], [3, 2], [3, 3], [3, 4], [3, 5], [2, 5], [1, 5], [1, 6], [2, 6], [3, 6], [4, 6], [5, 6], [6, 6]].map(([x, y]) => ({ x, y }))
+ addPath(nodes, ribbon)
+ nodes.get('0,3')!.special = 'source'
+ nodes.get('3,3')!.special = 'bridge'
+ branch(nodes, size, 4 + local % 3, rng)
+ if (local > 2) branch(nodes, size, 4, rng)
+ } else if (chapter === 5) {
+ const left = pathFrom({ x: 0, y: 1 + Math.floor(rng() * 5) }, 10 + local % 4, size, new Set(), rng, (p) => p.x <= 2)
+ const right = pathFrom({ x: 4, y: 1 + Math.floor(rng() * 5) }, 10 + local % 4, size, new Set(), rng, (p) => p.x >= 4)
+ addPath(nodes, left)
+ addPath(nodes, right)
+ nodes.get(key(left[0].x, left[0].y))!.special = 'source'
+ const portalA = nodes.get(key(left.at(-1)!.x, left.at(-1)!.y))!
+ const portalB = nodes.get(key(right[0].x, right[0].y))!
+ portalA.special = 'portal'
+ portalB.special = 'portal'
+ portalA.pair = portalB.pair = 'a'
+ branch(nodes, size, 4, rng, (p) => p.x >= 4)
+ if (local > 1) branch(nodes, size, 4, rng, (p) => p.x <= 2)
+ } else {
+ const length = chapter === 0 ? 7 + local * 2 : 12 + local
+ const path = pathFrom({ x: 0, y: 1 + Math.floor(rng() * (size - 2)) }, length, size, new Set(), rng)
+ addPath(nodes, path)
+ nodes.get(key(path[0].x, path[0].y))!.special = 'source'
+ if (chapter > 0) {
+ branch(nodes, size, 4 + local % 3, rng)
+ if (chapter > 1 || local > 3) branch(nodes, size, 4, rng)
+ }
+ }
+
+ const turns = Math.floor(rng() * 4)
+ const mirror = rng() > .5
+ const tiles: Tile[] = []
+ const gateCandidates: { tile: Tile; input: number }[] = []
+ for (const node of nodes.values()) {
+ const point = transform(node, size, turns, mirror)
+ const sockets = [...node.neighbors].map((neighbor) => direction(point, transform(nodes.get(neighbor)!, size, turns, mirror)))
+ const kind: TileKind = node.special ?? (sockets.length === 1 ? 'crystal' : sockets.length === 3 ? 'tee' : sockets.length === 4 ? 'cross' : mod(sockets[0] - sockets[1], 2) === 0 ? 'straight' : 'elbow')
+ const tile: Tile = {
+ id: `cell-${point.x}-${point.y}`, ...point, kind,
+ rotation: rotationFor(kind, sockets),
+ fixed: kind === 'source' || kind === 'crystal' || kind === 'bridge' || kind === 'cross',
+ ...(node.pair ? { pair: node.pair } : {}),
+ }
+ if (kind === 'straight' && node.parent) {
+ gateCandidates.push({ tile, input: direction(point, transform(nodes.get(node.parent)!, size, turns, mirror)) })
+ }
+ tiles.push(tile)
+ }
+ if (chapter >= 3) {
+ const count = Math.min(gateCandidates.length, chapter === 3 ? 1 + Math.floor(local / 3) : 2)
+ for (const { tile, input } of shuffled(gateCandidates, rng).slice(0, count)) {
+ tile.kind = 'gate'
+ tile.rotation = mod(input - 2)
+ }
+ }
+ if (chapter >= 2) {
+ const candidates = shuffled(tiles.filter((tile) => !tile.fixed && tile.kind !== 'gate' && tile.kind !== 'portal'), rng)
+ for (const tile of candidates.slice(0, 2 + Math.floor(local / 3))) tile.fixed = true
+ }
+ const routeIds = new Set(tiles.map((tile) => tile.id))
+ const occupied = new Set(tiles.map((tile) => key(tile.x, tile.y)))
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
+ if (occupied.has(key(x, y))) continue
+ const decoy = chapter > 0 && rng() < .18
+ tiles.push({ id: `cell-${x}-${y}`, x, y, kind: decoy ? rng() > .5 ? 'elbow' : 'straight' : 'rock', rotation: Math.floor(rng() * 4), fixed: !decoy })
+ }
+ tiles.sort((a, b) => a.y - b.y || a.x - b.x)
+ const solution = Object.fromEntries(tiles.map((tile) => [tile.id, tile.rotation]))
+ if (!simulate(tiles).solved) throw new Error(`Invalid generated solution for seed ${seed}, chapter ${chapter}`)
+ const movable = tiles.filter((tile) => !tile.fixed && routeIds.has(tile.id))
+ for (let attempt = 0; attempt < 30; attempt++) {
+ for (const tile of movable) tile.rotation = Math.floor(rng() * 4)
+ if (!simulate(tiles).solved) break
+ }
+ // Deterministically break the first route in the extremely unlikely all-solved shuffle.
+ if (simulate(tiles).solved) {
+ for (const tile of movable) {
+ tile.rotation = mod(solution[tile.id] + 1)
+ if (!simulate(tiles).solved) break
+ }
+ }
+ const par = tiles.reduce((moves, tile) => moves + (tile.fixed ? 0 : mod(solution[tile.id] - tile.rotation, tile.kind === 'straight' ? 2 : 4)), 0)
+ return { size, tiles, solution, par }
+}
+
+const TITLES = [
+ ['A Small Awakening', 'The Winding Path', 'A Quiet Corner', 'Sun Through Stone', 'Little Constellations', 'The Patient Bloom', 'A Longer Way Home', 'The Garden Remembers'],
+ ['Where Light Divides', 'Three Wishes', 'The Glass Orchard', 'Branches of Gold', 'A Shared Sunrise', 'The Crystal Grove', 'Many Small Miracles', 'All Things Connected'],
+ ['The First Monument', 'Written in Stone', 'The Keepers’ Path', 'A Stillness Between', 'The Old Arrangement', 'A Promise Unmoved', 'Reading the Ruins', 'The Silent Choir'],
+ ['Follow the Current', 'The River Knows', 'Against the Silence', 'Golden Tributaries', 'A Turn of Fortune', 'The Water Clock', 'Every River Returns', 'The Sunward Tide'],
+ ['Threads of Heaven', 'The Crossing', 'Ribbons in the Air', 'Above and Below', 'The Weaver’s Walk', 'Parallel Wishes', 'A Tapestry of Stars', 'The Sky Unfolds'],
+ ['The First Doorway', 'Somewhere, a Garden', 'Across the Quiet', 'A Distant Answer', 'Two Halves of Dawn', 'The Other Side', 'A World Reawakened', 'Where the Light Lives'],
+]
+const STORIES = [
+ ['You find a sleeping crystal at the edge of the garden. Somewhere beneath the moss, an old path is waiting for the sun.', 'The keepers believed that a single lit window could guide someone home. Tonight, you are the keeper.'],
+ ['Leaves of glass chime in the morning air. Their roots share the same light; no branch needs to be left in shadow.', 'You begin to understand the garden: the more light it gives away, the more beautiful it becomes.'],
+ ['Some stones have stood here for a thousand years. They cannot turn, but their silence is a map.', 'A keeper’s note is carved beneath the ivy: “Start with what remains. The rest will find its place.”'],
+ ['Once, golden rivers flowed through these channels. Find their direction, and the water garden will sing again.', 'The current has a memory. Give it a clear way forward, and it will carry the morning with it.'],
+ ['The old weavers learned to let two journeys cross without changing either one. Their sky bridges still hold.', 'Above the garden, the pathways trace a constellation no one has seen for centuries. You are nearly there.'],
+ ['A doorway hums softly. On its other side, another garden has been waiting for the very same sunrise.', 'The world was never a collection of separate gardens. It was one garden, waiting for someone to connect its light.'],
+]
+
+export const LEVELS: Level[] = Array.from({ length: 48 }, (_, index) => {
+ const chapter = Math.floor(index / 8)
+ const local = index % 8
+ return {
+ id: `level-${String(index + 1).padStart(2, '0')}`, index, chapter,
+ title: TITLES[chapter][local], subtitle: CHAPTERS[chapter].subtitle,
+ story: STORIES[chapter][local > 3 ? 1 : 0],
+ ...createBoard(0x1a2b3000 + index * 7919, chapter, local),
+ }
+})
+export function generateLevel(seed: number, chapter = 5): Level {
+ const normalizedSeed = seed >>> 0
+ const normalizedChapter = Math.max(0, Math.min(5, Number.isFinite(chapter) ? Math.floor(chapter) : 5))
+ const local = normalizedSeed % 8
+ return {
+ id: `generated-${normalizedSeed}-${normalizedChapter}`, index: -1, chapter: normalizedChapter,
+ title: `Garden ${normalizedSeed.toString(36).toUpperCase()}`,
+ subtitle: 'A new path, just for this moment.',
+ story: 'Every seed holds a garden no one has walked before. Take your time. Find its light.',
+ ...createBoard(normalizedSeed, normalizedChapter, local),
+ }
+}
+export function dailyLevel(date = new Date().toISOString().slice(0, 10)): Level {
+ let seed = 2166136261
+ for (const character of date) seed = Math.imul(seed ^ character.charCodeAt(0), 16777619) >>> 0
+ const level = generateLevel(seed, 3 + seed % 3)
+ return { ...level, id: `daily-${date}`, title: 'Today’s Garden', subtitle: 'One shared garden. A new beginning each day.', story: `A garden for ${date}. The same stones wait for every traveler today. Find your own way through them.` }
+}
diff --git a/projects/lumen-atelier-8f3c/src/index.css b/projects/lumen-atelier-8f3c/src/index.css
new file mode 100644
index 00000000..655c1dcc
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/index.css
@@ -0,0 +1,27 @@
+@import '@fontsource/dm-sans/latin-400.css';
+@import '@fontsource/dm-sans/latin-500.css';
+@import '@fontsource/dm-sans/latin-600.css';
+@import '@fontsource/dm-sans/latin-700.css';
+@import '@fontsource/cormorant-garamond/latin-400.css';
+@import '@fontsource/cormorant-garamond/latin-500.css';
+@import '@fontsource/cormorant-garamond/latin-500-italic.css';
+@import '@fontsource/cormorant-garamond/latin-600.css';
+
+:root{font-family:'DM Sans',sans-serif;color:#243e36;background:#f0f3ed;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-weight:400;font-size:14px;--paper:#f0f3ed;--ink:#243e36;--muted:#7c8a7c;--line:#dce2d6;--gold:#d6ad67;--serif:'Cormorant Garamond',Georgia,serif}
+*{box-sizing:border-box}
+body{margin:0;min-width:360px}
+button,input,select,textarea{font:inherit}
+button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}
+button{cursor:pointer}
+button:disabled{cursor:default}
+button,a{touch-action:manipulation}
+button{color:inherit}
+a{color:inherit}
+button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #b58132;outline-offset:4px}
+button{border:0}
+::selection{color:#fff;background:#497060}
+button svg{flex-shrink:0}
+button{font-weight:500}
+button:disabled{opacity:.4}
+.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
+@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
diff --git a/projects/lumen-atelier-8f3c/src/main.tsx b/projects/lumen-atelier-8f3c/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/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/lumen-atelier-8f3c/src/progress.ts b/projects/lumen-atelier-8f3c/src/progress.ts
new file mode 100644
index 00000000..8da3b8bd
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/src/progress.ts
@@ -0,0 +1,92 @@
+/** Portable, version-independent player progress. Never trusts stored JSON. */
+export type Completion = { moves: number; stars: number; hints: number; date: string }
+
+export type Progress = {
+ completed: Record
+ currentLevel: string
+ sound: boolean
+ ambience: boolean
+ reduceMotion: boolean
+ highContrast: boolean
+}
+
+export const STORAGE_KEY = 'lumen-lost-gardens-v1'
+export const DEFAULT_PROGRESS: Progress = {
+ completed: {},
+ currentLevel: 'level-01',
+ sound: true,
+ ambience: false,
+ reduceMotion: false,
+ highContrast: false,
+}
+
+function freshProgress(): Progress {
+ return { ...DEFAULT_PROGRESS, completed: {} }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function validId(value: unknown): value is string {
+ return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value)
+ && value !== '__proto__' && value !== 'constructor' && value !== 'prototype'
+}
+
+function integer(value: unknown, maximum: number): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= maximum
+}
+
+function sanitize(value: unknown): Progress | null {
+ if (!isRecord(value) || !isRecord(value.completed) || !validId(value.currentLevel)) return null
+ const result = freshProgress()
+ result.currentLevel = value.currentLevel
+ for (const key of ['sound', 'ambience', 'reduceMotion', 'highContrast'] as const) {
+ if (typeof value[key] === 'boolean') result[key] = value[key]
+ }
+ for (const [id, completion] of Object.entries(value.completed).slice(0, 20000)) {
+ if (!validId(id) || !isRecord(completion)) continue
+ const { moves, stars, hints, date } = completion
+ if (!integer(moves, 1000000) || !integer(stars, 3) || !integer(hints, 10000)) continue
+ if (typeof date !== 'string' || date.length > 40 || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(date)
+ || !Number.isFinite(Date.parse(date))) continue
+ const calendarDay = new Date(`${date.slice(0, 10)}T00:00:00.000Z`)
+ if (!Number.isFinite(calendarDay.getTime()) || calendarDay.toISOString().slice(0, 10) !== date.slice(0, 10)) continue
+ result.completed[id] = { moves, stars, hints, date }
+ }
+ return result
+}
+
+/** Invalid, unavailable, or full browser storage never prevents playing. */
+export function loadProgress(): Progress {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ return stored ? importProgress(stored) ?? freshProgress() : freshProgress()
+ } catch {
+ return freshProgress()
+ }
+}
+
+export function saveProgress(progress: Progress): boolean {
+ try {
+ const sanitized = sanitize(progress)
+ if (!sanitized) return false
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized))
+ return true
+ } catch {
+ return false
+ }
+}
+
+export function exportProgress(progress: Progress): string {
+ return JSON.stringify(sanitize(progress) ?? freshProgress(), null, 2)
+}
+
+export function importProgress(json: string): Progress | null {
+ try {
+ if (json.length > 5000000) return null
+ return sanitize(JSON.parse(json) as unknown)
+ } catch {
+ return null
+ }
+}
diff --git a/projects/lumen-atelier-8f3c/tests/game.test.mjs b/projects/lumen-atelier-8f3c/tests/game.test.mjs
new file mode 100644
index 00000000..ea23ab4c
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/tests/game.test.mjs
@@ -0,0 +1,132 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { CHAPTERS, LEVELS, ports, simulate, rotateTile, getHint, generateLevel, dailyLevel } from '../src/game.ts'
+
+const tile = (id, x, y, kind, rotation = 0, extra = {}) => ({ id, x, y, kind, rotation, ...extra })
+const solvedTiles = (level) => level.tiles.map((stone) => ({ ...stone, rotation: level.solution[stone.id] }))
+
+test('48 complete campaign boards have valid solutions, stable IDs, and unsolved starts', () => {
+ assert.equal(LEVELS.length, 48)
+ assert.equal(CHAPTERS.length, 6)
+ assert.equal(new Set(LEVELS.map((level) => level.id)).size, 48)
+ for (const level of LEVELS) {
+ assert.equal(level.chapter, Math.floor(level.index / 8))
+ assert.equal(level.tiles.length, level.size ** 2)
+ assert.equal(new Set(level.tiles.map((stone) => `${stone.x},${stone.y}`)).size, level.size ** 2)
+ assert.equal(simulate(solvedTiles(level)).solved, true, `${level.id} is solvable`)
+ assert.equal(simulate(level.tiles).solved, false, `${level.id} starts unfinished`)
+ assert.ok(level.par > 0)
+ for (const stone of level.tiles) {
+ assert.ok(stone.x >= 0 && stone.x < level.size && stone.y >= 0 && stone.y < level.size)
+ if (stone.fixed) assert.equal(stone.rotation, level.solution[stone.id])
+ if (stone.kind === 'crystal' || stone.kind === 'source') assert.equal(stone.fixed, true)
+ }
+ }
+})
+
+test('every chapter actually introduces its promised mechanic', () => {
+ for (const level of LEVELS) {
+ const kinds = level.tiles.map((stone) => stone.kind)
+ if (level.chapter > 0) assert.ok(kinds.includes('tee'))
+ if (level.chapter >= 2) assert.ok(level.tiles.some((stone) => stone.fixed && ['straight', 'elbow', 'tee'].includes(stone.kind)))
+ if (level.chapter >= 3) assert.ok(kinds.includes('gate'))
+ if (level.chapter === 4) assert.ok(kinds.includes('bridge'))
+ if (level.chapter === 5) assert.equal(kinds.filter((kind) => kind === 'portal').length, 2)
+ }
+})
+
+test('ports rotate correctly and locked tiles are immutable', () => {
+ assert.deepEqual(ports(tile('a', 0, 0, 'elbow')), [0, 1])
+ assert.deepEqual(ports(tile('a', 0, 0, 'elbow', 3)), [3, 0])
+ assert.deepEqual(ports(tile('a', 0, 0, 'straight', 1)), [1, 3])
+ assert.deepEqual(ports(tile('a', 0, 0, 'rock')), [])
+ const fixed = tile('a', 0, 0, 'tee', 1, { fixed: true })
+ assert.equal(rotateTile(fixed), fixed)
+ assert.equal(rotateTile(tile('a', 0, 0, 'elbow'), -1).rotation, 3)
+ assert.equal(rotateTile(tile('a', 0, 0, 'elbow', 3)).rotation, 0)
+})
+
+test('light requires matching ports, and terminal crystals never transmit', () => {
+ const source = tile('s', 0, 0, 'source')
+ const crystal = tile('c', 2, 0, 'crystal')
+ assert.equal(simulate([source, tile('p', 1, 0, 'straight', 1), crystal]).solved, true)
+ assert.equal(simulate([source, tile('p', 1, 0, 'straight', 0), crystal]).solved, false)
+ assert.equal(simulate([source, tile('p', 1, 0, 'crystal'), crystal]).litCrystals, 1)
+ assert.equal(simulate([source]).solved, false)
+})
+
+test('directional gates accept light only from behind their arrow', () => {
+ const source = tile('s', 0, 0, 'source')
+ const crystal = tile('c', 2, 0, 'crystal')
+ const forward = simulate([source, tile('g', 1, 0, 'gate', 1), crystal])
+ assert.equal(forward.solved, true)
+ assert.deepEqual([...forward.litPorts.get('g')].sort(), [1, 3])
+ const backward = simulate([source, tile('g', 1, 0, 'gate', 3), crystal])
+ assert.equal(backward.solved, false)
+ assert.equal(backward.lit.has('g'), false)
+})
+
+test('bridges preserve isolated channels while crosses split light', () => {
+ const stones = [tile('s', 0, 1, 'source'), tile('b', 1, 1, 'bridge'), tile('east', 2, 1, 'crystal'), tile('south', 1, 2, 'crystal', 1)]
+ const bridge = simulate(stones)
+ assert.equal(bridge.litCrystals, 1)
+ assert.equal(bridge.lit.has('south'), false)
+ assert.deepEqual([...bridge.litPorts.get('b')].sort(), [1, 3])
+ assert.equal(simulate(stones.map((stone) => stone.id === 'b' ? { ...stone, kind: 'cross' } : stone)).solved, true)
+ const both = simulate([...stones, tile('s2', 1, 0, 'source', 1)])
+ assert.equal(both.solved, true)
+ assert.equal(both.litPorts.get('b').size, 4)
+})
+
+test('paired portals transport light, with directional sockets on both ends', () => {
+ const source = tile('s', 0, 0, 'source')
+ const a = tile('a', 1, 0, 'portal', 3, { pair: 'moon' })
+ const b = tile('b', 4, 2, 'portal', 1, { pair: 'moon' })
+ const crystal = tile('c', 5, 2, 'crystal')
+ assert.equal(simulate([source, a, b, crystal]).solved, true)
+ assert.equal(simulate([source, { ...a, rotation: 1 }, b, crystal]).solved, false)
+ assert.equal(simulate([source, a, { ...b, rotation: 0 }, crystal]).solved, false)
+ assert.equal(simulate([source, a, { ...b, pair: 'sun' }, crystal]).solved, false)
+ assert.equal(simulate([source, a, crystal]).solved, false)
+})
+
+test('cycles terminate and illuminate connected targets', () => {
+ const stones = [
+ tile('s', 0, 1, 'source'), tile('a', 1, 1, 'tee', 0),
+ tile('b', 1, 0, 'elbow', 1), tile('c', 2, 0, 'elbow', 2),
+ tile('d', 2, 1, 'tee', 0), tile('crystal', 3, 1, 'crystal'),
+ ]
+ assert.equal(simulate(stones).solved, true)
+})
+
+test('repeated hints solve every campaign board without changing locks', () => {
+ for (const level of LEVELS) {
+ let stones = level.tiles.map((stone) => ({ ...stone }))
+ let hints = 0
+ while (!simulate(stones).solved && hints <= stones.length) {
+ const hint = getHint(level, stones)
+ assert.ok(hint, `Hint exists for ${level.id}`)
+ assert.equal(stones.find((stone) => stone.id === hint.tileId).fixed, false)
+ stones = stones.map((stone) => stone.id === hint.tileId ? { ...stone, rotation: hint.rotation } : stone)
+ hints++
+ }
+ assert.equal(simulate(stones).solved, true, `Hints solve ${level.id}`)
+ assert.equal(getHint(level, stones), null)
+ }
+})
+
+test('daily boards are deterministic across calls and vary by date', () => {
+ assert.deepEqual(dailyLevel('2026-09-24'), dailyLevel('2026-09-24'))
+ assert.equal(dailyLevel('2026-09-24').id, 'daily-2026-09-24')
+ assert.notDeepEqual(dailyLevel('2026-09-24').tiles, dailyLevel('2026-09-25').tiles)
+ assert.equal(simulate(solvedTiles(dailyLevel('2026-09-24'))).solved, true)
+})
+
+test('600 seeded endless boards are reproducible, solvable, and initially unsolved', () => {
+ for (let seed = 0; seed < 600; seed++) {
+ const level = generateLevel(seed * 7919, seed % 6)
+ assert.equal(simulate(solvedTiles(level)).solved, true, `Seed ${seed}`)
+ assert.equal(simulate(level.tiles).solved, false, `Seed ${seed}`)
+ if (seed < 12) assert.deepEqual(level, generateLevel(seed * 7919, seed % 6))
+ }
+})
diff --git a/projects/lumen-atelier-8f3c/tests/gameplay.spec.ts b/projects/lumen-atelier-8f3c/tests/gameplay.spec.ts
new file mode 100644
index 00000000..f2fdbdc2
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/tests/gameplay.spec.ts
@@ -0,0 +1,292 @@
+import { expect, test } from '@playwright/test'
+import type { Page } from '@playwright/test'
+import { LEVELS, dailyLevel, generateLevel } from '../src/game'
+import type { Level } from '../src/game'
+import { DEFAULT_PROGRESS, STORAGE_KEY } from '../src/progress'
+
+const errors = new WeakMap()
+test.beforeEach(async ({ page }) => {
+ errors.set(page, [])
+ page.on('pageerror', error => errors.get(page)!.push(error.message))
+})
+test.afterEach(async ({ page }) => {
+ expect(errors.get(page), 'No uncaught browser errors').toEqual([])
+})
+
+async function openLevel(page: Page, level: Level, route = `play/${level.id}`) {
+ await page.goto(`/#/${route}`)
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(level.title)
+ await expect(page.locator('.garden-board')).toBeVisible()
+}
+
+/** Exercise the real SVG pointer targets; never set game state to a winning board. */
+async function solveWithClicks(page: Page, level: Level) {
+ for (const stone of level.tiles) {
+ if (await page.locator('.garden-complete').count()) break
+ if (stone.fixed) continue
+ const period = stone.kind === 'straight' ? 2 : 4
+ const clicks = ((level.solution[stone.id] - stone.rotation) % period + period) % period
+ for (let click = 0; click < clicks; click++) {
+ if (await page.locator('.garden-complete').count()) break
+ await page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).click()
+ }
+ }
+ await expect(page.getByRole('heading', { name: 'A garden, reborn.', exact: true })).toBeVisible()
+ await expect(page.locator('.garden-board [role="button"]').first()).toHaveAttribute('aria-disabled', 'true')
+}
+
+async function savedProgress(page: Page) {
+ return page.evaluate(key => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEY)
+}
+
+test('the first garden completes through clicks, awards blooms, and survives a reload', async ({ page }, testInfo) => {
+ await openLevel(page, LEVELS[0])
+ await page.screenshot({ path: testInfo.outputPath('first-garden-desktop.png'), fullPage: true })
+ await solveWithClicks(page, LEVELS[0])
+ await expect.poll(async () => (await savedProgress(page)).completed['level-01']?.stars).toBe(3)
+ await page.reload()
+ await expect(page.getByRole('heading', { name: 'A garden, reborn.', exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Garden 1: A Small Awakening, restored', exact: true })).toBeVisible()
+ await page.getByRole('button', { name: 'Next garden', exact: true }).click()
+ await expect(page).toHaveURL(/#\/play\/level-02$/)
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(LEVELS[1].title)
+ await expect(page.getByTestId('move-count')).toHaveText('00')
+})
+
+for (const [index, mechanic] of [[24, 'one-way gates'], [32, 'isolated bridges'], [40, 'paired portals']] as const) {
+ test(`a complete ${mechanic} garden works through actual pointer clicks`, async ({ page }, testInfo) => {
+ const level = LEVELS[index]
+ await openLevel(page, level)
+ await solveWithClicks(page, level)
+ await expect.poll(async () => Boolean((await savedProgress(page)).completed[level.id])).toBe(true)
+ await page.screenshot({ path: testInfo.outputPath(`completed-${index + 1}.png`), fullPage: true })
+ })
+}
+
+test('undo, reverse rotation, keyboard controls, restart, and two-step hints work', async ({ page }) => {
+ await openLevel(page, LEVELS[0])
+ const stone = LEVELS[0].tiles.find(item => !item.fixed)!
+ const target = page.locator(`[data-tile-id="${stone.id}"] .tile-hit`)
+ const initialLabel = await target.getAttribute('aria-label')
+ await expect(page.getByRole('button', { name: /^Undo/ })).toBeDisabled()
+ await target.click()
+ await expect(page.getByTestId('move-count')).toHaveText('01')
+ await target.click({ modifiers: ['Shift'] })
+ await expect(page.getByTestId('move-count')).toHaveText('02')
+ await expect(target).toHaveAttribute('aria-label', initialLabel!)
+ await page.getByRole('button', { name: /^Undo/ }).click()
+ await expect(page.getByTestId('move-count')).toHaveText('01')
+ await page.keyboard.press('z')
+ await expect(page.getByTestId('move-count')).toHaveText('00')
+ await expect(target).toHaveAttribute('aria-label', initialLabel!)
+ await target.focus()
+ await page.keyboard.press('Enter')
+ await expect(page.getByTestId('move-count')).toHaveText('01')
+ await page.keyboard.press('r')
+ await expect(page.getByTestId('move-count')).toHaveText('00')
+ await expect(page.getByRole('button', { name: /^Undo/ })).toBeDisabled()
+ await page.getByRole('button', { name: /A gentle nudge/ }).click()
+ await expect(page.locator('.garden-tile--hint')).toHaveCount(1)
+ await expect(page.getByTestId('move-count')).toHaveText('00')
+ await page.getByRole('button', { name: /Turn this stone/ }).click()
+ await expect(page.getByTestId('move-count')).not.toHaveText('00')
+ const moves = await page.getByTestId('move-count').textContent()
+ await page.reload()
+ await expect(page.getByTestId('move-count')).toHaveText(moves!)
+ await page.getByRole('button', { name: /^Restart/ }).click()
+ await expect(page.getByTestId('move-count')).toHaveText('00')
+})
+
+test('settings persist, invalid imports are harmless, and backups merge existing progress', async ({ page }) => {
+ await openLevel(page, LEVELS[0])
+ await solveWithClicks(page, LEVELS[0])
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ const dialog = page.getByRole('dialog')
+ await dialog.getByRole('switch', { name: 'Reduce motion' }).click()
+ await dialog.getByRole('switch', { name: 'Stronger contrast' }).click()
+ await dialog.getByRole('switch', { name: 'Sound effects' }).click()
+ await dialog.getByLabel('Paste a progress backup').fill('{"broken":true}')
+ await dialog.getByRole('button', { name: 'Import and keep existing progress' }).click()
+ await expect(page.locator('.toast')).toContainText('That backup could not be read')
+ const before = await savedProgress(page)
+ expect(before.completed['level-01'].stars).toBe(3)
+ const imported = { ...DEFAULT_PROGRESS, completed: {
+ 'level-01': { moves: 500, stars: 1, hints: 20, date: '2026-09-24' },
+ 'level-02': { moves: 10, stars: 2, hints: 0, date: '2026-09-24' },
+ } }
+ await dialog.getByLabel('Paste a progress backup').fill(JSON.stringify(imported))
+ await dialog.getByRole('button', { name: 'Import and keep existing progress' }).click()
+ await expect(page.locator('.toast')).toContainText('Your gardens are safely restored')
+ const after = await savedProgress(page)
+ expect(after.completed['level-01']).toMatchObject({ stars: before.completed['level-01'].stars, moves: before.completed['level-01'].moves, hints: before.completed['level-01'].hints })
+ expect(after.completed['level-02'].stars).toBe(2)
+ await dialog.getByRole('button', { name: 'Export progress' }).click()
+ await expect(dialog.getByLabel('Copy this backup or garden link')).toHaveValue(/level-01/)
+ await page.keyboard.press('Escape')
+ await expect(dialog).toHaveCount(0)
+ await page.reload()
+ await expect(page.locator('.app-shell')).toHaveClass(/reduce-motion/)
+ await expect(page.locator('.app-shell')).toHaveClass(/high-contrast/)
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ await expect(page.getByRole('switch', { name: 'Sound effects' })).toHaveAttribute('aria-checked', 'false')
+})
+
+test('finishing the final garden unlocks the ending and 48 completed records remain after reload', async ({ page }) => {
+ await openLevel(page, LEVELS[47])
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ const completed = Object.fromEntries(LEVELS.slice(0, 47).map(level => [level.id, { moves: level.par, stars: 3, hints: 0, date: '2026-09-24' }]))
+ await page.getByLabel('Paste a progress backup').fill(JSON.stringify({ ...DEFAULT_PROGRESS, completed }))
+ await page.getByRole('button', { name: 'Import and keep existing progress' }).click()
+ await page.getByRole('button', { name: 'Close dialog' }).click()
+ await solveWithClicks(page, LEVELS[47])
+ await page.getByRole('button', { name: 'Read the final memory' }).click()
+ await expect(page.getByRole('heading', { name: 'The keeper was always you.' })).toBeVisible()
+ await expect.poll(async () => Object.keys((await savedProgress(page)).completed).length).toBe(48)
+ await page.reload()
+ await expect(page.getByRole('heading', { name: 'The keeper was always you.' })).toBeVisible()
+ await page.getByRole('button', { name: /Collected blooms/ }).click()
+ await expect(page.getByRole('dialog')).toContainText('144 of 144 campaign blooms collected')
+ await expect(page.getByRole('dialog')).toContainText('A perfect constellation')
+})
+
+test('daily, workshop, shareable seed routes, and browser back navigation work', async ({ page }, testInfo) => {
+ const daily = dailyLevel('2026-09-24')
+ await openLevel(page, daily, 'daily/2026-09-24')
+ await expect(page.locator('.breadcrumb')).toContainText('2026-09-24')
+ await solveWithClicks(page, daily)
+ expect((await savedProgress(page)).completed[daily.id]).toBeTruthy()
+ await page.getByRole('button', { name: 'Workshop', exact: true }).click()
+ await expect(page.getByRole('heading', { name: 'Make room for wonder.' })).toBeVisible()
+ await page.getByLabel('Garden style').selectOption('5')
+ await page.getByLabel('Seed number').fill('4294967296')
+ await page.getByRole('button', { name: 'Grow this garden' }).click()
+ await expect(page.locator('.toast')).toContainText('Choose a seed from 0')
+ await page.getByLabel('Seed number').fill('314159')
+ await page.screenshot({ path: testInfo.outputPath('workshop.png'), fullPage: true })
+ await page.getByRole('button', { name: 'Grow this garden' }).click()
+ await expect(page).toHaveURL(/#\/seed\/314159\/5$/)
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(generateLevel(314159, 5).title)
+ await page.reload()
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(generateLevel(314159, 5).title)
+ await page.goBack()
+ await expect(page.getByRole('heading', { name: 'Make room for wonder.' })).toBeVisible()
+ await page.goBack()
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(daily.title)
+ expect((await savedProgress(page)).completed[daily.id]).toBeTruthy()
+})
+
+test('field guide supports keyboard close and all six atlas chapters are open', async ({ page }, testInfo) => {
+ await openLevel(page, LEVELS[0])
+ await page.getByRole('button', { name: 'How to play', exact: true }).click()
+ await expect(page.getByRole('dialog')).toContainText('One-way gates')
+ await expect(page.getByRole('dialog')).toContainText('Portals')
+ await page.keyboard.press('Tab')
+ await expect(page.getByRole('button', { name: 'Close dialog' })).toBeFocused()
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('dialog')).toHaveCount(0)
+ await page.getByRole('button', { name: 'Lumen, all gardens' }).click()
+ await expect(page.getByRole('heading', { name: 'A world waiting for light.' })).toBeVisible()
+ await expect(page.locator('.chapter-card')).toHaveCount(6)
+ await page.screenshot({ path: testInfo.outputPath('atlas.png'), fullPage: true })
+ await page.getByRole('button', { name: 'Explore The Far Gardens' }).click()
+ await page.locator('.card-level-list button').last().click()
+ await expect(page).toHaveURL(/#\/play\/level-48$/)
+})
+
+test('mobile layout keeps the board, toolbar, navigation, and dialogs usable without horizontal overflow', async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await openLevel(page, LEVELS[0])
+ await expect(page.getByRole('button', { name: /^Restart/ })).toBeVisible()
+ await expect(page.getByRole('button', { name: /A gentle nudge/ })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Settings', exact: true })).toBeVisible()
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
+ await page.screenshot({ path: testInfo.outputPath('first-garden-mobile.png'), fullPage: true })
+ const stone = LEVELS[0].tiles.find(item => !item.fixed)!
+ await page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).click()
+ await expect(page.getByTestId('move-count')).toHaveText('01')
+ await page.getByRole('button', { name: /^Restart/ }).click()
+ await page.getByRole('button', { name: 'Toggle chapter list' }).click()
+ await expect(page.locator('.chapter-list')).toBeVisible()
+ await page.locator('.chapter-button').last().click()
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(LEVELS[40].title)
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ await expect(page.getByRole('dialog')).toBeVisible()
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
+ await page.getByRole('button', { name: 'Close dialog' }).click()
+ await page.getByRole('button', { name: 'Workshop', exact: true }).click()
+ await expect(page.getByLabel('Seed number')).toBeVisible()
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
+})
+
+test('an initial campaign deep link becomes the resumed journey and preserves in-progress turns', async ({ page }) => {
+ const level = LEVELS[24]
+ await openLevel(page, level)
+ await expect.poll(async () => (await savedProgress(page)).currentLevel).toBe(level.id)
+ const movable = level.tiles.filter(stone => !stone.fixed).slice(0, 3)
+ for (const stone of movable) await page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).click()
+ await expect(page.getByTestId('move-count')).toHaveText('03')
+ const labels = await Promise.all(movable.map(stone => page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).getAttribute('aria-label')))
+ await page.getByRole('button', { name: 'Lumen, all gardens' }).click()
+ await page.getByRole('button', { name: 'The journey', exact: true }).click()
+ await expect(page).toHaveURL(new RegExp(`#/play/${level.id}$`))
+ await expect(page.getByTestId('move-count')).toHaveText('03')
+ await page.reload()
+ await expect(page.getByTestId('move-count')).toHaveText('03')
+ // Returning without any hash must resume the deep-linked garden, not garden 1.
+ await page.goto('/')
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(level.title)
+ await expect(page.getByTestId('move-count')).toHaveText('03')
+ for (const [index, stone] of movable.entries()) {
+ await expect(page.locator(`[data-tile-id="${stone.id}"] .tile-hit`)).toHaveAttribute('aria-label', labels[index]!)
+ }
+})
+
+test('a session quota failure stays visible even when progress and settings save successfully', async ({ page }) => {
+ await page.addInitScript(() => {
+ const original = Storage.prototype.setItem
+ Storage.prototype.setItem = function (key, value) {
+ if (key === 'lumen-garden-session-v1') throw new DOMException('Simulated session quota failure', 'QuotaExceededError')
+ original.call(this, key, value)
+ }
+ })
+ await openLevel(page, LEVELS[0])
+ await expect(page.locator('.page-footer')).toContainText('Storage unavailable')
+ expect((await savedProgress(page)).currentLevel).toBe('level-01')
+ await page.getByRole('button', { name: 'Mute sound effects' }).click()
+ await expect.poll(async () => (await savedProgress(page)).sound).toBe(false)
+ await expect(page.locator('.page-footer')).toContainText('Storage unavailable')
+ const stone = LEVELS[0].tiles.find(item => !item.fixed)!
+ await page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).click()
+ await expect(page.getByTestId('move-count')).toHaveText('01')
+ await expect(page.locator('.page-footer')).toContainText('Storage unavailable')
+ expect(await page.evaluate(() => localStorage.getItem('lumen-garden-session-v1'))).toBeNull()
+ await page.reload()
+ await expect(page.getByRole('button', { name: 'Enable sound effects' })).toBeVisible()
+ await expect(page.locator('.page-footer')).toContainText('Storage unavailable')
+})
+
+test('saved ambience restores its controls without constructing audio before a player gesture', async ({ page }) => {
+ await page.addInitScript(({ storageKey, progress }) => {
+ localStorage.setItem(storageKey, JSON.stringify(progress))
+ const metrics = window as Window & { __lumenAudioContexts: number }
+ metrics.__lumenAudioContexts = 0
+ window.AudioContext = new Proxy(window.AudioContext, {
+ construct(target, argumentsList) {
+ metrics.__lumenAudioContexts++
+ return Reflect.construct(target, argumentsList)
+ },
+ })
+ }, { storageKey: STORAGE_KEY, progress: { ...DEFAULT_PROGRESS, ambience: true, sound: false } })
+ await openLevel(page, LEVELS[0])
+ await expect(page.getByRole('button', { name: 'Pause garden ambience' })).toBeVisible()
+ const audioContexts = () => page.evaluate(() => (window as Window & { __lumenAudioContexts: number }).__lumenAudioContexts)
+ expect(await audioContexts()).toBe(0)
+ expect((await savedProgress(page)).ambience).toBe(true)
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ await expect(page.getByRole('switch', { name: 'Garden ambience' })).toHaveAttribute('aria-checked', 'true')
+ expect(await audioContexts()).toBe(0)
+ await page.getByRole('button', { name: 'Close dialog' }).click()
+ const stone = LEVELS[0].tiles.find(item => !item.fixed)!
+ await page.locator(`[data-tile-id="${stone.id}"] .tile-hit`).click()
+ await expect.poll(audioContexts).toBe(1)
+})
diff --git a/projects/lumen-atelier-8f3c/tsconfig.app.json b/projects/lumen-atelier-8f3c/tsconfig.app.json
new file mode 100644
index 00000000..d6ca4e74
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/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/lumen-atelier-8f3c/tsconfig.json b/projects/lumen-atelier-8f3c/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/projects/lumen-atelier-8f3c/tsconfig.node.json b/projects/lumen-atelier-8f3c/tsconfig.node.json
new file mode 100644
index 00000000..a0267b23
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/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/lumen-atelier-8f3c/vite.config.ts b/projects/lumen-atelier-8f3c/vite.config.ts
new file mode 100644
index 00000000..50f5e243
--- /dev/null
+++ b/projects/lumen-atelier-8f3c/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()],
+})