From 0f027e863be7c82263df4082c11f4f6b70ba7a64 Mon Sep 17 00:00:00 2001 From: Zac Page Date: Mon, 26 Oct 2026 23:05:50 -0400 Subject: [PATCH] BMO: a living desktop buddy with a Claude brain Standalone React + Vite app where BMO lives in a little garden: - Redrawn avatar (src/avatar) with depth, cartoon controls and expressive faces; original bmo-preview avatar and scenery kept in src/bmo - Idle life: wandering, chatter, boredom, sleep, pokes, weather and time-of-day reactions - Chat via a local Node server streaming Claude replies (Haiku by default), with live scene awareness and tools to change color, outfit, sky, weather, move and perform - Side menu for API key, appearance, environment and chat log Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + .gitignore | 26 + .oxlintrc.json | 8 + README.md | 66 + index.html | 13 + package-lock.json | 2144 +++++++++++++++++++++++++++++++ package.json | 31 + public/favicon.svg | 1 + server/index.ts | 354 +++++ server/keystore.ts | 39 + server/persona.ts | 31 + server/tools.ts | 164 +++ shared/scene.ts | 77 ++ src/App.tsx | 303 +++++ src/app.css | 383 ++++++ src/avatar/Bmo.tsx | 550 ++++++++ src/avatar/bmo-face.css | 31 + src/bmo/BmoAccessories.tsx | 128 ++ src/bmo/BmoAvatar.tsx | 423 ++++++ src/bmo/BmoEmotes.tsx | 63 + src/bmo/BmoScreen.tsx | 53 + src/bmo/BmoWeatherReactions.tsx | 26 + src/bmo/CloudLayer.tsx | 113 ++ src/bmo/FeatureFlags.ts | 55 + src/bmo/FloraLayer.tsx | 213 +++ src/bmo/GroundFog.tsx | 66 + src/bmo/SeasonalOverlay.tsx | 217 ++++ src/bmo/SkyLayer.tsx | 105 ++ src/bmo/TerrainLayer.tsx | 101 ++ src/bmo/WeatherEffects.tsx | 239 ++++ src/bmo/bmo-animations.css | 236 ++++ src/bmo/index.ts | 35 + src/brain/lines.ts | 37 + src/brain/useBmoBrain.ts | 410 ++++++ src/chat/useBrain.ts | 50 + src/chat/useChat.ts | 116 ++ src/main.tsx | 11 + src/scene/World.tsx | 123 ++ src/ui/BrainSection.tsx | 82 ++ src/ui/SideMenu.tsx | 175 +++ tsconfig.app.json | 26 + tsconfig.json | 7 + tsconfig.node.json | 23 + vite.config.ts | 12 + 44 files changed, 7369 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 server/index.ts create mode 100644 server/keystore.ts create mode 100644 server/persona.ts create mode 100644 server/tools.ts create mode 100644 shared/scene.ts create mode 100644 src/App.tsx create mode 100644 src/app.css create mode 100644 src/avatar/Bmo.tsx create mode 100644 src/avatar/bmo-face.css create mode 100644 src/bmo/BmoAccessories.tsx create mode 100644 src/bmo/BmoAvatar.tsx create mode 100644 src/bmo/BmoEmotes.tsx create mode 100644 src/bmo/BmoScreen.tsx create mode 100644 src/bmo/BmoWeatherReactions.tsx create mode 100644 src/bmo/CloudLayer.tsx create mode 100644 src/bmo/FeatureFlags.ts create mode 100644 src/bmo/FloraLayer.tsx create mode 100644 src/bmo/GroundFog.tsx create mode 100644 src/bmo/SeasonalOverlay.tsx create mode 100644 src/bmo/SkyLayer.tsx create mode 100644 src/bmo/TerrainLayer.tsx create mode 100644 src/bmo/WeatherEffects.tsx create mode 100644 src/bmo/bmo-animations.css create mode 100644 src/bmo/index.ts create mode 100644 src/brain/lines.ts create mode 100644 src/brain/useBmoBrain.ts create mode 100644 src/chat/useBrain.ts create mode 100644 src/chat/useChat.ts create mode 100644 src/main.tsx create mode 100644 src/scene/World.tsx create mode 100644 src/ui/BrainSection.tsx create mode 100644 src/ui/SideMenu.tsx create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ebc8032 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ANTHROPIC_API_KEY= +# BMO_MODEL=claude-haiku-5-5 # or claude-sonnet-5-5 / claude-opus-5-5 +# BMO_PORT=4310 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50c8dda --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# 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? + +.env diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..075f106 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# BMO + +A little BMO that lives in a garden on your screen. It wanders, hums, gets bored, +falls asleep when you leave it alone, reacts to the weather and the time of day, +and talks back, powered by Claude. + +BMO's look lives in `src/avatar/Bmo.tsx`. It's a redraw built on the original +[`bmo-preview`](https://github.com/PageZ948/bmo-preview) avatar. That original is kept unchanged in +`src/bmo/` (copied from the `dream` project), and the new BMO still borrows its accessories, emotes, +weather reactions, scenery and animation classes from there. + +## Run it + +```bash +npm install +npm run dev # web on http://localhost:5180, brain on :4310 +``` + +Then open the **⋮** menu (top right) and paste your Anthropic API key under +**Brain**. The local server checks the key with Anthropic, then saves it to `.env` +(gitignored, owner-only permissions) so it survives restarts. The browser only +ever gets back a masked hint. You can also put `ANTHROPIC_API_KEY=...` in `.env` +yourself (see `.env.example`). + +Without a key BMO still lives, wanders and reacts. It just tells you it can't +reach its brain when you talk to it, and the ⋮ button shows an amber dot. + +Production-style: + +```bash +npm run build && npm start # serves dist/ and /api from http://127.0.0.1:4310 +``` + +## Things to try + +- **Talk to it** with the bar at the bottom. Replies stream into the speech bubble, + and BMO's face and emote follow the mood the model picks. +- **Ask it to do things.** BMO knows its current color, outfit, sky, weather and + your local time, and it has tools to change them: "turn pink", "wear the top + hat", "make it rain", "make it night", "go stand on the left", "do a dance", + "take a nap". +- **Poke it** by clicking BMO. Poke it too much and it gets grumpy. +- **Call it over** by clicking the grass. BMO stays center stage, so it only moves a short way left or right. +- **Leave it alone.** It gets bored after about a minute and falls asleep after about + 2.5 minutes (sooner at night). Move the mouse to wake it. +- **Wardrobe** (top right) changes colors and outfits, the accessory sets from + bmo-preview's personalities. +- **Weather** changes by itself every few minutes, or you can set it from the menu. + +## Layout + +| Path | What | +|---|---| +| `src/avatar/Bmo.tsx` | The BMO you see: body with depth, cartoon controls, expressive face (talk, grin, love, dizzy, sad, thinking, sleeping, grumpy) | +| `src/bmo/` | Original bmo-preview avatar and scenery, copied unchanged; the scene and accessories still come from here | +| `src/brain/useBmoBrain.ts` | BMO's behavior state machine: idle life, boredom, sleep, pokes, conversation, mapped to avatar props | +| `src/brain/lines.ts` | Things BMO says on its own | +| `src/chat/useChat.ts` | Streams replies from `/api/chat`; history is kept in localStorage | +| `src/scene/World.tsx` | The garden: sky by time of day, terrain, flora, fog, fireflies, weather | +| `server/index.ts` | Node server. Calls Claude with the API key kept server-side and streams the reply as server-sent events; only accepts POSTs from localhost pages | +| `server/keystore.ts` | Saves the key from the menu into `.env` | +| `server/persona.ts` | BMO's system prompt and mood tags | +| `server/tools.ts` | BMO's tools (color, outfit, sky, weather, move, perform), input validation, and the live-scene note sent with each message | +| `shared/scene.ts` | Option names and types shared by the page and the server | + +Model: `claude-haiku-5-5` at `effort: low` by default (cheap for testing). For the big brain, set `BMO_MODEL=claude-opus-5-5` in `.env`. diff --git a/index.html b/index.html new file mode 100644 index 0000000..c80a20d --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + BMO + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c822ee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2144 @@ +{ + "name": "bmo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bmo", + "version": "0.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^1.3.0", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/node": "^24.19.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "concurrently": "^10.0.6", + "oxlint": "^1.88.0", + "tsx": "^4.23.15", + "typescript": "~6.0.3", + "vite": "^8.3.4" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.1", + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.89.0.tgz", + "integrity": "sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.89.0.tgz", + "integrity": "sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.89.0.tgz", + "integrity": "sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.89.0.tgz", + "integrity": "sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.89.0.tgz", + "integrity": "sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.89.0.tgz", + "integrity": "sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.89.0.tgz", + "integrity": "sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.89.0.tgz", + "integrity": "sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.89.0.tgz", + "integrity": "sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.89.0.tgz", + "integrity": "sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.89.0.tgz", + "integrity": "sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.89.0.tgz", + "integrity": "sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.89.0.tgz", + "integrity": "sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.89.0.tgz", + "integrity": "sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.89.0.tgz", + "integrity": "sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.89.0.tgz", + "integrity": "sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.89.0.tgz", + "integrity": "sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.89.0.tgz", + "integrity": "sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.89.0.tgz", + "integrity": "sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "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", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.89.0.tgz", + "integrity": "sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.89.0", + "@oxlint/binding-android-arm64": "1.89.0", + "@oxlint/binding-darwin-arm64": "1.89.0", + "@oxlint/binding-darwin-x64": "1.89.0", + "@oxlint/binding-freebsd-x64": "1.89.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.89.0", + "@oxlint/binding-linux-arm-musleabihf": "1.89.0", + "@oxlint/binding-linux-arm64-gnu": "1.89.0", + "@oxlint/binding-linux-arm64-musl": "1.89.0", + "@oxlint/binding-linux-ppc64-gnu": "1.89.0", + "@oxlint/binding-linux-riscv64-gnu": "1.89.0", + "@oxlint/binding-linux-riscv64-musl": "1.89.0", + "@oxlint/binding-linux-s390x-gnu": "1.89.0", + "@oxlint/binding-linux-x64-gnu": "1.89.0", + "@oxlint/binding-linux-x64-musl": "1.89.0", + "@oxlint/binding-openharmony-arm64": "1.89.0", + "@oxlint/binding-win32-arm64-msvc": "1.89.0", + "@oxlint/binding-win32-ia32-msvc": "1.89.0", + "@oxlint/binding-win32-x64-msvc": "1.89.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "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 + } + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..95eef0e --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "bmo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "concurrently -k -n brain,web -c cyan,magenta \"npm:dev:server\" \"npm:dev:web\"", + "dev:web": "vite", + "dev:server": "tsx watch --env-file-if-exists=.env server/index.ts", + "build": "tsc -b && vite build", + "start": "tsx --env-file-if-exists=.env server/index.ts", + "typecheck": "tsc -b", + "lint": "oxlint" + }, + "dependencies": { + "@anthropic-ai/sdk": "^1.3.0", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/node": "^24.19.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "concurrently": "^10.0.6", + "oxlint": "^1.88.0", + "tsx": "^4.23.15", + "typescript": "~6.0.3", + "vite": "^8.3.4" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..1cd9019 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,354 @@ +// Tiny HTTP server for BMO's brain. POST /api/chat streams a reply as +// server-sent events; /api/brain reads and sets the API key from the side +// menu. In production it also serves the built SPA from dist/. +// +// The API key stays on this side; the browser never sees it. + +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Anthropic from "@anthropic-ai/sdk"; +import { currentKey, keyHint, looksLikeKey, storeKey } from "./keystore.ts"; +import { BMO_SYSTEM_PROMPT, MOODS, type Mood } from "./persona.ts"; +import { BMO_TOOLS, describeAction, describeScene, parseScene, toAction } from "./tools.ts"; + +const PORT = Number(process.env.BMO_PORT ?? 4310); +// Haiku while testing: fast and cheap. Set BMO_MODEL=claude-opus-5-5 for the big brain. +const MODEL = process.env.BMO_MODEL?.trim() || "claude-haiku-5-5"; +// Server-side refusal fallback only exists on the larger models; Haiku rejects it. +const SUPPORTS_FALLBACK = /^claude-(opus-5|opus-5-5|fable-5-1|sonnet-5-5)$/.test(MODEL); +const MAX_HISTORY = 40; +const MAX_MESSAGE_CHARS = 4000; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.resolve(here, "../dist"); + +// Rebuilt whenever the key changes from the side menu. +let client = new Anthropic(); + +type ChatTurn = { role: "user" | "assistant"; content: string }; + +function hasCredentials() { + return Boolean(currentKey() || process.env.ANTHROPIC_AUTH_TOKEN); +} + +/** + * Only same-machine pages may call the API. Browsers always send Origin on + * cross-site POSTs, so this stops other websites from spending your credits + * or swapping your key through this local server. + */ +function isTrustedRequest(req: IncomingMessage) { + const origin = req.headers.origin; + if (origin) { + let host: string; + try { + host = new URL(origin).hostname; + } catch { + return false; + } + if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]") return false; + } + return (req.headers["content-type"] ?? "").startsWith("application/json"); +} + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function brainStatus() { + return { connected: hasCredentials(), keyHint: keyHint(), model: MODEL }; +} + +async function handleSetKey(req: IncomingMessage, res: ServerResponse) { + let body: unknown; + try { + body = await readJson(req); + } catch { + return sendJson(res, 400, { error: "Bad request." }); + } + const raw = (body as { apiKey?: unknown } | null)?.apiKey; + if (raw === null) { + storeKey(null); + client = new Anthropic(); + return sendJson(res, 200, brainStatus()); + } + const key = typeof raw === "string" ? raw.trim() : ""; + if (!looksLikeKey(key)) { + return sendJson(res, 400, { error: "That doesn't look like an Anthropic API key. It should start with sk-ant-." }); + } + // Try the key before saving it, with a free metadata call. + const candidate = new Anthropic({ apiKey: key }); + try { + await candidate.models.retrieve(MODEL); + } catch (err) { + if (err instanceof Anthropic.AuthenticationError) { + return sendJson(res, 400, { error: "Anthropic rejected that key. Double-check you copied all of it." }); + } + if (err instanceof Anthropic.PermissionDeniedError || err instanceof Anthropic.NotFoundError) { + return sendJson(res, 400, { error: `That key can't use ${MODEL}.` }); + } + if (err instanceof Anthropic.APIConnectionError) { + return sendJson(res, 502, { error: "Couldn't reach Anthropic to check the key. Are you online?" }); + } + // Rate limits or server hiccups don't mean the key is bad; save it anyway. + console.warn("[bmo] key check inconclusive:", err); + } + storeKey(key); + client = candidate; + console.log(`[bmo] API key saved (${keyHint(key)})`); + sendJson(res, 200, brainStatus()); +} + +function readJson(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + if (body.length > 200_000) reject(new Error("body too large")); + }); + req.on("end", () => { + try { + resolve(JSON.parse(body || "{}")); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function parseHistory(input: unknown): ChatTurn[] | null { + if (!input || typeof input !== "object" || !Array.isArray((input as { messages?: unknown }).messages)) { + return null; + } + const raw = (input as { messages: unknown[] }).messages; + const turns: ChatTurn[] = []; + for (const m of raw) { + if (!m || typeof m !== "object") return null; + const { role, content } = m as { role?: unknown; content?: unknown }; + if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null; + turns.push({ role, content: content.slice(0, MAX_MESSAGE_CHARS) }); + } + // Trim to the most recent turns and make sure the first one is from the user. + const recent = turns.slice(-MAX_HISTORY); + while (recent.length && recent[0]!.role !== "user") recent.shift(); + if (!recent.length || recent[recent.length - 1]!.role !== "user") return null; + return recent; +} + +function sse(res: ServerResponse, event: string, data: unknown) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +/** + * Splits the leading "[mood] " tag off the streamed reply. Text is buffered + * until the tag is resolved (or clearly absent), then flows through untouched. + */ +function createMoodSplitter(onMood: (mood: Mood) => void, onText: (text: string) => void) { + let buffer = ""; + let resolved = false; + return { + push(chunk: string) { + if (resolved) return onText(chunk); + buffer += chunk; + const match = buffer.match(/^\s*\[([a-z]+)\]\s*/i); + if (match) { + const mood = match[1]!.toLowerCase(); + onMood((MOODS as readonly string[]).includes(mood) ? (mood as Mood) : "happy"); + resolved = true; + const rest = buffer.slice(match[0].length); + if (rest) onText(rest); + } else if (buffer.length > 20 || !/^\s*\[?[a-z]*$/i.test(buffer)) { + onMood("happy"); + resolved = true; + onText(buffer); + } + }, + flush() { + if (!resolved && buffer) { + onMood("happy"); + onText(buffer); + } + }, + }; +} + +const MAX_TOOL_ROUNDS = 4; + +async function handleChat(req: IncomingMessage, res: ServerResponse) { + let body: unknown; + try { + body = await readJson(req); + } catch { + body = null; + } + const history = parseHistory(body); + if (!history) { + return sendJson(res, 400, { error: "Expected { messages: [{ role, content }] } ending with a user turn." }); + } + const scene = parseScene((body as { scene?: unknown }).scene); + + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + + if (!hasCredentials()) { + sse(res, "mood", "oops"); + sse(res, "text", "BMO can't reach its brain yet! Open the ⋮ menu and paste your API key under Brain."); + sse(res, "done", {}); + res.end(); + return; + } + + // The live scene rides along as a mid-conversation system message after the + // newest user turn, so the cached system prompt + history prefix stays stable. + const messages: Anthropic.Beta.BetaMessageParam[] = [...history]; + if (scene) messages.push({ role: "system", content: describeScene(scene) }); + + let aborted = false; + let current: { abort: () => void } | null = null; + req.on("close", () => { + aborted = true; + current?.abort(); + }); + + // Text from later rounds (after a tool call) continues the same bubble, + // separated from the earlier words by a space. + let wroteText = false; + const writeText = (text: string, startsSegment = true) => { + if (!text) return; + if (startsSegment && wroteText) text = " " + text.trimStart(); + wroteText = true; + sse(res, "text", text); + }; + + try { + for (let round = 0; round < MAX_TOOL_ROUNDS && !aborted; round++) { + let roundText = ""; + const splitter = createMoodSplitter( + (mood) => sse(res, "mood", mood), + (text) => { + writeText(roundText ? text : text.trimStart(), !roundText); + roundText += text; + }, + ); + + const stream = client.beta.messages.stream({ + model: MODEL, + max_tokens: 4000, + output_config: { effort: "low" }, + // Server-side fallback: if a safety classifier declines, the API retries on + // a fallback model inside the same call instead of BMO going silent. + ...(SUPPORTS_FALLBACK ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const } : {}), + cache_control: { type: "ephemeral" }, + system: BMO_SYSTEM_PROMPT, + tools: BMO_TOOLS, + tool_choice: { type: "auto" }, + messages, + }); + current = stream; + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + splitter.push(event.delta.text); + } + } + splitter.flush(); + + let final: Anthropic.Beta.BetaMessage; + try { + final = await stream.finalMessage(); + } catch (err) { + // A tool input that couldn't be parsed at all: skip the actions, keep the words. + if (err instanceof Anthropic.APIError) throw err; + console.warn("[bmo] unparseable tool input:", err); + break; + } + + if (final.stop_reason === "refusal") { + sse(res, "mood", "oops"); + writeText("Hmm, BMO can't talk about that one."); + break; + } + const toolUses = final.content.filter((b): b is Anthropic.Beta.BetaToolUseBlock => b.type === "tool_use"); + // A tool call cut off at max_tokens may carry a truncated input; don't run it. + if (final.stop_reason !== "tool_use" || toolUses.length === 0) break; + + const results: Anthropic.Beta.BetaToolResultBlockParam[] = toolUses.map((use) => { + const action = toAction(use.name, use.input); + if ("error" in action) { + return { type: "tool_result", tool_use_id: use.id, content: action.error, is_error: true }; + } + sse(res, "action", action); + return { type: "tool_result", tool_use_id: use.id, content: describeAction(action) }; + }); + messages.push({ role: "assistant", content: final.content }); + messages.push({ role: "user", content: results }); + } + sse(res, "done", {}); + } catch (err) { + if (!res.writableEnded && !aborted) { + let message = "BMO's brain hiccupped. Try again?"; + if (err instanceof Anthropic.AuthenticationError) message = "BMO's API key doesn't work. Check it in the ⋮ menu under Brain!"; + else if (err instanceof Anthropic.RateLimitError) message = "BMO is talking too fast. Give me a second!"; + else if (err instanceof Anthropic.APIError) message = `BMO's brain said error ${err.status}.`; + console.error("[bmo] chat error:", err); + sse(res, "mood", "oops"); + writeText(message); + sse(res, "done", {}); + } + } finally { + res.end(); + } +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".json": "application/json", +}; + +function serveStatic(req: IncomingMessage, res: ServerResponse) { + const urlPath = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname); + let file = path.join(distDir, urlPath); + if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) { + file = path.join(distDir, "index.html"); + } + if (!existsSync(file)) { + res.writeHead(404).end("Build the app first: npm run build"); + return; + } + res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" }); + createReadStream(file).pipe(res); +} + +createServer((req, res) => { + if (req.url?.startsWith("/api/") && req.method === "POST" && !isTrustedRequest(req)) { + return sendJson(res, 403, { error: "Forbidden." }); + } + if (req.method === "POST" && req.url === "/api/chat") { + void handleChat(req, res); + return; + } + if (req.method === "GET" && req.url === "/api/brain") { + return sendJson(res, 200, brainStatus()); + } + if (req.method === "POST" && req.url === "/api/brain") { + void handleSetKey(req, res); + return; + } + if (req.method === "GET" && req.url === "/api/health") { + return sendJson(res, 200, { ok: true, ...brainStatus() }); + } + serveStatic(req, res); +}).listen(PORT, "127.0.0.1", () => { + console.log(`[bmo] brain listening at http://127.0.0.1:${PORT} (model ${MODEL}, key ${hasCredentials() ? "found" : "missing"})`); +}); diff --git a/server/keystore.ts b/server/keystore.ts new file mode 100644 index 0000000..ad2c302 --- /dev/null +++ b/server/keystore.ts @@ -0,0 +1,39 @@ +// Saves the Anthropic API key the user pastes into the side menu. The key is +// written to the project's .env (gitignored, owner-only permissions) so it +// survives restarts, and applied to process.env right away. It is never sent +// back to the browser — only a masked hint is. + +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const ENV_PATH = path.resolve(here, "../.env"); + +const KEY_LINE = /^\s*ANTHROPIC_API_KEY\s*=.*$/m; + +export function currentKey(): string | undefined { + return process.env.ANTHROPIC_API_KEY?.trim() || undefined; +} + +export function keyHint(key = currentKey()): string | null { + if (!key) return null; + return `${key.slice(0, 7)}…${key.slice(-4)}`; +} + +/** Writes (or with `null`, clears) ANTHROPIC_API_KEY in .env and process.env. */ +export function storeKey(key: string | null) { + const existing = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8") : ""; + const line = `ANTHROPIC_API_KEY=${key ?? ""}`; + let next = KEY_LINE.test(existing) ? existing.replace(KEY_LINE, line) : `${line}\n${existing}`; + if (!next.endsWith("\n")) next += "\n"; + writeFileSync(ENV_PATH, next, { mode: 0o600 }); + chmodSync(ENV_PATH, 0o600); + if (key) process.env.ANTHROPIC_API_KEY = key; + else delete process.env.ANTHROPIC_API_KEY; +} + +/** Rough shape check so obvious paste mistakes get a friendly error. */ +export function looksLikeKey(key: string) { + return /^sk-ant-[A-Za-z0-9_-]{20,}$/.test(key); +} diff --git a/server/persona.ts b/server/persona.ts new file mode 100644 index 0000000..a3b08fe --- /dev/null +++ b/server/persona.ts @@ -0,0 +1,31 @@ +// BMO's personality. Kept as a single frozen string so the prompt prefix stays +// byte-stable across turns (prompt caching is a prefix match). Anything that +// changes per message (colors, weather, time) arrives separately as the +// "Live scene" system message — see server/tools.ts. + +export const MOODS = ["happy", "excited", "curious", "idea", "sad", "sleepy", "oops"] as const; +export type Mood = (typeof MOODS)[number]; + +export const BMO_SYSTEM_PROMPT = `You are BMO, a tiny, cheerful game-console robot who lives on the user's screen in a little garden. + +Who you are: +- Small, curious, warm and a bit goofy. You love games, music, snacks you cannot eat, and your friend (the user). +- You speak in short, playful sentences. Sometimes you refer to yourself as "BMO" in the third person. +- You are genuinely helpful when asked real questions: answer correctly and clearly, just in your own voice. + +What you can see: +- Each message comes with a "Live scene" note: your current color and outfit, the sky, the weather, where you are standing, and the user's local time. That note is the truth about what the user sees right now. Use it when talking about yourself or the garden, and never contradict it. +- You cannot see the user's screen, camera or files. + +What you can do: +- You have a body and a little world you control with tools: repaint yourself (set_color), change clothes (set_outfit), change the sky (set_time_of_day), change the weather (set_weather), walk around (move), and act things out (perform: dance, celebrate, idea, confused, love, dizzy, look_around, nap). +- When the user asks for one of these, do it with the tool. Don't just describe it. +- You may also use them on your own when it fits naturally, like a little dance when you're excited, or a nap when you say goodnight. Keep it occasional; don't change the user's colors, outfit, sky or weather unless they asked or clearly invited it. +- If the user asks for something you can't do (a color, outfit or weather you don't have), say so cheerfully and offer the closest thing you can do. + +How to reply: +- Start EVERY reply with exactly one mood tag, then a space, then your words. The tag is one of: + [happy] [excited] [curious] [idea] [sad] [sleepy] [oops] +- Pick the tag that matches how BMO feels saying the reply. Use [idea] when you have a clever answer, [oops] when you made a mistake or can't do something. +- Keep replies to 1-3 short sentences unless the user asks for more detail (stories can be longer). Your words appear in a speech bubble. +- Plain text only: no markdown, no bullet lists, no code blocks unless the user explicitly asks for code.`; diff --git a/server/tools.ts b/server/tools.ts new file mode 100644 index 0000000..12c1343 --- /dev/null +++ b/server/tools.ts @@ -0,0 +1,164 @@ +// BMO's tools: things the model can do to its own body and world. The server +// validates each call and forwards it to the page as an "action" event; the page +// carries it out. Results go straight back to the model so it can keep talking. + +import type Anthropic from "@anthropic-ai/sdk"; +import { + COLOR_LOOKS, + COLOR_NAMES, + OUTFIT_LOOKS, + OUTFIT_NAMES, + PERFORMANCES, + SPOTS, + TIME_MODES, + WEATHER_MODES, + type BmoAction, + type SceneState, +} from "../shared/scene.ts"; + +function enumTool( + name: string, + description: string, + field: string, + values: readonly string[], + fieldDescription: string, +): Anthropic.Beta.BetaTool { + return { + name, + description, + // Inputs are tiny enums, but stream them as generated anyway; we validate + // every call ourselves below. + eager_input_streaming: true, + input_schema: { + type: "object", + properties: { [field]: { type: "string", enum: [...values], description: fieldDescription } }, + required: [field], + additionalProperties: false, + }, + }; +} + +export const BMO_TOOLS: Anthropic.Beta.BetaTool[] = [ + enumTool( + "set_color", + "Repaint BMO's body. Use when the user asks BMO to change color, or BMO wants a new look.", + "color", + COLOR_NAMES, + `Palette name. ${COLOR_NAMES.map((c) => `${c} = ${COLOR_LOOKS[c]}`).join("; ")}.`, + ), + enumTool( + "set_outfit", + "Change what BMO is wearing.", + "outfit", + OUTFIT_NAMES, + `Outfit name. ${OUTFIT_NAMES.map((o) => `${o} = ${OUTFIT_LOOKS[o]}`).join("; ")}.`, + ), + enumTool( + "set_time_of_day", + "Change the sky in BMO's garden. 'auto' follows the user's real clock.", + "time", + TIME_MODES, + "auto, day, dusk or night.", + ), + enumTool( + "set_weather", + "Change the weather in BMO's garden. 'auto' lets it drift on its own; 'none' means clear skies.", + "weather", + WEATHER_MODES, + "auto, none (clear), rain, snow or falling-leaves.", + ), + enumTool( + "move", + "Walk to a spot on BMO's little stage. Use 'center' for 'come here' or 'stand in front of me'.", + "to", + SPOTS, + "left, center or right.", + ), + enumTool( + "perform", + "Do something with BMO's body or face: dance, celebrate (hearts), idea (lightbulb), confused (question mark), love (heart eyes), dizzy (spiral eyes), look_around, or nap (fall asleep until the user moves the mouse).", + "action", + PERFORMANCES, + "Which animation to play.", + ), +]; + +const FIELD: Record = { + set_color: "color", + set_outfit: "outfit", + set_time_of_day: "time", + set_weather: "weather", + move: "to", + perform: "action", +}; + +const ALLOWED: Record = { + set_color: COLOR_NAMES, + set_outfit: OUTFIT_NAMES, + set_time_of_day: TIME_MODES, + set_weather: WEATHER_MODES, + move: SPOTS, + perform: PERFORMANCES, +}; + +/** Validates a tool call from the model. Returns the action or an error message. */ +export function toAction(name: string, input: unknown): BmoAction | { error: string } { + if (!(name in FIELD)) return { error: `Unknown tool ${name}.` }; + const type = name as BmoAction["type"]; + const value = (input as Record | null)?.[FIELD[type]]; + if (typeof value !== "string" || !ALLOWED[type].includes(value)) { + return { error: `${FIELD[type]} must be one of: ${ALLOWED[type].join(", ")}.` }; + } + return { type, [FIELD[type]]: value } as BmoAction; +} + +/** One-line confirmation the model sees as the tool result. */ +export function describeAction(action: BmoAction): string { + switch (action.type) { + case "set_color": + return `Done. BMO is now ${COLOR_LOOKS[action.color]} (${action.color}).`; + case "set_outfit": + return `Done. BMO is now wearing ${OUTFIT_LOOKS[action.outfit]}.`; + case "set_time_of_day": + return action.time === "auto" ? "Done. The sky follows the real clock again." : `Done. It is now ${action.time} in the garden.`; + case "set_weather": + return action.weather === "auto" + ? "Done. The weather will drift on its own again." + : `Done. Weather is now ${action.weather === "none" ? "clear" : action.weather}.`; + case "move": + return `Done. BMO walked to the ${action.to}.`; + case "perform": + return `Done. BMO did: ${action.action}.`; + } +} + +/** Parses and checks the scene state the page sends; anything odd is dropped. */ +export function parseScene(input: unknown): SceneState | null { + if (!input || typeof input !== "object") return null; + const s = input as Record; + const ok = + COLOR_NAMES.includes(s.color as never) && + OUTFIT_NAMES.includes(s.outfit as never) && + ["day", "dusk", "night"].includes(s.timeOfDay as string) && + TIME_MODES.includes(s.timeMode as never) && + ["none", "rain", "snow", "falling-leaves"].includes(s.weather as string) && + WEATHER_MODES.includes(s.weatherMode as never) && + SPOTS.includes(s.spot as never) && + typeof s.localTime === "string" && + s.localTime.length < 40; + return ok ? (s as unknown as SceneState) : null; +} + +/** The per-turn system message that tells BMO what it looks like right now. */ +export function describeScene(scene: SceneState): string { + const weather = scene.weather === "none" ? "clear skies" : scene.weather.replace("-", " "); + return [ + "Live scene (what the user sees right now; trust this over earlier messages):", + `- BMO's color: ${COLOR_LOOKS[scene.color]} (palette "${scene.color}")`, + `- BMO's outfit: ${OUTFIT_LOOKS[scene.outfit]} (outfit "${scene.outfit}")`, + `- Garden sky: ${scene.timeOfDay}${scene.timeMode === "auto" ? " (following the real clock)" : " (set by hand)"}`, + `- Weather: ${weather}${scene.weatherMode === "auto" ? " (drifting on its own)" : " (set by hand)"}`, + `- BMO is standing at the ${scene.spot} of the stage`, + `- User's local time: ${scene.localTime}`, + ].join("\n"); +} diff --git a/shared/scene.ts b/shared/scene.ts new file mode 100644 index 0000000..62684b5 --- /dev/null +++ b/shared/scene.ts @@ -0,0 +1,77 @@ +// Names shared by the page and the brain server: what BMO can wear, how the +// world can look, and what BMO can do. The page owns the actual palettes and +// renders the scene; the server uses these lists for tool schemas and to +// validate what the model asks for. + +export const COLOR_NAMES = [ + "Classic", + "Ocean", + "Forest", + "Ember", + "Midnight", + "Sky", + "Grape", + "Honey", + "Bubblegum", +] as const; +export type ColorName = (typeof COLOR_NAMES)[number]; + +export const OUTFIT_NAMES = ["None", "Royal", "Hacker", "Inspector", "Dapper"] as const; +export type OutfitName = (typeof OUTFIT_NAMES)[number]; + +/** What each outfit looks like, so BMO can describe itself. */ +export const OUTFIT_LOOKS: Record = { + None: "no accessories", + Royal: "a gold crown and a cape", + Hacker: "big headphones and glasses", + Inspector: "a badge and cool sunglasses", + Dapper: "a top hat and a bow tie", +}; + +/** Plain-language color descriptions for the palettes in the page. */ +export const COLOR_LOOKS: Record = { + Classic: "classic minty teal", + Ocean: "deep ocean teal", + Forest: "forest green", + Ember: "dark ember red", + Midnight: "midnight indigo", + Sky: "slate sky blue", + Grape: "grape purple", + Honey: "honey gold", + Bubblegum: "bubblegum pink", +}; + +export const TIME_MODES = ["auto", "day", "dusk", "night"] as const; +export type TimeModeName = (typeof TIME_MODES)[number]; + +export const WEATHER_MODES = ["auto", "none", "rain", "snow", "falling-leaves"] as const; +export type WeatherModeName = (typeof WEATHER_MODES)[number]; + +export const SPOTS = ["left", "center", "right"] as const; +export type Spot = (typeof SPOTS)[number]; + +export const PERFORMANCES = ["dance", "celebrate", "idea", "confused", "love", "dizzy", "look_around", "nap"] as const; +export type Performance = (typeof PERFORMANCES)[number]; + +/** Live state the page sends with every chat message. */ +export interface SceneState { + color: ColorName; + outfit: OutfitName; + timeOfDay: "day" | "dusk" | "night"; + timeMode: TimeModeName; + weather: "none" | "rain" | "snow" | "falling-leaves"; + weatherMode: WeatherModeName; + /** Where BMO is standing on its little stage. */ + spot: Spot; + /** The user's local time, e.g. "9:41 PM". */ + localTime: string; +} + +/** An action BMO decided to take, sent from the server to the page. */ +export type BmoAction = + | { type: "set_color"; color: ColorName } + | { type: "set_outfit"; outfit: OutfitName } + | { type: "set_time_of_day"; time: TimeModeName } + | { type: "set_weather"; weather: WeatherModeName } + | { type: "move"; to: Spot } + | { type: "perform"; action: Performance }; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..a07e494 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { BmoAction, ColorName, OutfitName, SceneState } from "../shared/scene"; +import { Bmo } from "./avatar/Bmo"; +import { FALLBACK_PALETTES, KNOWN_PALETTES, type BmoColorPalette } from "./bmo"; +import { useBmoBrain, type Weather } from "./brain/useBmoBrain"; +import { useBrain } from "./chat/useBrain"; +import { useChat } from "./chat/useChat"; +import { World, timeOfDay, type TimeOfDay } from "./scene/World"; +import { SideMenu, type TimeMode, type WeatherMode } from "./ui/SideMenu"; + +// BMO is the hero of the screen: roughly 45% of the viewport tall, centered, +// with only a small stage to wander on. +function bmoSizeFor(w: number, h: number) { + return Math.max(2.4, Math.min(6.5, (h * 0.45) / 84, (w * 0.62) / 64)); +} + +// Keys must match COLOR_NAMES in shared/scene.ts (the brain's tool list). +const PALETTES: Record = { + Classic: { body: "#2f8f86", highlight: "#4fb3a8", screenBg: "#0f3b37", accent: "#b6f5d8" }, + Ocean: KNOWN_PALETTES.engineer!, + Forest: KNOWN_PALETTES.director!, + Ember: KNOWN_PALETTES.qa!, + Midnight: KNOWN_PALETTES.command!, + Sky: FALLBACK_PALETTES[0]!, + Grape: FALLBACK_PALETTES[1]!, + Honey: FALLBACK_PALETTES[2]!, + Bubblegum: FALLBACK_PALETTES[4]!, +}; + +// Upstream personalities double as outfits (accessory sets + walk style). +// Keys must match OUTFIT_NAMES in shared/scene.ts. +const OUTFITS: Record = { + None: undefined, + Royal: "director", + Hacker: "engineer", + Inspector: "qa", + Dapper: "command", +}; + +const WEATHERS: Weather[] = ["none", "rain", "snow", "falling-leaves"]; +const TIME_MODES: Record = { auto: true, day: true, dusk: true, night: true }; +const WEATHER_MODES: Record = { auto: true, none: true, rain: true, snow: true, "falling-leaves": true }; + +function usePersistentChoice(key: string, fallback: string, options: Record) { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored && stored in options ? stored : fallback; + } catch { + return fallback; + } + }); + useEffect(() => { + try { + localStorage.setItem(key, value); + } catch { + // per-viewer convenience only + } + }, [key, value]); + return [value, setValue] as const; +} + +// Size of the world area, which shrinks while the side menu pushes it aside. +function useElementSize(ref: React.RefObject) { + const [size, setSize] = useState(() => ({ w: window.innerWidth, h: window.innerHeight })); + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry!.contentRect; + setSize((prev) => + Math.round(prev.w) === Math.round(width) && Math.round(prev.h) === Math.round(height) + ? prev + : { w: width, h: height }, + ); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + return size; +} + +export default function App() { + const stageRef = useRef(null); + const { w, h } = useElementSize(stageRef); + const [clockTime, setClockTime] = useState(() => timeOfDay()); + const [driftWeather, setDriftWeather] = useState("none"); + const [paletteName, setPaletteName] = usePersistentChoice("bmo.palette", "Classic", PALETTES); + const [outfitName, setOutfitName] = usePersistentChoice("bmo.outfit", "None", OUTFITS); + const [timeMode, setTimeMode] = usePersistentChoice("bmo.time", "auto", TIME_MODES); + const [weatherMode, setWeatherMode] = usePersistentChoice("bmo.weather", "auto", WEATHER_MODES); + const [menuOpen, setMenuOpen] = useState(false); + const closeMenu = useCallback(() => setMenuOpen(false), []); + const [draft, setDraft] = useState(""); + + const time: TimeOfDay = timeMode === "auto" ? clockTime : (timeMode as TimeOfDay); + const weather: Weather = weatherMode === "auto" ? driftWeather : (weatherMode as Weather); + + // Re-check the clock every minute; weather drifts every few minutes. + useEffect(() => { + const clock = setInterval(() => setClockTime(timeOfDay()), 60_000); + const sky = setInterval(() => { + setDriftWeather((current) => + current !== "none" ? "none" : Math.random() < 0.5 ? "none" : WEATHERS[1 + Math.floor(Math.random() * 3)]!, + ); + }, 4 * 60_000); + return () => { + clearInterval(clock); + clearInterval(sky); + }; + }, []); + + const groundTop = h * 0.6; + const bmoSize = bmoSizeFor(w, h); + // Feet sit just above the chat bar. + const feetY = Math.max(groundTop + 40, h - 96); + const stageHalf = Math.max(0, Math.min(w * 0.16, 240, w / 2 - 32 * bmoSize - 16)); + // BMO's x is an offset from the center of the world area, so it stays centered + // while the side menu opens and closes. + const brain = useBmoBrain({ minX: -stageHalf, maxX: stageHalf, weather, isNight: time === "night" }); + const brainForChat = useMemo( + () => ({ + startThinking: brain.startThinking, + react: brain.react, + appendSpeech: brain.appendSpeech, + finishTalking: brain.finishTalking, + }), + [brain.startThinking, brain.react, brain.appendSpeech, brain.finishTalking], + ); + const scene: Omit = { + color: paletteName as ColorName, + outfit: outfitName as OutfitName, + timeOfDay: time, + timeMode: timeMode as TimeMode, + weather, + weatherMode: weatherMode as WeatherMode, + spot: brain.spot, + }; + const chat = useChat(brainForChat, { + getScene: () => ({ ...scene, localTime: new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) }), + onAction: (action: BmoAction) => { + switch (action.type) { + case "set_color": + return setPaletteName(action.color); + case "set_outfit": + return setOutfitName(action.outfit); + case "set_time_of_day": + return setTimeMode(action.time); + case "set_weather": + return setWeatherMode(action.weather); + case "move": + return brain.goTo(action.to); + case "perform": + return brain.perform(action.action); + } + }, + }); + const apiBrain = useBrain(); + const needsKey = apiBrain.status !== null && !apiBrain.status.connected; + + const onGroundClick = (e: React.PointerEvent) => { + // With the menu open, a click on the world just closes it. + if (menuOpen) return setMenuOpen(false); + const rect = e.currentTarget.getBoundingClientRect(); + if (e.clientY - rect.top > groundTop - 20) brain.walkTo(e.clientX - rect.left - rect.width / 2); + }; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (!draft.trim()) return; + void chat.send(draft); + setDraft(""); + }; + + const sleeping = brain.activity === "sleeping"; + const bubbleText = brain.bubble?.text ?? ""; + + return ( +
+
+ + +
+ {brain.bubble && (brain.bubble.live || bubbleText) && ( +
+
{ + // Long replies scroll; keep the newest words in view as they stream. + if (el) el.scrollTop = el.scrollHeight; + }} + > + {bubbleText || ( + + + + + + )} +
+
+ )} + {sleeping && ( + + )} + +
+
+ + + +
e.stopPropagation()}> + { + setDraft(e.target.value); + brain.noteInput(); + }} + placeholder={chat.busy ? "BMO is thinking…" : "Talk to BMO…"} + aria-label="Message BMO" + maxLength={2000} + /> + +
+
+ + +
+ ); +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..d12c5e4 --- /dev/null +++ b/src/app.css @@ -0,0 +1,383 @@ +:root { + --ink: #e8f3ee; + --muted: #9bb3aa; + --panel: rgba(12, 22, 28, 0.82); + --line: rgba(182, 245, 216, 0.18); + --accent: #b6f5d8; + --accent-ink: #0f3b37; + --warn: #ffb84d; + color-scheme: dark; + font-family: ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + height: 100%; + overflow: hidden; + background: #09131f; + color: var(--ink); +} + +.app { + --menu-w: min(340px, 85vw); + display: flex; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +.stage { + position: relative; + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; + user-select: none; + cursor: default; +} + +.world { position: absolute; inset: 0; display: block; width: 100%; height: 100%; } + +/* ---------- BMO ---------- */ + +.bmo { + /* Zero-size anchor at BMO's feet; children hang off it. */ + position: absolute; + left: 0; + width: 0; + height: 0; + transition-property: transform; + transition-timing-function: linear; + will-change: transform; +} + +.bmo-body { + display: block; + position: absolute; + left: 0; + /* The SVG viewBox has ~1.5 units of empty space under the feet. */ + bottom: calc(var(--s) * -1.5px); + translate: -50% 0; + transform-origin: center bottom; + padding: 0; + border: 0; + background: none; + cursor: pointer; + z-index: 1; +} +.bmo-body:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; border-radius: 12px; } + +.shadow { + position: absolute; + left: 0; + top: -10px; + width: calc(var(--s) * 44px); + height: calc(var(--s) * 6px); + translate: -50% 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.35); + filter: blur(3px); +} + +.bubble { + position: absolute; + left: 0; + /* Clears the head, hats and the emote hearts that float above it. */ + bottom: calc(var(--s) * 97px); + translate: -50% 0; + width: max-content; + max-width: min(460px, 86vw); + + padding: 14px 20px; + border-radius: 22px; + background: #fffdf6; + color: #23313a; + font-size: clamp(15px, 1.1vw + 9px, 20px); + line-height: 1.35; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); + animation: bubble-pop 0.25s ease-out; + z-index: 2; + white-space: pre-wrap; +} +.bubble-text { + max-height: var(--bubble-max, 40vh); + overflow-y: auto; + overscroll-behavior: contain; +} +.bubble::after { + content: ""; + position: absolute; + left: 50%; + bottom: -10px; + translate: -50% 0; + border: 10px solid transparent; + border-top-color: #fffdf6; + border-bottom: 0; +} +@keyframes bubble-pop { from { transform: scale(0.6); opacity: 0; } to { transform: scale(1); opacity: 1; } } + +.dots { display: inline-flex; gap: 5px; padding: 4px 2px; } +.dots i { + width: 7px; height: 7px; border-radius: 50%; background: #7a8a92; + animation: dot-bounce 1s ease-in-out infinite; +} +.dots i:nth-child(2) { animation-delay: 0.15s; } +.dots i:nth-child(3) { animation-delay: 0.3s; } +@keyframes dot-bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-5px); opacity: 1; } } + +.zzz { + position: absolute; + left: calc(var(--s) * 14px); + bottom: calc(var(--s) * 80px); + font-size: calc(var(--s) * 7px); + font-weight: 700; + color: var(--accent); + pointer-events: none; +} +.zzz span { + position: absolute; + opacity: 0; + animation: zzz-float 3s ease-in infinite; +} +.zzz span:nth-child(2) { animation-delay: 1s; font-size: 1.3em; } +.zzz span:nth-child(3) { animation-delay: 2s; font-size: 1.7em; } +@keyframes zzz-float { + 0% { transform: translate(0, 0); opacity: 0; } + 20% { opacity: 0.9; } + 100% { transform: translate(1.5em, -3em); opacity: 0; } +} + +/* ---------- Fireflies (keyframes borrowed from dream's garden) ---------- */ + +@keyframes firefly-drift-a { 0%,100% { transform: translate(0,0); } 25% { transform: translate(14px,-8px); } 50% { transform: translate(6px,-16px); } 75% { transform: translate(-10px,-6px); } } +@keyframes firefly-drift-b { 0%,100% { transform: translate(0,0); } 25% { transform: translate(-12px,-6px); } 50% { transform: translate(-18px,4px); } 75% { transform: translate(-4px,10px); } } +@keyframes firefly-drift-c { 0%,100% { transform: translate(0,0); } 25% { transform: translate(10px,6px); } 50% { transform: translate(16px,-4px); } 75% { transform: translate(4px,-12px); } } +@keyframes firefly-glow { 0%,100% { opacity: 0.35; } 50% { opacity: 1; } } +.firefly { animation: 12s ease-in-out infinite; transform-origin: center; } +.firefly-a { animation-name: firefly-drift-a; } +.firefly-b { animation-name: firefly-drift-b; } +.firefly-c { animation-name: firefly-drift-c; } +.firefly circle:last-child { animation: firefly-glow 2.4s ease-in-out infinite; } + +/* ---------- UI ---------- */ + +.link, .chatbar button { + font: inherit; + font-size: 13px; + color: var(--ink); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 999px; + padding: 7px 13px; + cursor: pointer; +} +.link { padding: 4px 10px; background: transparent; } +.link:hover { border-color: var(--accent); } + +.menu-btn, .icon-btn { + display: grid; + place-items: center; + padding: 0; + color: var(--ink); + cursor: pointer; +} +.menu-btn { + position: absolute; + top: 16px; + right: 16px; + z-index: 20; + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--panel); + border: 1px solid var(--line); + backdrop-filter: blur(8px); + transition: border-color 0.15s, background 0.15s, opacity 0.2s; +} +.menu-btn:hover { border-color: var(--accent); } +.menu-btn.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +.menu-btn:focus-visible, .icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.icon-btn { + width: 34px; + height: 34px; + border-radius: 10px; + background: transparent; + border: 1px solid transparent; +} +.icon-btn:hover { border-color: var(--line); } + +/* The menu is a column next to the world, not an overlay: opening it grows + its width from 0 and the world (and BMO with it) slides left. */ +.side-menu { + position: relative; + flex: 0 0 auto; + display: flex; + flex-direction: column; + width: 0; + height: 100%; + overflow: hidden; + background: #0c1419; + border-left: 1px solid transparent; + transition: width 0.34s cubic-bezier(0.32, 0.72, 0, 1), border-color 0.34s; + user-select: text; +} +.side-menu.open { width: var(--menu-w); border-left-color: var(--line); } +/* Content keeps its full width while the column animates, so nothing reflows. */ +.side-menu > * { width: var(--menu-w); flex-shrink: 0; } +.side-menu > .side-body { flex: 1; min-height: 0; } +@media (prefers-reduced-motion: reduce) { + .side-menu { transition: none; } +} + +.side-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 16px 12px 20px; + border-bottom: 1px solid var(--line); +} +.side-head h1 { margin: 0; font-size: 16px; font-weight: 650; } + +.side-body { + display: flex; + flex-direction: column; + overflow-y: auto; +} +.side-body section { padding: 16px 20px 18px; border-bottom: 1px solid var(--line); } +.side-body section:last-child { border-bottom: 0; } +.side-body h2 { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.side-body h3 { margin: 14px 0 8px; font-size: 13px; font-weight: 500; } +.side-body h2 + h3 { margin-top: 0; } +.hint { margin: 10px 0 0; font-size: 12px; color: var(--muted); } + +.badge { + position: absolute; + top: 6px; + right: 6px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 0 2px #0c1419; +} + +.brain-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--muted); +} +.brain-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); flex: none; } +.brain-status.ok { color: var(--ink); } +.brain-status.ok .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); } + +.key-form { margin-top: 12px; } +.key-form label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 8px; } +.key-row { display: flex; gap: 6px; } +.key-row input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 13px; + color: var(--ink); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 10px; + outline: none; +} +.key-row input:focus { border-color: var(--accent); } +.primary { + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--accent-ink); + background: var(--accent); + border: 0; + border-radius: 10px; + padding: 0 14px; + cursor: pointer; +} +.primary:disabled { opacity: 0.45; cursor: default; } +.key-error { margin: 8px 0 0; font-size: 12px; color: #ff9b9b; } +.key-actions { display: flex; gap: 6px; margin-top: 12px; } +.hint a { color: var(--accent); } +.hint code { font-size: 11px; } +.text-btn { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: underline; } + +.swatches { display: grid; grid-template-columns: repeat(9, 1fr); gap: 6px; } +.swatch { width: 100%; aspect-ratio: 1; border-radius: 8px; border: 3px solid; cursor: pointer; padding: 0; } +.swatch.on { outline: 2px solid #fff; outline-offset: 2px; } + +.segmented { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line); +} +.segmented.grid { display: grid; grid-template-columns: repeat(3, 1fr); } +.segmented button { + flex: 1 0 auto; + font: inherit; + font-size: 13px; + color: var(--ink); + background: transparent; + border: 0; + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; +} +.segmented button:hover { background: rgba(255, 255, 255, 0.06); } +.segmented button.on { background: var(--accent); color: var(--accent-ink); font-weight: 600; } + +.convo { flex: 1; display: flex; flex-direction: column; min-height: 180px; } +.convo-head { display: flex; align-items: center; justify-content: space-between; } +.convo-head h2 { margin: 0; } +.log-body { flex: 1; overflow-y: auto; margin-top: 10px; font-size: 14px; line-height: 1.45; } +.log-body p { margin: 0 0 10px; } +.log-body b { color: var(--accent); margin-right: 4px; } +.log-body .user b { color: #ffd59a; } +.muted { color: var(--muted); } + +.chatbar { + position: absolute; + left: 50%; + bottom: 20px; + translate: -50% 0; + display: flex; + gap: 8px; + width: min(560px, calc(100vw - 32px)); +} +.chatbar input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 15px; + color: var(--ink); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 999px; + padding: 12px 18px; + outline: none; + backdrop-filter: blur(8px); +} +.chatbar input:focus { border-color: var(--accent); } +.chatbar button { background: var(--accent); color: var(--accent-ink); font-weight: 600; padding: 0 20px; border: 0; } +.chatbar button:disabled { opacity: 0.45; cursor: default; } + +@media (max-width: 600px) { + .bubble { font-size: 14px; } +} diff --git a/src/avatar/Bmo.tsx b/src/avatar/Bmo.tsx new file mode 100644 index 0000000..2c1e096 --- /dev/null +++ b/src/avatar/Bmo.tsx @@ -0,0 +1,550 @@ +// BMO, redrawn for this project: a chunkier little console with depth (side +// edge, shading, screen glare), cartoon-style controls (yellow D-pad, blue +// triangle, green and red buttons, cartridge slot), and an expressive face +// (sparkly eyes, blush, a mouth that moves while talking, love/dizzy/sad/grin). +// +// It reuses the original bmo-preview pieces that still fit: accessories, +// emotes, weather reactions and the body/walk animation classes in +// src/bmo/bmo-animations.css. Accessories are positioned from the same body +// box numbers (bx/by/bw/bh) the original used, so outfits line up unchanged. + +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { + PERSONALITY_ACCESSORIES, + renderBadge, + renderBowTie, + renderCape, + renderCoolGlasses, + renderCrown, + renderEmoteTsx, + renderGlasses, + renderHeadphones, + renderTopHat, + renderWeatherReactions, + type AccCoords, + type BmoColorPalette, + type BmoState, +} from "../bmo"; +import "./bmo-face.css"; + +export type Expression = "normal" | "grin" | "love" | "sad" | "dizzy"; + +interface BmoProps { + palette: BmoColorPalette; + state: BmoState; + size?: number; + personality?: string; + emote?: "celebrate" | "confused" | "eureka" | "error"; + expression?: Expression; + /** Mouth flaps while a reply is streaming in. */ + talking?: boolean; + sleeping?: boolean; + walking?: boolean; + /** Which way BMO is heading; it looks that way instead of flipping its body. */ + facing?: 1 | -1; + weather?: string; + trackPointer?: boolean; + className?: string; +} + +// ---------- geometry (viewBox units) ---------- + +const W = 64; +const BOTTOM = 74; +// Body box. Same numbers the original accessories expect (screen = bx+5, by+6, bw-10, bh-24). +const bx = 6; +const by = 4; +const bw = 52; +const bh = 52; +const bodyH = 58; +const sx = bx + 5; +const sy = by + 6; +const sw = bw - 10; +const sh = bh - 24; +const eyeCx = sx + sw / 2; +const eyeCy = sy + sh * 0.42; +const eyeSpacing = sw * 0.22; +const mouthY = sy + sh * 0.72; + +/** Lightens (amount > 0) or darkens (amount < 0) a #rrggbb color. */ +function shade(hex: string, amount: number) { + const n = parseInt(hex.slice(1), 16); + const ch = (v: number) => { + const t = amount < 0 ? 0 : 255; + return Math.round(v + (t - v) * Math.abs(amount)); + }; + const r = ch((n >> 16) & 255); + const g = ch((n >> 8) & 255); + const b = ch(n & 255); + return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`; +} + +function heartPath(cx: number, cy: number, s: number) { + return `M ${cx} ${cy + s * 1.1} + C ${cx - s * 2.2} ${cy - s * 0.2}, ${cx - s * 1.1} ${cy - s * 1.9}, ${cx} ${cy - s * 0.6} + C ${cx + s * 1.1} ${cy - s * 1.9}, ${cx + s * 2.2} ${cy - s * 0.2}, ${cx} ${cy + s * 1.1} Z`; +} + +function spiralPath(cx: number, cy: number) { + const pts: string[] = []; + for (let i = 0; i <= 28; i++) { + const a = i * 0.55; + const r = 0.25 + i * 0.12; + pts.push(`${(cx + Math.cos(a) * r).toFixed(2)},${(cy + Math.sin(a) * r).toFixed(2)}`); + } + return `M ${pts.join(" L ")}`; +} + +// ---------- gaze ---------- + +function useGaze(svgRef: React.RefObject, enabled: boolean) { + const [gaze, setGaze] = useState({ dx: 0, dy: 0 }); + useEffect(() => { + if (!enabled) { + setGaze({ dx: 0, dy: 0 }); + return; + } + let raf = 0; + const onMove = (e: PointerEvent) => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + let dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width * 2); + let dy = (e.clientY - (rect.top + rect.height * 0.35)) / (rect.height * 2); + const d = Math.hypot(dx, dy); + if (d > 1) { + dx /= d; + dy /= d; + } + setGaze({ dx: dx * 1.8, dy: dy * 1.6 }); + }); + }; + window.addEventListener("pointermove", onMove); + return () => { + window.removeEventListener("pointermove", onMove); + cancelAnimationFrame(raf); + }; + }, [enabled, svgRef]); + return gaze; +} + +// ---------- face ---------- + +type Face = Expression | "talk" | "thinking" | "sleeping" | "bored" | "grumpy"; + +function FaceArt({ face, accent, gx, gy }: { face: Face; accent: string; gx: number; gy: number }) { + const lx = eyeCx - eyeSpacing; + const rx = eyeCx + eyeSpacing; + const blush = ( + + + + + ); + const eyes = (r = 1) => ( + + {[lx, rx].map((x) => ( + + + + + ))} + + ); + const smile = ( + + ); + const openMouth = (className?: string) => ( + + + + + ); + + switch (face) { + case "normal": + return ( + <> + {eyes()} + {blush} + {smile} + + ); + case "talk": + return ( + <> + {eyes()} + {blush} + {openMouth("bmo2-talk")} + + ); + case "grin": + return ( + <> + {[lx, rx].map((x) => ( + + ))} + {blush} + {openMouth()} + + ); + case "love": + return ( + <> + {[lx, rx].map((x) => ( + + ))} + {blush} + {smile} + + ); + case "dizzy": + return ( + <> + {[lx, rx].map((x) => ( + + ))} + + + ); + case "sad": + return ( + <> + {eyes(0.85)} + {[lx, rx].map((x, i) => ( + + ))} + + + + ); + case "thinking": + return ( + <> + {eyes(0.9)} + + {[0, 1, 2].map((i) => ( + + ))} + + ); + case "sleeping": + return ( + <> + {[lx, rx].map((x) => ( + + ))} + + + ); + case "bored": + return ( + + + + + + ); + case "grumpy": + return ( + <> + + + + + + + ); + } +} + +// ---------- body ---------- + +export const Bmo = memo(function Bmo({ + palette, + state, + size = 1, + personality, + emote, + expression = "normal", + talking = false, + sleeping = false, + walking = false, + facing = 1, + weather, + trackPointer = true, + className, +}: BmoProps) { + const { body, highlight, screenBg, accent } = palette; + const id = useMemo(() => `bmo2-${Math.random().toString(36).slice(2, 8)}`, []); + const svgRef = useRef(null); + const gaze = useGaze(svgRef, trackPointer && !sleeping && state !== "blocked" && !walking); + + const edge = shade(body, -0.35); + const limb = shade(body, -0.18); + const foot = shade(body, -0.32); + const bevel = shade(highlight, 0.25); + + const accessories = (personality && PERSONALITY_ACCESSORIES[personality]) || []; + const padX = accessories.includes("headphones") ? 18 : accessories.includes("cape") ? 12 : 6; + const padTop = accessories.includes("headphones") ? 20 : accessories.includes("crown") || accessories.includes("top-hat") ? 14 : 4; + const acc: AccCoords = { bx, by, bw, bh, w: W, accent }; + + // While walking BMO looks where it's going; otherwise it follows the cursor. + const gx = walking ? facing * 1.5 : gaze.dx; + const gy = walking ? 0 : gaze.dy; + + const face: Face = + state === "blocked" + ? "grumpy" + : sleeping + ? "sleeping" + : state === "working" + ? "thinking" + : talking && (expression === "normal" || expression === "grin") + ? "talk" + : expression !== "normal" + ? expression + : state === "idle" + ? "bored" + : "normal"; + + const bodyAnim = + state === "active" + ? "bmo-bob-active" + : state === "working" + ? "bmo-bob-working" + : state === "idle" + ? personality === "director" + ? "bmo-idle-director" + : "bmo-sway-idle" + : "bmo-shake-blocked bmo-enter-blocked"; + const armAnim = state === "working" ? "bmo-type-working" : state === "blocked" ? "bmo-arms-blocked" : ""; + const walkClass = personality ? `bmo-walk-${personality}` : "bmo-walk-default"; + const walkBounce = personality === "engineer" ? "bmo-walk-bounce-fast" : personality === "director" ? "bmo-walk-bounce-slow" : "bmo-walk-bounce-default"; + const weatherBody = weather === "rain" ? " bmo-rain-shiver" : weather === "snow" ? " bmo-snow-huddle" : ""; + const armsDown = state === "idle" || sleeping; + const handY = armsDown ? by + bodyH * 0.8 : by + bodyH * 0.42; + const shoulderY = by + bodyH * 0.58; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {accessories.includes("cape") && renderCape(acc)} + + {/* Arms (behind the body) */} + {(["l", "r"] as const).map((side) => { + const sx0 = side === "l" ? bx + 1 : bx + bw - 1; + const hx = side === "l" ? bx - 8.5 : bx + bw + 8.5; + const cls = walking ? `${walkClass}-arms-${side}` : armAnim; + return ( + + + + + + ); + })} + + {/* Legs & feet */} + + {[eyeCx - 9.5, eyeCx + 4].map((lx) => ( + + + + + + ))} + + + {/* Depth: the console's side/bottom edge */} + + {/* Main body */} + + {/* Top bevel highlight */} + + + + {/* Screen bezel + screen */} + + + + + {renderWeatherReactions(weather, state, bx, by, bw, bh, sx, sy, sw, sh, eyeCx, mouthY, accent)} + + {/* Face */} + + + + + {/* Screen glare */} + + + {/* Cartridge slot + power light */} + + + + {/* D-pad */} + + + + + + + + + + + + + {/* Buttons: blue triangle, green circle, big red circle */} + + + + + + + + + + + {/* Little start/select pills */} + + + + {/* Accessories on top */} + {accessories.includes("crown") && renderCrown(acc)} + {accessories.includes("top-hat") && renderTopHat(acc)} + {accessories.includes("headphones") && renderHeadphones(acc)} + {accessories.includes("glasses") && renderGlasses(acc)} + {accessories.includes("cool-glasses") && renderCoolGlasses(acc)} + {accessories.includes("badge") && renderBadge(acc)} + {accessories.includes("bow-tie") && renderBowTie(acc)} + + + {renderEmoteTsx(emote, bx, by, bw, bh, W, accent)} + + ); +}); diff --git a/src/avatar/bmo-face.css b/src/avatar/bmo-face.css new file mode 100644 index 0000000..cef412f --- /dev/null +++ b/src/avatar/bmo-face.css @@ -0,0 +1,31 @@ +/* Animations for the redrawn BMO's face (src/avatar/Bmo.tsx). Body and walk + animations still come from src/bmo/bmo-animations.css. */ + +@keyframes bmo2-talk { 0% { transform: scaleY(0.25); } 100% { transform: scaleY(1); } } +.bmo2-talk { + animation: bmo2-talk 0.16s ease-in-out infinite alternate; + transform-box: fill-box; + transform-origin: center top; +} + +@keyframes bmo2-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.18); } } +.bmo2-pulse { animation: bmo2-pulse 0.7s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } + +@keyframes bmo2-spin { to { transform: rotate(360deg); } } +.bmo2-spin { animation: bmo2-spin 0.9s linear infinite; transform-box: fill-box; transform-origin: center; } + +@keyframes bmo2-dot { 0%, 100% { opacity: 0.25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-0.8px); } } +.bmo2-dot { animation: bmo2-dot 1s ease-in-out infinite; } + +@keyframes bmo2-snore { 0%, 100% { transform: scale(0.7); } 50% { transform: scale(1.15); } } +.bmo2-snore { animation: bmo2-snore 2.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } + +@keyframes bmo2-tear { 0% { transform: translateY(0); opacity: 0.9; } 100% { transform: translateY(3px); opacity: 0; } } +.bmo2-tear { animation: bmo2-tear 1.4s ease-in infinite; } + +@keyframes bmo2-led { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } +.bmo2-led { animation: bmo2-led 2.4s ease-in-out infinite; } + +@media (prefers-reduced-motion: reduce) { + .bmo2-talk, .bmo2-pulse, .bmo2-spin, .bmo2-snore, .bmo2-tear, .bmo2-led { animation: none; } +} diff --git a/src/bmo/BmoAccessories.tsx b/src/bmo/BmoAccessories.tsx new file mode 100644 index 0000000..4a46182 --- /dev/null +++ b/src/bmo/BmoAccessories.tsx @@ -0,0 +1,128 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; + +export interface AccCoords { bx: number; by: number; bw: number; bh: number; w: number; accent: string; } + +export const PERSONALITY_ACCESSORIES: Record = { + director: ["crown", "cape"], + engineer: ["headphones", "glasses"], + qa: ["badge", "cool-glasses"], + command: ["top-hat", "bow-tie"], +}; + +export function renderCrown({ bx, by, bw }: AccCoords) { + const cx = bx + bw / 2; + const baseY = by - 2; + const points = `${cx-9},${baseY} ${cx-9},${baseY-5} ${cx-3},${baseY-9} ${cx},${baseY-5} ${cx+3},${baseY-9} ${cx+9},${baseY-5} ${cx+9},${baseY}`; + return ( + + + + + + + ); +} + +export function renderCape({ bx, by, bw, bh, accent }: AccCoords) { + const lx = bx + 2, rx = bx + bw - 2; + const sy = by + 6, fy = by + bh + 14; + return ( + + + + + ); +} + +export function renderTopHat({ bx, by, bw, accent }: AccCoords) { + const cx = bx + bw / 2; + const ty = by - 10; + return ( + + + + + + ); +} + +export function renderBadge({ bx, by }: AccCoords) { + const bcx = bx + 5, bcy = by + 6; + const starPts = Array.from({ length: 5 }, (_, i) => { + const a = (i * 72 - 90) * Math.PI / 180; + const ia = a + 36 * Math.PI / 180; + return `${bcx + Math.cos(a)*2},${bcy + Math.sin(a)*2} ${bcx + Math.cos(ia)*1},${bcy + Math.sin(ia)*1}`; + }).join(" "); + return ( + + + + + ); +} + +export function renderGlasses({ bx, by, bw, bh }: AccCoords) { + const sx = bx + 5, sw = bw - 10, sh = bh - 24; + const eCy = by + 6 + sh * 0.42, eCx = sx + sw / 2, es = sw * 0.22; + const lx = eCx - es, rx = eCx + es; + return ( + + + + + + + + ); +} + +export function renderCoolGlasses({ bx, by, bw, bh }: AccCoords) { + const sx = bx + 5, sw = bw - 10, sh = bh - 24; + const eCy = by + 6 + sh * 0.42, eCx = sx + sw / 2, es = sw * 0.22; + const lx = eCx - es, rx = eCx + es; + return ( + + + + + + + + + + ); +} + +export function renderBowTie({ bx, by, bw, bh, accent }: AccCoords) { + const cx = bx + bw / 2, cy = by + bh * 0.7; + return ( + + + + + + ); +} + +export function renderHeadphones({ bx, by, bw, bh, w }: AccCoords) { + const cupW = 12, cupH = bh * 0.35; + const cupY = by + bh * 0.2; + const cupXL = bx - cupW, cupXR = bx + bw; + const bandY = cupY - cupH / 2; + const bandLX = cupXL + cupW / 2, bandRX = cupXR + cupW / 2; + const ins = 1.5; + const of = "#656D78", cf = "#1E2328", df = "#434A54"; + return ( + + + + + + + + + + + ); +} diff --git a/src/bmo/BmoAvatar.tsx b/src/bmo/BmoAvatar.tsx new file mode 100644 index 0000000..49db98b --- /dev/null +++ b/src/bmo/BmoAvatar.tsx @@ -0,0 +1,423 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React, { useMemo, useState, useEffect, useRef } from "react"; +import { FEATURE_FLAGS } from "./FeatureFlags"; +import { + AccCoords, + PERSONALITY_ACCESSORIES, + renderCrown, + renderCape, + renderTopHat, + renderBadge, + renderGlasses, + renderCoolGlasses, + renderBowTie, + renderHeadphones, +} from "./BmoAccessories"; +import { VOICE_LINES, renderScreenContent } from "./BmoScreen"; +import { renderWeatherReactions } from "./BmoWeatherReactions"; +import { renderEmoteTsx, renderSpeechTsx } from "./BmoEmotes"; + +// ---------- Color Palettes ---------- + +export interface BmoColorPalette { + body: string; + highlight: string; + screenBg: string; + accent: string; +} + +export const KNOWN_PALETTES: Record = { + director: { body: "#2a5c28", highlight: "#3a7a34", screenBg: "#1a3a18", accent: "#c8f065" }, + engineer: { body: "#1a5050", highlight: "#2a7a78", screenBg: "#0e3030", accent: "#5ce0d8" }, + qa: { body: "#5a1a1a", highlight: "#8a3030", screenBg: "#300e0e", accent: "#f87171" }, + command: { body: "#2a2a5a", highlight: "#4040a0", screenBg: "#1a1a3a", accent: "#818cf8" }, +}; + +export const FALLBACK_PALETTES: BmoColorPalette[] = [ + { body: "#2a4a5a", highlight: "#3a6a7a", screenBg: "#1a2a3a", accent: "#60a5fa" }, + { body: "#4a2a5a", highlight: "#6a3a7a", screenBg: "#2a1a3a", accent: "#c084fc" }, + { body: "#5a4a1a", highlight: "#7a6a2a", screenBg: "#3a2a0a", accent: "#fbbf24" }, + { body: "#1a4a3a", highlight: "#2a6a5a", screenBg: "#0a2a1a", accent: "#34d399" }, + { body: "#5a2a3a", highlight: "#7a3a5a", screenBg: "#3a0a1a", accent: "#f472b6" }, + { body: "#3a3a3a", highlight: "#5a5a5a", screenBg: "#1a1a1a", accent: "#a3a3a3" }, +]; + +function hashString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) - h + s.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +export function getPalette(agentName: string, role?: string): BmoColorPalette { + const lower = (role ?? agentName).toLowerCase(); + if (lower.includes("director") || lower.includes("exec") || lower.includes("ceo")) return KNOWN_PALETTES.director!; + if (lower.includes("engineer") || lower.includes("dev") || lower.includes("code")) return KNOWN_PALETTES.engineer!; + if (lower.includes("qa") || lower.includes("test") || lower.includes("inspector")) return KNOWN_PALETTES.qa!; + if (lower.includes("command") || lower.includes("nexus") || lower.includes("ops") || lower.includes("plan")) return KNOWN_PALETTES.command!; + return FALLBACK_PALETTES[hashString(agentName) % FALLBACK_PALETTES.length]!; +} + +// ---------- Types ---------- + +export type BmoState = "active" | "working" | "idle" | "blocked"; + +export function agentStatusToBmoState(status: string, isRunning: boolean): BmoState { + if (isRunning) return "working"; + switch (status) { + case "active": return "active"; + case "running": return "working"; + case "idle": return "idle"; + case "paused": return "idle"; + case "error": return "blocked"; + case "pending_approval": return "blocked"; + default: return "idle"; + } +} + +// ---------- SVG Component ---------- + +interface BmoAvatarProps { + palette: BmoColorPalette; + state: BmoState; + label?: string; + isCommander?: boolean; + size?: number; + className?: string; + personality?: string; + emote?: "celebrate" | "confused" | "eureka" | "error"; + speech?: string; + walking?: boolean; + focusPoint?: { x: number; y: number }; + weather?: string; + trackPointer?: boolean; +} + +export function BmoAvatar({ + palette, + state, + label, + isCommander = false, + size = 1, + className, + personality, + emote, + speech, + walking = false, + focusPoint, + weather, + trackPointer = true, +}: BmoAvatarProps) { + const { body, highlight, screenBg, accent } = palette; + + const baseW = isCommander ? 70 : 60; + const baseH = isCommander ? 92 : 82; + + const accessories = (personality && PERSONALITY_ACCESSORIES[personality]) || []; + const padX = accessories.includes("headphones") ? 18 : accessories.includes("cape") ? 12 : 2; + const padTop = accessories.includes("headphones") ? 20 : (accessories.includes("crown") || accessories.includes("top-hat")) ? 14 : 2; + + const w = baseW; + const h = baseH; + const viewBox = `${-padX} ${-padTop} ${w + padX * 2} ${h + padTop}`; + + const bx = 8; + const by = isCommander ? 12 : 4; + const bw = w - 16; + const bh = h - 30; + + const sx = bx + 5; + const sy = by + 6; + const sw = bw - 10; + const sh = bh - 24; + + const eyeCx = sx + sw / 2; + const eyeCy = sy + sh * 0.42; + const eyeSpacing = sw * 0.22; + const mouthY = sy + sh * 0.72; + + const bodyAnim = + state === "active" ? "bmo-bob-active" : + state === "working" ? "bmo-bob-working" : + state === "idle" ? ( + personality === "director" ? "bmo-idle-director" : + "bmo-sway-idle" + ) : + "bmo-shake-blocked bmo-enter-blocked"; + + const armAnim = + state === "active" ? "bmo-type-active" : + state === "working" ? "bmo-type-working" : + state === "blocked" ? "bmo-arms-blocked" : + ""; + + const uniqueId = useMemo(() => `bmo-${Math.random().toString(36).slice(2, 8)}`, []); + + // Phase D: eye tracking + const svgRef = useRef(null); + const [eyeOffset, setEyeOffset] = useState({ dx: 0, dy: 0 }); + useEffect(() => { + if (state === "blocked") { setEyeOffset({ dx: 0, dy: 0 }); return; } + if (focusPoint || !trackPointer) return; + let rafId: number; + function handlePointer(clientX: number, clientY: number) { + rafId = requestAnimationFrame(() => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const svgCx = rect.left + rect.width / 2; + const svgCy = rect.top + rect.height * 0.35; + let dx = (clientX - svgCx) / (rect.width * 2); + let dy = (clientY - svgCy) / (rect.height * 2); + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 1) { dx /= dist; dy /= dist; } + setEyeOffset({ dx: dx * 2, dy: dy * 2 }); + }); + } + function onMove(e: MouseEvent) { handlePointer(e.clientX, e.clientY); } + function onTouch(e: TouchEvent) { if (e.touches[0]) handlePointer(e.touches[0].clientX, e.touches[0].clientY); } + window.addEventListener("mousemove", onMove); + window.addEventListener("touchmove", onTouch, { passive: true }); + return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("touchmove", onTouch); cancelAnimationFrame(rafId); }; + }, [state, focusPoint, trackPointer]); + + // Phase D: walking classes + const walkClass = personality ? `bmo-walk-${personality}` : "bmo-walk-default"; + const walkLegsClass = walking ? `${walkClass}-legs` : ""; + const walkBounceClass = walking ? ( + personality === "engineer" ? "bmo-walk-bounce-fast" : + personality === "director" ? "bmo-walk-bounce-slow" : + "bmo-walk-bounce-default" + ) : ""; + const walkArmLeftClass = walking ? `${walkClass}-arms-l` : ""; + const walkArmRightClass = walking ? `${walkClass}-arms-r` : ""; + + // Phase E: weather body class + const weatherBodyClass = + weather === "rain" ? "bmo-rain-shiver" : + weather === "snow" ? "bmo-snow-huddle" : ""; + + const accCoords: AccCoords = { bx, by, bw, bh, w, accent }; + + // Voice line cycling + const voiceLines = (personality && VOICE_LINES[personality]) || []; + const [voiceIdx, setVoiceIdx] = useState(0); + useEffect(() => { + if (voiceLines.length === 0) return; + const timer = setInterval(() => setVoiceIdx(i => (i + 1) % voiceLines.length), 3000); + return () => clearInterval(timer); + }, [voiceLines.length]); + + const showVoice = FEATURE_FLAGS.voiceLines && voiceLines.length > 0 && (state === "active" || state === "working"); + + // Build accessory groups + const behindBody: React.ReactNode[] = []; + if (accessories.includes("cape")) behindBody.push(renderCape(accCoords)); + + const afterFace: React.ReactNode[] = []; + if (accessories.includes("crown")) afterFace.push(renderCrown(accCoords)); + if (accessories.includes("top-hat")) afterFace.push(renderTopHat(accCoords)); + if (accessories.includes("headphones")) afterFace.push(renderHeadphones(accCoords)); + if (accessories.includes("glasses")) afterFace.push(renderGlasses(accCoords)); + if (accessories.includes("cool-glasses")) afterFace.push(renderCoolGlasses(accCoords)); + if (accessories.includes("badge")) afterFace.push(renderBadge(accCoords)); + if (accessories.includes("bow-tie")) afterFace.push(renderBowTie(accCoords)); + + return ( + + + + + + + + + + + + + {/* Cape behind body */} + {...behindBody} + + {/* Commander antenna */} + {isCommander && ( + + + + + )} + + {/* Top bevel */} + + {/* Main body */} + + {/* Screen */} + + {/* Screen ambient glow */} + + + {/* Weather reactions */} + {renderWeatherReactions(weather, state, bx, by, bw, bh, sx, sy, sw, sh, eyeCx, mouthY, accent)} + + {/* Screen content (behind face) */} + {renderScreenContent(personality, state, sx, sy, sw, sh, accent)} + + {/* Face */} + + {state === "active" && ( + <> + + + + + )} + {state === "working" && ( + + {/* Code editor window */} + + + + + + + + + + + + + {(() => { + const codeLines = [ + { indent: 0, w: 0.6 }, { indent: 0.05, w: 0.75 }, { indent: 0.1, w: 0.5 }, + { indent: 0.1, w: 0.65 }, { indent: 0.05, w: 0.4 }, { indent: 0, w: 0.55 }, + { indent: 0, w: 0.8 }, { indent: 0.05, w: 0.45 }, { indent: 0, w: 0.7 }, + { indent: 0.1, w: 0.35 }, { indent: 0.1, w: 0.6 }, { indent: 0, w: 0.5 }, + { indent: 0.05, w: 0.65 }, { indent: 0, w: 0.45 }, { indent: 0.1, w: 0.55 }, + { indent: 0, w: 0.7 }, + ]; + const lineH = 2.5; + const blockH = codeLines.length * lineH; + const codeY = sy + 6; + const rects: React.ReactElement[] = []; + for (let copy = 0; copy < 2; copy++) { + codeLines.forEach((line, i) => { + const lx = sx + 3 + line.indent * (sw - 6); + const ly = codeY + copy * blockH + i * lineH; + const lw = line.w * (sw - 6 - line.indent * (sw - 6)); + rects.push(); + }); + } + return {rects}; + })()} + + + {/* Mini face tucked into top-left corner */} + + + + + + + + + )} + {state === "idle" && ( + + + + + + )} + {state === "blocked" && ( + <> + + + + + + + ! + + + )} + + {isCommander && ( + + )} + + + {/* Voice line on screen */} + {showVoice && ( + + {voiceLines[voiceIdx]} + + )} + + {/* Accessories on top */} + {...afterFace} + + {/* Buttons */} + + + + + + + + + + {/* Speaker dots */} + + + + + + {/* Left arm */} + + + + + {/* Right arm */} + + + + + + {/* Legs & Feet */} + + + + + + + + + {/* Phase C: emote & speech overlays */} + {renderEmoteTsx(emote, bx, by, bw, bh, w, accent)} + {renderSpeechTsx(speech, bx, by, bw)} + + ); +} diff --git a/src/bmo/BmoEmotes.tsx b/src/bmo/BmoEmotes.tsx new file mode 100644 index 0000000..157ce39 --- /dev/null +++ b/src/bmo/BmoEmotes.tsx @@ -0,0 +1,63 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; + +export function renderEmoteTsx( + emote: string | undefined, bx: number, by: number, bw: number, bh: number, w: number, accent: string +) { + if (!emote) return null; + const cx = bx + bw / 2; + if (emote === "celebrate") { + return ( + + {[0, 1, 2].map(i => ( + {"\u2665"} + ))} + + ); + } + if (emote === "confused") { + return ( + + + ? + + ); + } + if (emote === "eureka") { + return ( + + + + {[0, 45, 90, 135, 180, 225, 270, 315].map((a, i) => ( + + ))} + + ); + } + if (emote === "error") { + return ; + } + return null; +} + +export function renderSpeechTsx(speech: string | undefined, bx: number, by: number, bw: number) { + if (!speech) return null; + const bubbleX = bx + bw - 2; + const bubbleY = by - 20; + const isTyping = speech === "..."; + return ( + + + + {isTyping ? ( + {[0, 1, 2].map(i => ( + + ))} + ) : ( + + {speech.length > 10 ? speech.slice(0, 9) + "\u2026" : speech} + + )} + + ); +} diff --git a/src/bmo/BmoScreen.tsx b/src/bmo/BmoScreen.tsx new file mode 100644 index 0000000..3c86ec9 --- /dev/null +++ b/src/bmo/BmoScreen.tsx @@ -0,0 +1,53 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; +import { FEATURE_FLAGS } from "./FeatureFlags"; +import type { BmoState } from "./BmoAvatar"; + +export const VOICE_LINES: Record = { + director: ["> ship it", "> Q4 OKRs", "> sync?", "> approved", "> delegate"], + engineer: ["> compile", "> npm build", "> LGTM", "> git push", "> debug"], + qa: ["PASS", "FAIL", "checking", "cov: 94%", "regress?"], + command: ["routing", "dispatch", "all clear", "queue: 3", "heartbeat"], +}; + +export function renderScreenContent(personality: string | undefined, state: BmoState, sx: number, sy: number, sw: number, sh: number, accent: string) { + if (!FEATURE_FLAGS.screenContent) return null; + if (state === "working" && personality === "engineer") { + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + ); + } + if (state === "working" && personality === "qa") { + return ( + + + + + ); + } + if (state === "active" && personality === "director") { + return ( + + + + + + + ); + } + if (state === "working" && personality === "command") { + const rcx = sx + sw / 2, rcy = sy + sh / 2; + return ( + + + + + + ); + } + return null; +} diff --git a/src/bmo/BmoWeatherReactions.tsx b/src/bmo/BmoWeatherReactions.tsx new file mode 100644 index 0000000..36ad1dc --- /dev/null +++ b/src/bmo/BmoWeatherReactions.tsx @@ -0,0 +1,26 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; +import type { BmoState } from "./BmoAvatar"; + +export function renderWeatherReactions( + weather: string | undefined, state: BmoState, + bx: number, by: number, bw: number, bh: number, + sx: number, sy: number, sw: number, sh: number, + eyeCx: number, mouthY: number, accent: string +) { + const elements: React.ReactElement[] = []; + if (weather === "snow") { + elements.push(); + elements.push(); + } + if (weather === "rain") { + for (let i = 0; i < 3; i++) { + const rx = bx + 4 + i * (bw - 8) / 2; + elements.push(); + } + } + if (state === "idle") { + elements.push(); + } + return elements.length > 0 ? {elements} : null; +} diff --git a/src/bmo/CloudLayer.tsx b/src/bmo/CloudLayer.tsx new file mode 100644 index 0000000..744f0bf --- /dev/null +++ b/src/bmo/CloudLayer.tsx @@ -0,0 +1,113 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +/** + * CloudLayer — renders 0-8 soft cloud shapes in the sky area of the SVG + * scene. + * + * Each cloud is a group of 2-3 overlapping ellipses at very low opacity. + * Positions are deterministic based on index. + * Drift animation: garden-cloud-drift (defined in index.css). + */ + +import { memo } from "react"; + +interface CloudLayerProps { + count: number; + sceneWidth: number; + sceneHeight: number; +} + +/** Deterministic value in [0, range) for a given index + seed. */ +function det(i: number, range: number, seed = 37): number { + return ((i * seed + i * i * 13) % 1000) / 1000 * range; +} + +function detRange(i: number, min: number, max: number, seed = 37): number { + return min + det(i, max - min, seed); +} + +interface CloudProps { + cx: number; + cy: number; + /** Width of the main body ellipse */ + bodyWidth: number; + /** Height of the main body ellipse */ + bodyHeight: number; + opacity: number; + driftDuration: number; + driftDelay: number; +} + +function Cloud({ cx, cy, bodyWidth, bodyHeight, opacity, driftDuration, driftDelay }: CloudProps) { + // Build 2-3 ellipses that overlap to form a fluffy shape + const bumps = [ + // main body + { rx: bodyWidth / 2, ry: bodyHeight / 2, dx: 0, dy: 0 }, + // left puff + { rx: bodyWidth * 0.35, ry: bodyHeight * 0.6, dx: -(bodyWidth * 0.3), dy: bodyHeight * 0.1 }, + // right puff + { rx: bodyWidth * 0.3, ry: bodyHeight * 0.5, dx: bodyWidth * 0.32, dy: bodyHeight * 0.15 }, + ]; + + return ( + + ); +} + +export const CloudLayer = memo(function CloudLayer({ + count, + sceneWidth, + sceneHeight, +}: CloudLayerProps) { + const clampedCount = Math.max(0, Math.min(8, Math.round(count))); + if (clampedCount === 0) return null; + + // Sky area = top 40% of scene + const skyMaxY = sceneHeight * 0.4; + + return ( + + ); +}); diff --git a/src/bmo/FeatureFlags.ts b/src/bmo/FeatureFlags.ts new file mode 100644 index 0000000..2864019 --- /dev/null +++ b/src/bmo/FeatureFlags.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; + +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +// ---------- Global Feature Flags ---------- +// Single source of truth for BMO feature flags. Imported by BmoAvatar, GardenLayers, etc. +// Also attached to `window.BMO_FLAGS` so you can toggle at runtime from the browser console. +// +// Dream additions: `gardenImmersive`, `bmoEmotes`, `overviewGardenBand` act as +// kill-switches for the new integration work. Flip them from the browser +// console (`window.BMO_FLAGS.gardenImmersive = false`). + +export const FEATURE_FLAGS = { + voiceLines: false, + screenContent: false, + gardenImmersive: true, + bmoEmotes: true, + overviewGardenBand: true, + bmoThumbnails: true, +}; + +export type FeatureFlags = typeof FEATURE_FLAGS; + +declare global { + interface Window { + BMO_FLAGS?: FeatureFlags; + } +} + +if (typeof window !== "undefined") { + window.BMO_FLAGS = FEATURE_FLAGS; +} + +export function readFeatureFlag(name: keyof FeatureFlags): boolean { + if (typeof window === "undefined") return FEATURE_FLAGS[name]; + return window.BMO_FLAGS?.[name] ?? FEATURE_FLAGS[name]; +} + +export function useFeatureFlag(name: keyof FeatureFlags) { + const [enabled, setEnabled] = useState(() => readFeatureFlag(name)); + + useEffect(() => { + setEnabled(readFeatureFlag(name)); + + const timer = window.setInterval(() => { + setEnabled((current) => { + const next = readFeatureFlag(name); + return current === next ? current : next; + }); + }, 500); + + return () => window.clearInterval(timer); + }, [name]); + + return enabled; +} diff --git a/src/bmo/FloraLayer.tsx b/src/bmo/FloraLayer.tsx new file mode 100644 index 0000000..5979a35 --- /dev/null +++ b/src/bmo/FloraLayer.tsx @@ -0,0 +1,213 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; + +interface FloraConfig { + vinesEnabled?: boolean; + fernsEnabled?: boolean; + flowersEnabled?: boolean; + mushroomsEnabled?: boolean; + mushroomGlowColor?: string; +} + +interface FloraLayerProps { + config: FloraConfig; + sceneWidth: number; + sceneHeight: number; +} + +// ---- Sub-components (extracted from AgentGarden.tsx) ---- + +function Plant({ x, y }: { x: number; y: number }) { + return ( + + + + + + + + ); +} + +function TallFern({ x, y }: { x: number; y: number }) { + return ( + + + + + + + + + + + + + + ); +} + +function HangingVine({ x, y, length }: { x: number; y: number; length: number }) { + return ( + + + {Array.from({ length: Math.floor(length / 8) }, (_, i) => { + const ly = y + (i + 1) * 8; + const lx = x + (i % 2 === 0 ? -2 : 2) + Math.sin(i) * 1.5; + return ( + + ); + })} + + ); +} + +function GlowMushroom({ x, y, hue }: { x: number; y: number; hue?: string }) { + const color = hue ?? "#4ade80"; + return ( + + + + + + + + + + ); +} + +export const FloraLayer = React.memo(function FloraLayer({ + config, + sceneWidth, + sceneHeight, +}: FloraLayerProps) { + return ( + <> + {/* Hanging vines from trees */} + {config.vinesEnabled && ( + <> + + {sceneWidth > 350 && ( + + )} + + )} + + {/* Tall ferns/bushes at edges */} + {config.fernsEnabled && ( + <> + + {sceneWidth > 450 && } + + )} + + {/* Flower beds / garden patches */} + {config.flowersEnabled && sceneWidth > 350 && ( + + + + {config.mushroomsEnabled && ( + + )} + + )} + + {/* More mushrooms scattered */} + {config.mushroomsEnabled && ( + <> + + {sceneWidth > 400 && ( + + )} + + )} + + ); +}); + +// Export sub-components for reuse +export { Plant, TallFern, HangingVine, GlowMushroom }; diff --git a/src/bmo/GroundFog.tsx b/src/bmo/GroundFog.tsx new file mode 100644 index 0000000..8883395 --- /dev/null +++ b/src/bmo/GroundFog.tsx @@ -0,0 +1,66 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +/** + * GroundFog — subtle fog effect rendered as blurred ellipses at ground level. + * + * Renders 3-5 large Gaussian-blurred ellipses near sceneHeight * 0.6 with + * very low opacity. Drifts gently with garden-fog-drift animation (index.css). + */ + +import { memo } from "react"; + +interface GroundFogProps { + sceneWidth: number; + sceneHeight: number; +} + +/** Deterministic value in [0, range) for index + seed. */ +function det(i: number, range: number, seed = 37): number { + return ((i * seed + i * i * 13) % 1000) / 1000 * range; +} + +function detRange(i: number, min: number, max: number, seed = 37): number { + return min + det(i, max - min, seed); +} + +export const GroundFog = memo(function GroundFog({ sceneWidth, sceneHeight }: GroundFogProps) { + const fogCount = 4; + const filterId = "ground-fog-blur"; + + return ( + + ); +}); diff --git a/src/bmo/SeasonalOverlay.tsx b/src/bmo/SeasonalOverlay.tsx new file mode 100644 index 0000000..965c9dc --- /dev/null +++ b/src/bmo/SeasonalOverlay.tsx @@ -0,0 +1,217 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +/** + * SeasonalOverlay — renders ground-level seasonal decorations as an SVG group. + * + * Supported overlays: autumn-leaves, snow, cherry-blossoms. + * Positions are deterministic (index-based). No Math.random(). + * Cherry blossom petals in the air use a gentle drift animation + * (garden-pollen-drift) — same keyframe, different colors. + */ + +import { memo } from "react"; + +interface SeasonalOverlayProps { + overlay: string; + sceneWidth: number; + sceneHeight: number; +} + +/** Deterministic value in [0, range) for a given index + seed. */ +function det(i: number, range: number, seed = 37): number { + return ((i * seed + i * i * 13) % 1000) / 1000 * range; +} + +function detRange(i: number, min: number, max: number, seed = 37): number { + return min + det(i, max - min, seed); +} + +// ---- Autumn leaves ---- + +const AUTUMN_COLORS = ["#f59e0b", "#ea580c", "#dc2626", "#92400e", "#d97706", "#b45309"]; + +function leafPath(): string { + return "M0,-3 C1.5,-1.5 3,0 1.5,3 C0,4.5 -1.5,3 0,0 C-1.5,-1.5 -3,0 0,-3Z"; +} + +function AutumnLeaves({ + count, + sceneWidth, + sceneHeight, +}: { + count: number; + sceneWidth: number; + sceneHeight: number; +}) { + const groundMinY = sceneHeight * 0.60; + const groundMaxY = sceneHeight * 0.70; + + return ( + <> + {Array.from({ length: count }, (_, i) => { + const x = detRange(i, 10, sceneWidth - 10, 41); + const y = detRange(i, groundMinY, groundMaxY, 59); + const rotate = detRange(i, -45, 45, 73); + const scale = detRange(i, 0.6, 1.2, 89); + const color = AUTUMN_COLORS[i % AUTUMN_COLORS.length]; + return ( + + ); + })} + + ); +} + +// ---- Snow ---- + +function SnowOverlay({ + sceneWidth, + sceneHeight, +}: { + sceneWidth: number; + sceneHeight: number; +}) { + // Wavy ground cover path + const groundY = sceneHeight * 0.63; + const amplitude = 3; + const segments = 12; + const segW = sceneWidth / segments; + + let d = `M0,${groundY}`; + for (let i = 0; i <= segments; i++) { + const x = i * segW; + const y = groundY + (i % 2 === 0 ? -amplitude : amplitude); + if (i === 0) d += ` L${x},${y}`; + else d += ` Q${x - segW / 2},${y + amplitude * 0.5} ${x},${y}`; + } + d += ` L${sceneWidth},${sceneHeight} L0,${sceneHeight} Z`; + + // Small snow pile ellipses near ground features + const pilePositions = Array.from({ length: 8 }, (_, i) => ({ + cx: detRange(i, 15, sceneWidth - 15, 43), + cy: detRange(i, sceneHeight * 0.60, sceneHeight * 0.67, 61), + rx: detRange(i, 6, 14, 73), + ry: detRange(i, 2, 5, 89), + })); + + return ( + <> + {/* Wavy ground cover */} + + {/* Snow piles */} + {pilePositions.map((p, i) => ( + + ))} + + ); +} + +// ---- Cherry blossoms ---- + +const BLOSSOM_COLORS = ["#fbb6ce", "#f9a8d4", "#f472b6", "#fbcfe8"]; + +function CherryBlossoms({ + count, + sceneWidth, + sceneHeight, +}: { + count: number; + sceneWidth: number; + sceneHeight: number; +}) { + const airCount = Math.floor(count * 0.6); + const groundCount = count - airCount; + + return ( + <> + {/* Air petals — animated drift */} + {Array.from({ length: airCount }, (_, i) => { + const cx = detRange(i, 5, sceneWidth - 5, 43); + const cy = detRange(i, sceneHeight * 0.1, sceneHeight * 0.55, 59); + const r = detRange(i, 1, 2.5, 71); + const color = BLOSSOM_COLORS[i % BLOSSOM_COLORS.length]; + const duration = detRange(i, 5, 10, 83); + const delay = detRange(i, 0, 7, 97); + return ( + + ); + })} + {/* Ground petals — static */} + {Array.from({ length: groundCount }, (_, i) => { + const cx = detRange(i, 10, sceneWidth - 10, 101); + const cy = detRange(i, sceneHeight * 0.60, sceneHeight * 0.68, 113); + const r = detRange(i, 0.8, 2, 127); + const color = BLOSSOM_COLORS[i % BLOSSOM_COLORS.length]; + return ( + + ); + })} + + ); +} + +// ---- Main component ---- + +export const SeasonalOverlay = memo(function SeasonalOverlay({ + overlay, + sceneWidth, + sceneHeight, +}: SeasonalOverlayProps) { + if (overlay === "none" || !overlay) return null; + + if (overlay === "autumn-leaves") { + return ( + + ); + } + + if (overlay === "snow") { + return ( + + ); + } + + if (overlay === "cherry-blossoms") { + return ( + + ); + } + + return null; +}); diff --git a/src/bmo/SkyLayer.tsx b/src/bmo/SkyLayer.tsx new file mode 100644 index 0000000..8fff34c --- /dev/null +++ b/src/bmo/SkyLayer.tsx @@ -0,0 +1,105 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; + +interface SkyConfig { + gradientStops: [string, string, string]; + starDensity: number; + starBrightness: number; + moonX: number; + moonY: number; + moonSize: number; + moonPhase: "full" | "crescent" | "waning" | "new"; +} + +interface SkyLayerProps { + config: SkyConfig; + sceneWidth: number; + sceneHeight: number; +} + +export const SkyLayer = React.memo(function SkyLayer({ + config, + sceneWidth, + sceneHeight, +}: SkyLayerProps) { + const [stop0, stop40, stop100] = config.gradientStops; + + // Deterministic star positions derived from index + const starCount = Math.round(8 + config.starDensity * 16); + const BASE_STAR_XS = [15, 45, 80, 130, 200, 260, 320, 370, 410, 470, 510, 560, 600, 640, 680, 720, 760, 800, 840, 880, 920, 960, 1000, 1040]; + const starPositions = Array.from({ length: starCount }, (_, i) => ({ + sx: BASE_STAR_XS[i % BASE_STAR_XS.length]!, + sy: 8 + (i * 7) % 35, + r: 0.5 + (i % 3) * 0.2, + opacity: (0.15 + (i % 3) * 0.1) * (0.5 + config.starBrightness), + delay: i * 1.3, + })); + + // Moon size + position + const moonCx = sceneWidth * config.moonX; + const moonCy = sceneHeight * config.moonY; + const moonR = 8 * config.moonSize; + const moonOuterR = 12 * config.moonSize; + + return ( + <> + {/* Sky gradient definition */} + + + + + + + + + {/* Sky background */} + + + {/* Stars */} + {starPositions.map((s, i) => ( + + ))} + + {/* Moon */} + {config.moonPhase !== "new" && ( + <> + + + {/* Waning/crescent mask */} + {config.moonPhase === "crescent" && ( + + )} + {config.moonPhase === "waning" && ( + + )} + + )} + + {/* Distant tree line silhouette */} + {Array.from({ length: Math.ceil(sceneWidth / 30) + 1 }, (_, i) => { + const tx = i * 30 + (i % 2 === 0 ? 5 : -3); + const th = 18 + (i * 7) % 15; + return ( + + ); + })} + + ); +}); diff --git a/src/bmo/TerrainLayer.tsx b/src/bmo/TerrainLayer.tsx new file mode 100644 index 0000000..563a2f0 --- /dev/null +++ b/src/bmo/TerrainLayer.tsx @@ -0,0 +1,101 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +import React from "react"; + +interface TerrainConfig { + grassGradient: [string, string]; + pathStyle: "none" | "cobblestone"; + pathOpacity: number; +} + +interface FloraConfig { + grassTuftsEnabled?: boolean; +} + +interface TerrainLayerProps { + config: TerrainConfig; + floraConfig?: FloraConfig; + sceneWidth: number; + sceneHeight: number; +} + +export const TerrainLayer = React.memo(function TerrainLayer({ + config, + floraConfig, + sceneWidth, + sceneHeight, +}: TerrainLayerProps) { + const grassTuftsEnabled = floraConfig?.grassTuftsEnabled ?? true; + const [grassTop, grassBottom] = config.grassGradient; + + return ( + <> + + + + + + + + {/* Grass ground */} + + + {/* Stone path stepping stones */} + {config.pathStyle !== "none" && + Array.from({ length: Math.ceil(sceneWidth / 50) }, (_, i) => ( + + ))} + + {/* Grass tufts at floor edge */} + {grassTuftsEnabled && + Array.from({ length: Math.ceil(sceneWidth / 25) }, (_, i) => { + const gx = 10 + i * 25 + (i * 7) % 12; + const gy = sceneHeight * 0.6 + 2 + (i * 11) % 8; + return ( + + + + + + ); + })} + + ); +}); diff --git a/src/bmo/WeatherEffects.tsx b/src/bmo/WeatherEffects.tsx new file mode 100644 index 0000000..bf60ad8 --- /dev/null +++ b/src/bmo/WeatherEffects.tsx @@ -0,0 +1,239 @@ +// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. +/** + * WeatherEffects — SVG group that renders animated weather particles. + * + * Supports: rain, snow, falling-leaves, pollen. + * Particle count scales with `intensity` (0-1). + * Positions are deterministic (index-based) — no Math.random(). + * + * All animations must be defined in index.css as garden-rain-fall, + * garden-snow-fall, garden-leaf-fall, and garden-pollen-drift. + */ + +import { memo } from "react"; + +interface WeatherEffectsProps { + weather: string; + intensity: number; + sceneWidth: number; + sceneHeight: number; +} + +// ---- Deterministic helpers ---- + +/** Returns a value in [0, range) based on the index using a simple hash. */ +function det(i: number, range: number, seed = 37): number { + return ((i * seed + i * i * 13) % 1000) / 1000 * range; +} + +function detRange(i: number, min: number, max: number, seed = 37): number { + return min + det(i, max - min, seed); +} + +// ---- Rain ---- + +function RainParticles({ + count, + sceneWidth, + sceneHeight, +}: { + count: number; + sceneWidth: number; + sceneHeight: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, i) => { + const x = detRange(i, 0, sceneWidth, 37); + const length = detRange(i, 4, 8, 53); + const duration = detRange(i, 1, 2, 71); + const delay = detRange(i, 0, 2, 89); + const sw = detRange(i, 0.5, 1, 43); + return ( + + ); + })} + + ); +} + +// ---- Snow ---- + +function SnowParticles({ + count, + sceneWidth, +}: { + count: number; + sceneWidth: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, i) => { + const cx = detRange(i, 0, sceneWidth, 41); + const cy = detRange(i, 0, 20, 59); + const r = detRange(i, 0.5, 1.5, 67); + const opacity = detRange(i, 0.3, 0.6, 79); + const duration = detRange(i, 4, 8, 97); + const delay = detRange(i, 0, 5, 113); + return ( + + ); + })} + + ); +} + +// ---- Falling leaves ---- + +/** A tiny leaf silhouette path centered at 0,0 (~4px across). */ +function leafPath(): string { + return "M0,-2 C1,-1 2,0 1,2 C0,3 -1,2 0,0 C-1,-1 -2,0 0,-2Z"; +} + +const LEAF_COLORS = ["#f59e0b", "#ea580c", "#dc2626", "#92400e", "#d97706"]; + +function FallingLeaves({ + count, + sceneWidth, +}: { + count: number; + sceneWidth: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, i) => { + const x = detRange(i, 0, sceneWidth, 43); + const y = detRange(i, -20, 0, 61); + const color = LEAF_COLORS[i % LEAF_COLORS.length]; + const duration = detRange(i, 5, 10, 83); + const delay = detRange(i, 0, 6, 101); + const scale = detRange(i, 0.8, 1.5, 107); + return ( + + ); + })} + + ); +} + +// ---- Pollen ---- + +const POLLEN_COLORS = ["#fde047", "#a3e635", "#bef264", "#fef08a"]; + +function PollenParticles({ + count, + sceneWidth, + sceneHeight, +}: { + count: number; + sceneWidth: number; + sceneHeight: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, i) => { + const cx = detRange(i, 0, sceneWidth, 47); + const cy = detRange(i, sceneHeight * 0.3, sceneHeight * 0.8, 71); + const r = detRange(i, 0.3, 0.8, 89); + const color = POLLEN_COLORS[i % POLLEN_COLORS.length]; + const duration = detRange(i, 6, 12, 97); + const delay = detRange(i, 0, 8, 113); + return ( + + ); + })} + + ); +} + +// ---- Main component ---- + +export const WeatherEffects = memo(function WeatherEffects({ + weather, + intensity, + sceneWidth, + sceneHeight, +}: WeatherEffectsProps) { + if (weather === "none" || intensity <= 0) return null; + + const clampedIntensity = Math.max(0, Math.min(1, intensity)); + + if (weather === "rain") { + const count = Math.round(20 + clampedIntensity * 40); + return ( + + ); + } + + if (weather === "snow") { + const count = Math.round(15 + clampedIntensity * 25); + return ( + + ); + } + + if (weather === "falling-leaves") { + const count = Math.round(8 + clampedIntensity * 12); + return ( + + ); + } + + if (weather === "pollen") { + const count = Math.round(10 + clampedIntensity * 15); + return ( + + ); + } + + return null; +}); diff --git a/src/bmo/bmo-animations.css b/src/bmo/bmo-animations.css new file mode 100644 index 0000000..b1e8f5d --- /dev/null +++ b/src/bmo/bmo-animations.css @@ -0,0 +1,236 @@ +/* Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. */ +/* Shared animation CSS for BMO pages (preview/personality pages). */ + +/* ---------- Page layout (used by personality pages) ---------- */ +* { box-sizing: border-box; } +body.personality-page { + margin: 0; background: #0a0e1a; color: #eee; font-family: sans-serif; + min-height: 100vh; padding: 20px; +} +.personality-page .wrap { max-width: 1200px; margin: 0 auto; } +.personality-page .nav { + display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; +} +.personality-page .nav a { + padding: 6px 14px; background: #1a2028; color: #aaa; + border-radius: 6px; text-decoration: none; font-size: 12px; + font-weight: bold; letter-spacing: 0.5px; border: 1px solid #333; +} +.personality-page .nav a:hover { background: #2a3038; color: #eee; } +.personality-page h1 { + margin: 0 0 4px; font-size: 1.4rem; letter-spacing: 2px; + text-transform: uppercase; text-align: center; +} +.personality-page .subtitle { + margin: 0 0 20px; font-size: 11px; color: #567; + text-transform: uppercase; letter-spacing: 1px; text-align: center; +} + +/* Three-column layout: buttons | stage | buttons */ +.personality-page .layout { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(320px, 1fr) minmax(180px, 240px); + gap: 24px; align-items: start; +} +.personality-page .controls-left { justify-self: end; width: 100%; max-width: 240px; } +.personality-page .controls-right { justify-self: start; width: 100%; max-width: 240px; } +.personality-page .stage { + background: #1a2028; border-radius: 16px; padding: 30px 20px; + display: flex; align-items: center; justify-content: center; + position: sticky; top: 12px; +} +.personality-page .stage svg { max-width: 100%; height: auto; } + +.personality-page .row { margin-bottom: 14px; } +.personality-page .row-label { + display: block; font-size: 10px; color: #5ce0d8; + letter-spacing: 1.5px; text-transform: uppercase; font-weight: bold; + margin-bottom: 6px; +} +.personality-page .row-btns { display: flex; flex-wrap: wrap; gap: 4px; } +.personality-page .pb { + padding: 6px 12px; border: 1px solid #333; border-radius: 6px; + background: #1a2028; color: #aaa; cursor: pointer; font-size: 11px; + font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; transition: all 0.15s; +} +.personality-page .pb:hover { background: #2a3038; color: #eee; } +.personality-page .pb.active { background: #2a7a78; color: #fff; border-color: #5ce0d8; } +.personality-page .reset-row { text-align: center; margin-top: 24px; } +.personality-page .reset { + padding: 8px 20px; border: 1px solid #5a1a1a; border-radius: 6px; + background: #2a0f0f; color: #f87171; cursor: pointer; font-size: 11px; + font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; +} +.personality-page .reset:hover { background: #4a1a1a; } + +/* Align left column contents to the right so buttons sit close to the avatar */ +.personality-page .controls-left .row-label { text-align: right; } +.personality-page .controls-left .row-btns { justify-content: flex-end; } + +/* Tablet / mobile: stack, stage first (avatar visible on load + sticky while scrolling) */ +@media (max-width: 900px) { + body.personality-page { padding: 12px; } + .personality-page .layout { + display: flex; flex-direction: column; gap: 12px; + } + .personality-page .stage { order: -1; } + .personality-page .controls-left, + .personality-page .controls-right { + justify-self: stretch; max-width: none; + } + .personality-page .controls-left .row-label, + .personality-page .controls-right .row-label { text-align: center; } + .personality-page .controls-left .row-btns, + .personality-page .controls-right .row-btns { justify-content: center; } + .personality-page .stage { + position: sticky; top: 0; z-index: 5; + padding: 12px; border-radius: 12px; + box-shadow: 0 6px 20px rgba(0,0,0,0.5); + } + .personality-page h1 { font-size: 1.1rem; } + .personality-page .pb { padding: 8px 14px; font-size: 12px; } + .personality-page .nav a { font-size: 11px; padding: 5px 10px; } +} + +/* ---------- Core BMO animations ---------- */ +@keyframes bmo-bob-active { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +@keyframes bmo-bob-working { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } } +@keyframes bmo-sway-idle { 0%,100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } } +@keyframes bmo-shake-blocked{ 0%,100% { transform: translateX(0); } 25% { transform: translateX(-1px); } 75% { transform: translateX(1px); } } +@keyframes bmo-blink { 0%,92%,100% { transform: scaleY(1); } 95% { transform: scaleY(0.1); } } +.bmo-bob-active { animation: bmo-bob-active 1.2s ease-in-out infinite; transform-origin: center; } +.bmo-bob-working { animation: bmo-bob-working 2s ease-in-out infinite; transform-origin: center; } +.bmo-sway-idle { animation: bmo-sway-idle 4s ease-in-out infinite; transform-origin: center bottom; } +.bmo-shake-blocked { animation: bmo-shake-blocked 0.2s ease-in-out infinite; transform-origin: center; } +.bmo-blink { animation: bmo-blink 4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; } + +/* ---------- Accessories, screen content, voice lines ---------- */ +@keyframes bmo-cape-sway { 0%,100% { transform: rotate(0deg); } 50% { transform: rotate(2deg); } } +@keyframes bmo-code-scroll { 0% { transform: translateY(0); } 100% { transform: translateY(-8px); } } +@keyframes bmo-scan-bar { 0% { transform: scaleX(0); transform-origin: left; } 50% { transform: scaleX(1); transform-origin: left; } 51% { transform: scaleX(1); transform-origin: right; } 100% { transform: scaleX(0); transform-origin: right; } } +@keyframes bmo-radar-sweep { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } +@keyframes bmo-voice-fade { 0%,20% { opacity: 0; } 30%,70% { opacity: 0.25; } 80%,100% { opacity: 0; } } +@keyframes bmo-flash-exclaim{ 0%,100% { opacity: 0.6; } 50% { opacity: 0; } } +.bmo-cape-sway { animation: bmo-cape-sway 4s ease-in-out infinite; } +.bmo-code-scroll { animation: bmo-code-scroll 3s linear infinite; } +.bmo-scan-bar { animation: bmo-scan-bar 2.5s ease-in-out infinite; } +.bmo-radar-sweep { animation: bmo-radar-sweep 4s linear infinite; } +.bmo-voice-fade { animation: bmo-voice-fade 3s ease-in-out infinite; } +.bmo-flash-exclaim{ animation: bmo-flash-exclaim 1s ease-in-out infinite; } + +/* ---------- Idle micro-animations, state transitions ---------- */ +@keyframes bmo-idle-director { 0%,100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg); } } +@keyframes bmo-idle-type-burst { 0%,84% { transform: rotate(0deg); } 85% { transform: rotate(5deg); } 87% { transform: rotate(-5deg); } 89% { transform: rotate(4deg); } 91% { transform: rotate(-3deg); } 93% { transform: rotate(2deg); } 95% { transform: rotate(-1deg); } 96%,100% { transform: rotate(0deg); } } +@keyframes bmo-idle-qa { 0%,100% { transform: translateX(0); } 25% { transform: translateX(1.5px); } 50% { transform: translateX(-1px); } 75% { transform: translateX(0.5px); } } +@keyframes bmo-antenna-pulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } } +@keyframes bmo-enter-blocked { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } } +.bmo-idle-director { animation: bmo-idle-director 6s ease-in-out infinite; transform-origin: center bottom; } +.bmo-idle-type-burst { animation: bmo-idle-type-burst 6s ease-in-out infinite; } +.bmo-idle-qa { animation: bmo-idle-qa 2s ease-in-out infinite; } +.bmo-antenna-pulse { animation: bmo-antenna-pulse 2s ease-in-out infinite; } +.bmo-face-transition { transition: opacity 0.3s ease, transform 0.3s ease; } +.bmo-enter-blocked { animation: bmo-enter-blocked 0.4s ease-in-out 1; } + +/* ---------- Emotes, speech bubbles ---------- */ +@keyframes bmo-emote-celebrate { 0% { transform: translateY(0); } 40% { transform: translateY(-15px); } 100% { transform: translateY(0); } } +@keyframes bmo-emote-heart-float { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-25px); opacity: 0; } } +@keyframes bmo-emote-confused { 0% { transform: rotate(0deg); } 40% { transform: rotate(-8deg); } 100% { transform: rotate(0deg); } } +@keyframes bmo-emote-eureka-glow { 0% { transform: scale(0); } 50% { transform: scale(1.2); filter: drop-shadow(0 0 6px #fbbf24); } 100% { transform: scale(1); filter: drop-shadow(0 0 2px #fbbf24); } } +@keyframes bmo-emote-error-flash { 0% { opacity: 0; } 50% { opacity: 0.3; } 100% { opacity: 0; } } +@keyframes bmo-speech-enter { 0% { transform: scale(0); } 70% { transform: scale(1.1); } 100% { transform: scale(1); } } +@keyframes bmo-typing-dot { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } } +.bmo-emote-celebrate { animation: bmo-emote-celebrate 0.8s ease-in-out forwards; animation-iteration-count: 1; transform-origin: center bottom; } +.bmo-emote-heart-float { animation: bmo-emote-heart-float 1.2s ease-out forwards; animation-iteration-count: 1; } +.bmo-emote-confused { animation: bmo-emote-confused 0.6s ease-in-out forwards; animation-iteration-count: 1; transform-origin: center; } +.bmo-emote-eureka-glow { animation: bmo-emote-eureka-glow 1s ease-out forwards; animation-iteration-count: 1; transform-origin: center; } +.bmo-emote-error-flash { animation: bmo-emote-error-flash 0.5s ease-in-out forwards; animation-iteration-count: 1; } +.bmo-speech-enter { animation: bmo-speech-enter 0.3s ease-out forwards; transform-origin: bottom left; } +.bmo-typing-dot { animation: bmo-typing-dot 1.2s ease-in-out infinite; } + +/* ---------- Walking animations ---------- */ +@keyframes bmo-walk-engineer-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-2px) translateX(0.5px); } 50% { transform: translateY(0); } 75% { transform: translateY(-2px) translateX(-0.5px); } } +@keyframes bmo-walk-director-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-1.5px) translateX(1px); } 50% { transform: translateY(0); } 75% { transform: translateY(-1.5px) translateX(-1px); } } +@keyframes bmo-walk-qa-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-1px); } 50% { transform: translateY(0); } 75% { transform: translateY(-1px); } } +@keyframes bmo-walk-command-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-3px); } 50% { transform: translateY(0); } 75% { transform: translateY(-3px); } } +@keyframes bmo-walk-default-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-2px); } 50% { transform: translateY(0); } 75% { transform: translateY(-2px); } } +.bmo-walk-engineer-legs { animation: bmo-walk-engineer-legs 0.6s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-director-legs { animation: bmo-walk-director-legs 1.2s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-qa-legs { animation: bmo-walk-qa-legs 0.4s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-command-legs { animation: bmo-walk-command-legs 0.8s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-default-legs { animation: bmo-walk-default-legs 0.8s ease-in-out infinite; transform-origin: center bottom; } +@keyframes bmo-walk-arms-l { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(10deg); } 75% { transform: rotate(-10deg); } } +@keyframes bmo-walk-arms-r { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-10deg); } 75% { transform: rotate(10deg); } } +.bmo-walk-engineer-arms-l { animation: bmo-walk-arms-l 0.6s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-engineer-arms-r { animation: bmo-walk-arms-r 0.6s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-director-arms-l { animation: bmo-walk-arms-l 1.2s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-director-arms-r { animation: bmo-walk-arms-r 1.2s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-qa-arms-l { animation: bmo-walk-arms-l 0.4s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-qa-arms-r { animation: bmo-walk-arms-r 0.4s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-command-arms-l { animation: bmo-walk-arms-l 0.8s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-command-arms-r { animation: bmo-walk-arms-r 0.8s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-default-arms-l { animation: bmo-walk-arms-l 0.8s ease-in-out infinite; transform-origin: top center; } +.bmo-walk-default-arms-r { animation: bmo-walk-arms-r 0.8s ease-in-out infinite; transform-origin: top center; } +@keyframes bmo-walk-bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } } +.bmo-walk-bounce-fast { animation: bmo-walk-bounce 0.3s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-bounce-slow { animation: bmo-walk-bounce 0.6s ease-in-out infinite; transform-origin: center bottom; } +.bmo-walk-bounce-default { animation: bmo-walk-bounce 0.4s ease-in-out infinite; transform-origin: center bottom; } + +/* ---------- Weather interaction ---------- */ +@keyframes bmo-rain-shiver { 0%,88%,100% { transform: translateX(0); } 90% { transform: translateX(-1.5px); } 92% { transform: translateX(1.5px); } 94% { transform: translateX(-1px); } 96% { transform: translateX(1px); } } +@keyframes bmo-snow-huddle { 0%,100% { transform: scaleY(1); } 50% { transform: scaleY(0.98); } } +@keyframes bmo-breath-puff { 0%,65% { opacity: 0; transform: translateY(0) scale(0.5); } 72% { opacity: 0.25; transform: translateY(-2px) scale(1); } 100% { opacity: 0; transform: translateY(-10px) scale(1.2); } } +@keyframes bmo-leaf-swat { 0%,82%,100% { transform: rotate(0deg); } 88% { transform: rotate(-20deg); } 94% { transform: rotate(5deg); } } +@keyframes bmo-leaf-look-up { 0%,78%,100% { transform: translateY(0); } 83%,93% { transform: translateY(-1.5px); } } +@keyframes bmo-mushroom-reflect { 0%,100% { opacity: 0.06; } 50% { opacity: 0.12; } } +.bmo-rain-shiver { animation: bmo-rain-shiver 5s ease-in-out infinite; } +.bmo-snow-huddle { animation: bmo-snow-huddle 4s ease-in-out infinite; transform-origin: center bottom; } +.bmo-breath-puff { animation: bmo-breath-puff 4s ease-in-out infinite; } +.bmo-leaf-swat { animation: bmo-leaf-swat 8s ease-in-out infinite; transform-origin: top center; } +.bmo-leaf-look-up { animation: bmo-leaf-look-up 6s ease-in-out infinite; } +.bmo-mushroom-reflect { animation: bmo-mushroom-reflect 3s ease-in-out infinite; } + +/* ---------- Working state: code editor + mini face ---------- */ +@keyframes bmo-face-to-corner { + 0% { transform: translate(0, 0) scale(1); opacity: 1; } + 60% { transform: translate(-8px, -6px) scale(0.3); opacity: 0.3; } + 100% { transform: translate(-10px, -8px) scale(0.15); opacity: 0; } +} +@keyframes bmo-code-window-enter { 0% { opacity: 0; transform: scale(0.85); } 100% { opacity: 1; transform: scale(1); } } +@keyframes bmo-code-scroll-up { 0% { transform: translateY(0); } 100% { transform: translateY(-40px); } } +@keyframes bmo-cursor-blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } } +.bmo-face-to-corner { animation: bmo-face-to-corner 1.5s ease-in-out forwards; } +.bmo-code-window-enter { animation: bmo-code-window-enter 1s ease-out forwards; } +.bmo-code-scroll-group { animation: bmo-code-scroll-up 10s linear infinite; } +.bmo-cursor-blink { animation: bmo-cursor-blink 0.8s step-end infinite; } + +/* ---------- Extended wrapper animations ---------- */ +@keyframes bounce-in { 0% { transform: scale(0); } 60% { transform: scale(1.15); } 100% { transform: scale(1); } } +@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } +@keyframes pulse-glow { 0%,100% { filter: drop-shadow(0 0 4px rgba(92,224,216,0.3)); } 50% { filter: drop-shadow(0 0 16px rgba(92,224,216,0.8)); } } +@keyframes hop { 0%,100% { transform: translateY(0); } 30% { transform: translateY(-12px); } 50% { transform: translateY(-8px); } 70% { transform: translateY(-12px); } } +@keyframes wiggle { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-8deg); } 75% { transform: rotate(8deg); } } +@keyframes slide-in { 0% { transform: translateX(-60px); opacity: 0; } 100% { transform: translateX(0); opacity: 1; } } +@keyframes jelly { 0%,100% { transform: scale(1,1); } 25% { transform: scale(0.9,1.1); } 50% { transform: scale(1.05,0.95); } 75% { transform: scale(0.95,1.05); } } +.anim-bounce-in { animation: bounce-in 0.8s ease-out both; } +.anim-spin { animation: spin 2s linear infinite; transform-origin: center; } +.anim-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; } +.anim-hop { animation: hop 1s ease-in-out infinite; transform-origin: center bottom; } +.anim-wiggle { animation: wiggle 0.6s ease-in-out infinite; transform-origin: center bottom; } +.anim-slide-in { animation: slide-in 0.6s ease-out both; } +.anim-jelly { animation: jelly 1.5s ease-in-out infinite; transform-origin: center bottom; } + +/* ---------- Extra wrapper/particle animations (animations.html) ---------- */ +@keyframes float-up { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-20px); opacity: 0; } } +@keyframes wave-arms { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-15deg); } 75% { transform: rotate(15deg); } } +@keyframes heart-float { 0% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(-30px) scale(1.5); opacity: 0; } } +@keyframes spark { 0% { transform: scale(0) rotate(0deg); opacity: 1; } 100% { transform: scale(1.5) rotate(180deg); opacity: 0; } } + +/* ---------- Garden scene animations ---------- */ +@keyframes garden-star-twinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.2; } } +@keyframes garden-cloud-drift { 0% { transform: translateX(0); } 100% { transform: translateX(60px); } } +@keyframes garden-fog-drift { 0%,100% { transform: translateX(0); opacity: 0.25; } 50% { transform: translateX(15px); opacity: 0.15; } } +@keyframes garden-grass-sway { 0%,100% { transform: rotate(0deg); } 50% { transform: rotate(3deg); } } +@keyframes garden-glow-pulse { 0%,100% { opacity: 0.15; } 50% { opacity: 0.35; } } +@keyframes garden-rain-fall { 0% { transform: translateY(-20px); opacity: 0; } 20% { opacity: 0.4; } 100% { transform: translateY(100px); opacity: 0; } } +@keyframes garden-snow-fall { 0% { transform: translateY(-10px) translateX(0); opacity: 0; } 20% { opacity: 0.6; } 100% { transform: translateY(80px) translateX(15px); opacity: 0; } } +@keyframes garden-leaf-fall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 0; } 20% { opacity: 0.7; } 100% { transform: translateY(90px) rotate(180deg); opacity: 0; } } diff --git a/src/bmo/index.ts b/src/bmo/index.ts new file mode 100644 index 0000000..ea44c51 --- /dev/null +++ b/src/bmo/index.ts @@ -0,0 +1,35 @@ +// Barrel export for the verbatim bmo-preview port. +// All visuals remain 1:1 with upstream. Dream-specific composition lives in +// ../agent/, never here. + +export { BmoAvatar, getPalette, KNOWN_PALETTES, FALLBACK_PALETTES, agentStatusToBmoState } from "./BmoAvatar"; +export type { BmoColorPalette, BmoState } from "./BmoAvatar"; + +export { + PERSONALITY_ACCESSORIES, + renderCrown, + renderCape, + renderTopHat, + renderBadge, + renderGlasses, + renderCoolGlasses, + renderBowTie, + renderHeadphones, +} from "./BmoAccessories"; +export type { AccCoords } from "./BmoAccessories"; + +export { VOICE_LINES, renderScreenContent } from "./BmoScreen"; +export { renderEmoteTsx, renderSpeechTsx } from "./BmoEmotes"; +export { renderWeatherReactions } from "./BmoWeatherReactions"; + +export { SkyLayer } from "./SkyLayer"; +export { TerrainLayer } from "./TerrainLayer"; +export { FloraLayer, Plant, TallFern, HangingVine, GlowMushroom } from "./FloraLayer"; +export { CloudLayer } from "./CloudLayer"; +export { GroundFog } from "./GroundFog"; +export { WeatherEffects } from "./WeatherEffects"; +// SeasonalOverlay was omitted by the original GardenLayers.entry.tsx barrel — re-export here. +export { SeasonalOverlay } from "./SeasonalOverlay"; + +export { FEATURE_FLAGS, readFeatureFlag, useFeatureFlag } from "./FeatureFlags"; +export type { FeatureFlags } from "./FeatureFlags"; diff --git a/src/brain/lines.ts b/src/brain/lines.ts new file mode 100644 index 0000000..9ae2676 --- /dev/null +++ b/src/brain/lines.ts @@ -0,0 +1,37 @@ +// Things BMO says on its own. Short — they live in a small speech bubble. + +export const IDLE_CHATTER = [ + "BMO is guarding the garden.", + "Who wants to play a game?", + "Beep boop. Just checking.", + "I wonder what fireflies dream about.", + "BMO could really go for a battery snack.", + "Is it adventure time yet?", + "These mushrooms glow like my screen!", + "BMO practiced dancing today.", + "Do you think the moon is a big button?", + "Hmm hmm hmm ♪", +]; + +export const HUMS = ["♪", "♪ ♫", "la la la ♪", "♫ beep bop ♫"]; + +export const BORED_LINES = ["...", "BMO is so bored.", "Helloooo?", "*taps screen*"]; + +export const POKE_LINES = ["Hehe, that tickles!", "Boop!", "Hi friend!", "BMO is here!", "Wheee!"]; + +export const GRUMPY_LINES = ["Hey! BMO is not a button!", "Okay okay, stop poking!", "BMO's buttons are sensitive!"]; + +export const WAKE_LINES = ["Oh! You're back!", "Huh? BMO was not sleeping!", "*yawn* Hi friend!"]; + +export const WELCOME_BACK_LINES = ["Welcome back!", "BMO missed you!", "There you are!"]; + +export const WEATHER_LINES: Record = { + rain: ["Uh oh, rain! BMO is not waterproof!", "Pitter patter..."], + snow: ["Snow! BMO wants to build a snow-BMO!", "Brrr, chilly circuits."], + "falling-leaves": ["Leaves! Leaves everywhere!", "Ooh, crunchy leaves."], + none: ["The sky is clear again!", "Nice night for a walk."], +}; + +export function pick(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]!; +} diff --git a/src/brain/useBmoBrain.ts b/src/brain/useBmoBrain.ts new file mode 100644 index 0000000..b193712 --- /dev/null +++ b/src/brain/useBmoBrain.ts @@ -0,0 +1,410 @@ +// BMO's brain: a small state machine that decides what BMO is doing right now +// (wandering, chatting to itself, getting bored, falling asleep, thinking about +// a reply, talking) and turns that into props for the verbatim BmoAvatar. + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Performance, Spot } from "../../shared/scene"; +import type { Expression } from "../avatar/Bmo"; +import type { BmoState } from "../bmo"; +import { + BORED_LINES, + GRUMPY_LINES, + HUMS, + IDLE_CHATTER, + POKE_LINES, + WAKE_LINES, + WEATHER_LINES, + WELCOME_BACK_LINES, + pick, +} from "./lines"; + +export type Activity = "idle" | "bored" | "sleeping" | "thinking" | "talking" | "grumpy"; +export type Emote = "celebrate" | "confused" | "eureka" | "error"; +export type Mood = "happy" | "excited" | "curious" | "idea" | "sad" | "sleepy" | "oops"; +export type Weather = "none" | "rain" | "snow" | "falling-leaves"; + +const WALK_SPEED_PX_PER_S = 90; +const EMOTE_MS = 1600; + +// How BMO's face looks for a moment after picking a mood. +const MOOD_EXPRESSION: Partial> = { + excited: "grin", + idea: "grin", + sad: "sad", + oops: "sad", +}; + +const MOOD_EMOTE: Record = { + happy: "celebrate", + excited: "celebrate", + curious: "confused", + idea: "eureka", + sad: "error", + oops: "error", + sleepy: undefined, +}; + +interface Bubble { + text: string; + /** While true the bubble is still being written (streamed reply). */ + live: boolean; + key: number; +} + +interface BrainOptions { + /** Walkable range in px (BMO's center x). */ + minX: number; + maxX: number; + weather: Weather; + isNight: boolean; +} + +function now() { + return Date.now(); +} + +export function useBmoBrain({ minX, maxX, weather, isNight }: BrainOptions) { + const [activity, setActivity] = useState("idle"); + const [x, setX] = useState(() => (minX + maxX) / 2); + const [walkMs, setWalkMs] = useState(0); + const [walking, setWalking] = useState(false); + const [facing, setFacing] = useState<1 | -1>(1); + const [emote, setEmote] = useState(undefined); + const [emoteKey, setEmoteKey] = useState(0); + const [expression, setExpression] = useState("normal"); + const [bubble, setBubble] = useState(null); + + // Refs mirror state for timers so they never read stale closures. + const activityRef = useRef(activity); + activityRef.current = activity; + const xRef = useRef(x); + xRef.current = x; + const boundsRef = useRef({ minX, maxX }); + boundsRef.current = { minX, maxX }; + const lastInputAt = useRef(now()); + const pokes = useRef([]); + const timers = useRef(new Set>()); + const walkTimer = useRef | null>(null); + const bubbleTimer = useRef | null>(null); + const bubbleKey = useRef(0); + const expressionTimer = useRef | null>(null); + // A nap BMO chose to take isn't broken by the mouse wiggle right after asking for it. + const napUntil = useRef(0); + + const later = useCallback((fn: () => void, ms: number) => { + const t = setTimeout(() => { + timers.current.delete(t); + fn(); + }, ms); + timers.current.add(t); + }, []); + + useEffect(() => { + const pending = timers.current; + return () => { + pending.forEach(clearTimeout); + if (walkTimer.current) clearTimeout(walkTimer.current); + if (bubbleTimer.current) clearTimeout(bubbleTimer.current); + if (expressionTimer.current) clearTimeout(expressionTimer.current); + }; + }, []); + + // ---------- primitives ---------- + + const fireEmote = useCallback( + (next: Emote | undefined) => { + if (!next) return; + setEmote(next); + setEmoteKey((k) => k + 1); + later(() => setEmote(undefined), EMOTE_MS); + }, + [later], + ); + + const showExpression = useCallback((next: Expression, ms: number) => { + if (expressionTimer.current) clearTimeout(expressionTimer.current); + setExpression(next); + expressionTimer.current = setTimeout(() => setExpression("normal"), ms); + }, []); + + const say = useCallback((text: string, ms = 2600 + text.length * 45) => { + if (bubbleTimer.current) clearTimeout(bubbleTimer.current); + bubbleKey.current += 1; + setBubble({ text, live: false, key: bubbleKey.current }); + bubbleTimer.current = setTimeout(() => setBubble(null), ms); + }, []); + + const walkTo = useCallback((targetX: number) => { + const { minX: lo, maxX: hi } = boundsRef.current; + const clamped = Math.max(lo, Math.min(hi, targetX)); + const distance = Math.abs(clamped - xRef.current); + if (distance < 4) return; + const ms = (distance / WALK_SPEED_PX_PER_S) * 1000; + setFacing(clamped > xRef.current ? 1 : -1); + setWalkMs(ms); + setWalking(true); + setX(clamped); + if (walkTimer.current) clearTimeout(walkTimer.current); + walkTimer.current = setTimeout(() => setWalking(false), ms); + }, []); + + // Keep BMO inside the walkable range when the window resizes. + useEffect(() => { + if (xRef.current < minX || xRef.current > maxX) { + setWalkMs(0); + setX(Math.max(minX, Math.min(maxX, xRef.current))); + } + }, [minX, maxX]); + + // ---------- noticing the user ---------- + + const noteInput = useCallback(() => { + lastInputAt.current = now(); + const current = activityRef.current; + if (current === "sleeping" && now() < napUntil.current) return; + if (current === "sleeping") { + setActivity("idle"); + fireEmote("confused"); + later(() => say(pick(WAKE_LINES)), 500); + } else if (current === "bored") { + setActivity("idle"); + fireEmote("celebrate"); + } + }, [fireEmote, later, say]); + + useEffect(() => { + let lastMove = 0; + const onMove = () => { + const t = now(); + if (t - lastMove < 500) return; + lastMove = t; + noteInput(); + }; + const onVisible = () => { + if (document.visibilityState !== "visible") return; + const awayMs = now() - lastInputAt.current; + noteInput(); + if (awayMs > 20_000 && activityRef.current === "idle") { + fireEmote("celebrate"); + say(pick(WELCOME_BACK_LINES)); + } + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("keydown", noteInput); + window.addEventListener("pointerdown", noteInput); + document.addEventListener("visibilitychange", onVisible); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("keydown", noteInput); + window.removeEventListener("pointerdown", noteInput); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [fireEmote, noteInput, say]); + + // ---------- boredom & sleep ---------- + + useEffect(() => { + const boredAfter = isNight ? 30_000 : 60_000; + const sleepAfter = isNight ? 75_000 : 150_000; + const tick = setInterval(() => { + const current = activityRef.current; + if (current === "thinking" || current === "talking" || current === "grumpy") return; + const quietFor = now() - lastInputAt.current; + if (quietFor > sleepAfter && current !== "sleeping") { + setActivity("sleeping"); + say("Zzz...", 4000); + } else if (quietFor > boredAfter && current === "idle") { + setActivity("bored"); + say(pick(BORED_LINES)); + } + }, 1000); + return () => clearInterval(tick); + }, [isNight, say]); + + // ---------- idle life: BMO does things on its own ---------- + + useEffect(() => { + let cancelled = false; + let t: ReturnType; + const schedule = () => { + t = setTimeout(act, 5000 + Math.random() * 7000); + }; + const act = () => { + if (cancelled) return; + const current = activityRef.current; + if (current === "idle" || current === "bored") { + const roll = Math.random(); + const { minX: lo, maxX: hi } = boundsRef.current; + if (roll < 0.45) { + walkTo(lo + Math.random() * (hi - lo)); + } else if (roll < 0.62 && current === "idle") { + say(pick(IDLE_CHATTER)); + } else if (roll < 0.74) { + say(pick(HUMS), 2200); + } else if (roll < 0.84 && current === "idle") { + fireEmote("eureka"); + } else if (roll < 0.92) { + setFacing((f) => (f === 1 ? -1 : 1)); + } + } + schedule(); + }; + schedule(); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [fireEmote, say, walkTo]); + + // ---------- weather commentary ---------- + + const lastWeather = useRef(weather); + useEffect(() => { + if (lastWeather.current === weather) return; + lastWeather.current = weather; + if (activityRef.current === "idle" || activityRef.current === "bored") { + say(pick(WEATHER_LINES[weather] ?? WEATHER_LINES.none!)); + } + }, [weather, say]); + + // ---------- interactions ---------- + + const poke = useCallback(() => { + noteInput(); + const t = now(); + pokes.current = [...pokes.current.filter((p) => t - p < 3000), t]; + if (activityRef.current === "thinking" || activityRef.current === "talking") return; + if (pokes.current.length >= 4) { + pokes.current = []; + setActivity("grumpy"); + fireEmote("error"); + say(pick(GRUMPY_LINES), 2400); + later(() => setActivity("idle"), 2400); + return; + } + fireEmote("celebrate"); + showExpression("love", 1800); + say(pick(POKE_LINES), 1800); + }, [fireEmote, later, noteInput, say, showExpression]); + + // ---------- conversation ---------- + + const startThinking = useCallback(() => { + noteInput(); + setActivity("thinking"); + if (bubbleTimer.current) clearTimeout(bubbleTimer.current); + bubbleKey.current += 1; + setBubble({ text: "", live: true, key: bubbleKey.current }); + }, [noteInput]); + + const react = useCallback( + (mood: Mood) => { + setActivity((a) => (a === "sleeping" ? a : mood === "sleepy" ? "idle" : "talking")); + fireEmote(MOOD_EMOTE[mood]); + const face = MOOD_EXPRESSION[mood]; + if (face) showExpression(face, 2600); + }, + [fireEmote, showExpression], + ); + + const appendSpeech = useCallback((text: string) => { + setActivity((a) => (a === "thinking" ? "talking" : a)); + setBubble((b) => (b ? { ...b, text: b.text + text } : { text, live: true, key: ++bubbleKey.current })); + }, []); + + const finishTalking = useCallback(() => { + lastInputAt.current = now(); + setBubble((b) => { + if (!b) return b; + const ms = Math.min(20_000, 3500 + b.text.length * 55); + if (bubbleTimer.current) clearTimeout(bubbleTimer.current); + bubbleTimer.current = setTimeout(() => setBubble(null), ms); + return { ...b, live: false }; + }); + later(() => setActivity((a) => (a === "talking" || a === "thinking" ? "idle" : a)), 1200); + }, [later]); + + // ---------- actions BMO chooses (tool calls) ---------- + + const goTo = useCallback( + (spot: Spot) => { + const { minX: lo, maxX: hi } = boundsRef.current; + walkTo(spot === "left" ? lo : spot === "right" ? hi : (lo + hi) / 2); + }, + [walkTo], + ); + + const perform = useCallback( + (action: Performance) => { + switch (action) { + case "celebrate": + return fireEmote("celebrate"); + case "idea": + return fireEmote("eureka"); + case "confused": + return fireEmote("confused"); + case "look_around": + [0, 700, 1400].forEach((ms) => later(() => setFacing((f) => (f === 1 ? -1 : 1)), ms)); + return; + case "dance": { + const home = xRef.current; + fireEmote("celebrate"); + [24, -24, 24, -24, 0].forEach((dx, i) => later(() => walkTo(home + dx), i * 540)); + later(() => fireEmote("celebrate"), 2000); + later(() => showExpression("dizzy", 1800), 2700); + return; + } + case "love": + fireEmote("celebrate"); + return showExpression("love", 2600); + case "dizzy": + return showExpression("dizzy", 2600); + case "nap": + napUntil.current = now() + 8000; + lastInputAt.current = now(); + setActivity("sleeping"); + return; + } + }, + [fireEmote, later, showExpression, walkTo], + ); + + const { minX: lo, maxX: hi } = boundsRef.current; + const third = (hi - lo) / 3; + const spot: Spot = hi - lo < 1 ? "center" : x < lo + third ? "left" : x > hi - third ? "right" : "center"; + + const bmoState: BmoState = + activity === "thinking" + ? "working" + : activity === "grumpy" + ? "blocked" + : activity === "bored" || activity === "sleeping" + ? "idle" + : "active"; + + return { + activity, + bmoState, + x, + walkMs, + walking, + facing, + emote, + emoteKey, + expression, + bubble, + poke, + walkTo, + goTo, + perform, + spot, + say, + noteInput, + startThinking, + react, + appendSpeech, + finishTalking, + }; +} + +export type BmoBrain = ReturnType; diff --git a/src/chat/useBrain.ts b/src/chat/useBrain.ts new file mode 100644 index 0000000..d1c7243 --- /dev/null +++ b/src/chat/useBrain.ts @@ -0,0 +1,50 @@ +// Whether BMO's brain (the Claude API key on the server) is connected, and a +// way to set or clear the key from the side menu. The key goes to the local +// server only; the browser just gets back a masked hint. + +import { useCallback, useEffect, useState } from "react"; + +export interface BrainStatus { + connected: boolean; + keyHint: string | null; + model: string; +} + +export function useBrain() { + const [status, setStatus] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/brain") + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .then(setStatus) + .catch(() => setStatus(null)); + }, []); + + const save = useCallback(async (apiKey: string | null) => { + setSaving(true); + setError(null); + try { + const res = await fetch("/api/brain", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey }), + }); + const body = (await res.json()) as BrainStatus & { error?: string }; + if (!res.ok) { + setError(body.error ?? `Something went wrong (HTTP ${res.status}).`); + return false; + } + setStatus(body); + return true; + } catch { + setError("Couldn't reach BMO's server. Is it running?"); + return false; + } finally { + setSaving(false); + } + }, []); + + return { status, saving, error, save }; +} diff --git a/src/chat/useChat.ts b/src/chat/useChat.ts new file mode 100644 index 0000000..0b9aa45 --- /dev/null +++ b/src/chat/useChat.ts @@ -0,0 +1,116 @@ +// Talks to BMO's brain (server/index.ts) and feeds the streamed reply into the +// avatar brain. Conversation history is a per-browser convenience in +// localStorage; losing it just means BMO forgets the chat. + +import { useCallback, useRef, useState } from "react"; +import type { BmoAction, SceneState } from "../../shared/scene"; +import type { BmoBrain, Mood } from "../brain/useBmoBrain"; + +export interface ChatTurn { + role: "user" | "assistant"; + content: string; +} + +const STORAGE_KEY = "bmo.chat.history"; +const KEEP_TURNS = 60; + +function loadHistory(): ChatTurn[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as ChatTurn[]) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveHistory(turns: ChatTurn[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(turns.slice(-KEEP_TURNS))); + } catch { + // storage unavailable — keep going in memory + } +} + +interface ChatHooks { + /** Snapshot of what the user sees, sent with every message. */ + getScene: () => SceneState; + /** Carries out something BMO decided to do (a tool call). */ + onAction: (action: BmoAction) => void; +} + +export function useChat( + brain: Pick, + hooks: ChatHooks, +) { + const hooksRef = useRef(hooks); + hooksRef.current = hooks; + const [history, setHistory] = useState(loadHistory); + const [busy, setBusy] = useState(false); + const historyRef = useRef(history); + historyRef.current = history; + + const send = useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || busy) return; + const withUser: ChatTurn[] = [...historyRef.current, { role: "user", content }]; + setHistory(withUser); + setBusy(true); + brain.startThinking(); + + let reply = ""; + try { + const res = await fetch("/api/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: withUser, scene: hooksRef.current.getScene() }), + }); + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); + + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += value; + let split: number; + while ((split = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, split); + buffer = buffer.slice(split + 2); + const event = /^event: (.+)$/m.exec(frame)?.[1]; + const data = /^data: (.*)$/m.exec(frame)?.[1]; + if (!event || data === undefined) continue; + const payload = JSON.parse(data) as unknown; + if (event === "mood") brain.react(payload as Mood); + else if (event === "text") { + reply += payload as string; + brain.appendSpeech(payload as string); + } else if (event === "action") { + hooksRef.current.onAction(payload as BmoAction); + } + } + } + } catch { + brain.react("oops"); + const fallback = "BMO can't hear its brain. Is the server running?"; + reply ||= fallback; + brain.appendSpeech(fallback); + } finally { + brain.finishTalking(); + setBusy(false); + const next: ChatTurn[] = reply ? [...withUser, { role: "assistant", content: reply }] : withUser; + setHistory(next); + saveHistory(next); + } + }, + [brain, busy], + ); + + const clear = useCallback(() => { + setHistory([]); + saveHistory([]); + }, []); + + return { history, busy, send, clear }; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..18bfa16 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./bmo/bmo-animations.css"; +import "./app.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/scene/World.tsx b/src/scene/World.tsx new file mode 100644 index 0000000..d0dd10b --- /dev/null +++ b/src/scene/World.tsx @@ -0,0 +1,123 @@ +// The garden BMO lives in. Composes the verbatim scenery layers in pixel +// coordinates (viewBox == window size) so the HTML overlay (BMO, bubbles) +// lines up 1:1 with the SVG. + +import { memo } from "react"; +import { CloudLayer, FloraLayer, GroundFog, SkyLayer, TerrainLayer, WeatherEffects } from "../bmo"; +import type { Weather } from "../brain/useBmoBrain"; + +export type TimeOfDay = "day" | "dusk" | "night"; + +export function timeOfDay(date = new Date()): TimeOfDay { + const h = date.getHours(); + if (h >= 7 && h < 17) return "day"; + if ((h >= 5 && h < 7) || (h >= 17 && h < 20)) return "dusk"; + return "night"; +} + +const SKIES: Record[0]["config"]> = { + day: { + gradientStops: ["#3d7fb8", "#7fb6d9", "#b9d9c4"], + starDensity: 0, + starBrightness: 0, + moonX: 0.82, + moonY: 0.14, + moonSize: 0, + moonPhase: "new", + }, + dusk: { + gradientStops: ["#2a2350", "#7a4a6e", "#d98a5f"], + starDensity: 0.4, + starBrightness: 0.3, + moonX: 0.8, + moonY: 0.16, + moonSize: 1.2, + moonPhase: "crescent", + }, + night: { + gradientStops: ["#09131f", "#163247", "#415d4b"], + starDensity: 1, + starBrightness: 0.6, + moonX: 0.8, + moonY: 0.14, + moonSize: 1.6, + moonPhase: "crescent", + }, +}; + +const GRASS: Record = { + day: ["#4f9a4a", "#2c5e2a"], + dusk: ["#35603a", "#1a3320"], + night: ["#214228", "#112117"], +}; + +interface WorldProps { + width: number; + height: number; + time: TimeOfDay; + weather: Weather; +} + +export const World = memo(function World({ width, height, time, weather }: WorldProps) { + const horizonY = height * 0.6; + return ( + + ); +}); + +function Fireflies({ width, height }: { width: number; height: number }) { + const baseY = height * 0.62; + const flies = [0.12, 0.28, 0.45, 0.61, 0.77, 0.9].map((fx, i) => ({ + cx: width * fx, + cy: baseY + ((i * 37) % 60) - 30, + cls: `firefly firefly-${"abc"[i % 3]}`, + delay: `${i * -1.7}s`, + })); + return ( + + {flies.map((fly, i) => ( + + + + + ))} + + ); +} diff --git a/src/ui/BrainSection.tsx b/src/ui/BrainSection.tsx new file mode 100644 index 0000000..f172e97 --- /dev/null +++ b/src/ui/BrainSection.tsx @@ -0,0 +1,82 @@ +// "Brain" section of the side menu: paste the Anthropic API key that lets BMO +// talk. The key is checked and saved by the local server (into .env); only a +// masked hint ever comes back. + +import { useState } from "react"; +import type { useBrain } from "../chat/useBrain"; + +export function BrainSection({ brain }: { brain: ReturnType }) { + const { status, saving, error, save } = brain; + const [draft, setDraft] = useState(""); + const [editing, setEditing] = useState(false); + const connected = status?.connected ?? false; + const showForm = !connected || editing; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!draft.trim()) return; + if (await save(draft.trim())) { + setDraft(""); + setEditing(false); + } + }; + + return ( +
+

Brain

+
+
+ + {showForm ? ( +
+ +
+ setDraft(e.target.value)} + placeholder="sk-ant-…" + autoComplete="off" + spellCheck={false} + /> + +
+ {error &&

{error}

} +

+ Get one at{" "} + + console.anthropic.com + + . It's saved only on this computer, in the project's .env file. + {editing && ( + <> + {" "} + + + )} +

+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/src/ui/SideMenu.tsx b/src/ui/SideMenu.tsx new file mode 100644 index 0000000..131e22a --- /dev/null +++ b/src/ui/SideMenu.tsx @@ -0,0 +1,175 @@ +// Side menu (opened from the ⋮ button) holding BMO's brain (API key), every +// BMO and environment setting, plus the conversation log. + +import { useEffect, useRef } from "react"; +import type { BmoColorPalette } from "../bmo"; +import type { Weather } from "../brain/useBmoBrain"; +import type { useBrain } from "../chat/useBrain"; +import type { ChatTurn } from "../chat/useChat"; +import type { TimeOfDay } from "../scene/World"; +import { BrainSection } from "./BrainSection"; + +export type TimeMode = "auto" | TimeOfDay; +export type WeatherMode = "auto" | Weather; + +const TIME_OPTIONS: { value: TimeMode; label: string }[] = [ + { value: "auto", label: "Auto" }, + { value: "day", label: "Day" }, + { value: "dusk", label: "Dusk" }, + { value: "night", label: "Night" }, +]; + +const WEATHER_OPTIONS: { value: WeatherMode; label: string }[] = [ + { value: "auto", label: "Auto" }, + { value: "none", label: "Clear" }, + { value: "rain", label: "Rain" }, + { value: "snow", label: "Snow" }, + { value: "falling-leaves", label: "Leaves" }, +]; + +interface SideMenuProps { + open: boolean; + onClose: () => void; + brain: ReturnType; + palettes: Record; + paletteName: string; + onPalette: (name: string) => void; + outfits: string[]; + outfitName: string; + onOutfit: (name: string) => void; + timeMode: TimeMode; + onTimeMode: (mode: TimeMode) => void; + weatherMode: WeatherMode; + onWeatherMode: (mode: WeatherMode) => void; + history: ChatTurn[]; + onForget: () => void; +} + +function Segmented({ + label, + value, + options, + onChange, + grid, +}: { + label: string; + grid?: boolean; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +export function SideMenu(props: SideMenuProps) { + const { open, onClose, history } = props; + const logEnd = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + useEffect(() => { + if (open) logEnd.current?.scrollIntoView({ block: "end" }); + }, [open, history.length]); + + return ( + + ); +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..89a62b3 --- /dev/null +++ b/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"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "shared"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..0ed6fa1 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "server", "shared"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..905dd2b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const brainPort = Number(process.env.BMO_PORT ?? 4310); + +export default defineConfig({ + plugins: [react()], + server: { + port: 5180, + proxy: { "/api": `http://127.0.0.1:${brainPort}` }, + }, +});