diff --git a/package-lock.json b/package-lock.json
index 45a22cb..7369c26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,22 +42,26 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
+ "three": "^0.183.2",
"ws": "^8.19.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
+ "@codevibesmatter/kata": "^0.3.0",
"@tauri-apps/cli": "^2.2.0",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
+ "@types/three": "^0.183.1",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"archiver": "^7.0.1",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
+ "esbuild": "^0.25.12",
"jsdom": "^26.1.0",
"license-checker-rseidelsohn": "^4.4.2",
"postcss": "^8.5.1",
@@ -402,6 +406,23 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@codevibesmatter/kata": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@codevibesmatter/kata/-/kata-0.3.0.tgz",
+ "integrity": "sha512-6w+zDRY5pPH94URl98qTqKUsOKQaanxaTyb2nsvgCvEmSnB1tR1QpDH3poP7wZkk+Dc2SQDFv6e6Qlz74N1Esg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-yaml": "^4.1.0",
+ "zod": "^3.25.76"
+ },
+ "bin": {
+ "kata": "kata"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -517,6 +538,13 @@
"node": ">=18"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -2831,6 +2859,13 @@
}
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -2996,12 +3031,42 @@
"@types/react": "*"
}
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.183.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
+ "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.0.1"
+ }
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3164,6 +3229,13 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz",
@@ -3286,6 +3358,13 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -4388,6 +4467,13 @@
"pend": "~1.2.0"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5180,6 +5266,19 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
@@ -5745,6 +5844,13 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
+ "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -8153,6 +8259,12 @@
"node": ">=0.8"
}
},
+ "node_modules/three": {
+ "version": "0.183.2",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
+ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -8594,18 +8706,15 @@
}
},
"node_modules/vite": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
- "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz",
+ "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.25.0",
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
+ "esbuild": "^0.24.2",
+ "postcss": "^8.5.1",
+ "rollup": "^4.30.1"
},
"bin": {
"vite": "bin/vite.js"
@@ -8691,35 +8800,470 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/vite/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
+ "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
+ "cpu": [
+ "ppc64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
"engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
+ "node": ">=18"
}
},
- "node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
+ "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
+ "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
+ "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
+ "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
+ "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
+ "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
+ "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
+ "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
+ "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
+ "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
+ "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
+ "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
+ "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
+ "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
+ "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
+ "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
+ "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
+ "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
+ "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
+ "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
},
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.24.2",
+ "@esbuild/android-arm": "0.24.2",
+ "@esbuild/android-arm64": "0.24.2",
+ "@esbuild/android-x64": "0.24.2",
+ "@esbuild/darwin-arm64": "0.24.2",
+ "@esbuild/darwin-x64": "0.24.2",
+ "@esbuild/freebsd-arm64": "0.24.2",
+ "@esbuild/freebsd-x64": "0.24.2",
+ "@esbuild/linux-arm": "0.24.2",
+ "@esbuild/linux-arm64": "0.24.2",
+ "@esbuild/linux-ia32": "0.24.2",
+ "@esbuild/linux-loong64": "0.24.2",
+ "@esbuild/linux-mips64el": "0.24.2",
+ "@esbuild/linux-ppc64": "0.24.2",
+ "@esbuild/linux-riscv64": "0.24.2",
+ "@esbuild/linux-s390x": "0.24.2",
+ "@esbuild/linux-x64": "0.24.2",
+ "@esbuild/netbsd-arm64": "0.24.2",
+ "@esbuild/netbsd-x64": "0.24.2",
+ "@esbuild/openbsd-arm64": "0.24.2",
+ "@esbuild/openbsd-x64": "0.24.2",
+ "@esbuild/sunos-x64": "0.24.2",
+ "@esbuild/win32-arm64": "0.24.2",
+ "@esbuild/win32-ia32": "0.24.2",
+ "@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/vitest": {
diff --git a/package.json b/package.json
index ae94eda..607b2e9 100644
--- a/package.json
+++ b/package.json
@@ -88,22 +88,26 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
+ "three": "^0.183.2",
"ws": "^8.19.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
+ "@codevibesmatter/kata": "^0.3.0",
+ "@tauri-apps/cli": "^2.2.0",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
- "@tauri-apps/cli": "^2.2.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
+ "@types/three": "^0.183.1",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"archiver": "^7.0.1",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
+ "esbuild": "^0.25.12",
"jsdom": "^26.1.0",
"license-checker-rseidelsohn": "^4.4.2",
"postcss": "^8.5.1",
diff --git a/src/App.tsx b/src/App.tsx
index f66f095..66d2afe 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,9 +17,12 @@ import { useAppInit } from '@/hooks/useAppInit'
import { useChatStore } from '@/store/chat'
import { useTitleStore } from '@/store/titles'
import { useAgentStore } from '@/store/agents'
-import { MessageCircle, Settings, Plug, Clock, Sparkles, Loader2, Wrench } from 'lucide-react'
+import { MessageCircle, Settings, Plug, Clock, Sparkles, Loader2, Wrench, Hammer } from 'lucide-react'
+import { lazy, Suspense } from 'react'
-export type View = 'chat' | 'soul' | 'cron' | 'settings' | 'plugins' | 'skills'
+const LazyWorkshopView = lazy(() => import('@/workshop/components/WorkshopView').then(m => ({ default: m.WorkshopView })))
+
+export type View = 'chat' | 'soul' | 'cron' | 'settings' | 'plugins' | 'skills' | 'workshop'
type OnboardTransitionStage = 'idle' | 'closing' | 'opening'
const RECONFIGURE_SWITCH_DELAY_MS = 180
@@ -47,6 +50,7 @@ function App() {
{ id: 'cron' as View, icon: Clock, label: t('nav.cron') },
{ id: 'plugins' as View, icon: Plug, label: t('nav.plugins') },
{ id: 'skills' as View, icon: Wrench, label: t('nav.skills') },
+ { id: 'workshop' as View, icon: Hammer, label: t('nav.workshop') },
{ id: 'settings' as View, icon: Settings, label: t('nav.settings') },
]
@@ -107,6 +111,11 @@ function App() {
case 'cron': return
case 'plugins': return
case 'skills': return
+ case 'workshop': return (
+ }>
+
+
+ )
case 'settings': return
default: return
}
@@ -122,16 +131,18 @@ function App() {
>
-
-
- {currentView === 'chat' && conversation ? headerTitle : ''}
-
-
-
-
+ {currentView !== 'workshop' && (
+
+
+ {currentView === 'chat' && conversation ? headerTitle : ''}
+
+
+
+
+
-
-
+ )}
+ {currentView !== 'workshop' &&
}
{renderView()}
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts
index 2289943..d4a8f69 100644
--- a/src/hooks/useChat.ts
+++ b/src/hooks/useChat.ts
@@ -7,6 +7,7 @@ import { useTitleStore } from '@/store/titles'
import { streamChat, abortChat } from '@/services/ai'
import { toolsApi } from '@/services/api'
import type { Message } from '@/types'
+import { workshopCallbacks } from '@/workshop/bridge/ClawBoxEventTap'
export function useChat() {
const chatStore = useChatStore()
@@ -63,9 +64,16 @@ export function useChat() {
await streamChat(
{ sessionKey: convId, prompt, thinking: thinking || 'off' },
{
- onText: (content) => chatStore.appendTextBlock(finalConvId, assistantId, content),
- onReasoning: (content) => chatStore.appendReasoningBlock(finalConvId, assistantId, content),
+ onText: (content) => {
+ chatStore.appendTextBlock(finalConvId, assistantId, content)
+ workshopCallbacks.onText(content)
+ },
+ onReasoning: (content) => {
+ chatStore.appendReasoningBlock(finalConvId, assistantId, content)
+ workshopCallbacks.onReasoning(content)
+ },
onToolStart: (data) => {
+ workshopCallbacks.onToolStart(data)
chatStore.addToolCallBlock(finalConvId, assistantId, {
toolName: data.name,
toolCallId: data.toolCallId,
@@ -97,16 +105,21 @@ export function useChat() {
})
},
onToolUpdate: (data) => chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, {
+ // Note: tool_update events don't map to a distinct workshop event
toolName: data.name || 'tool',
result: data.result,
status: 'running',
}),
- onToolEnd: (data) => chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, {
- toolName: data.name || 'tool',
- result: data.result,
- status: data.error ? 'error' : 'completed',
- }),
+ onToolEnd: (data) => {
+ workshopCallbacks.onToolEnd(data)
+ chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, {
+ toolName: data.name || 'tool',
+ result: data.result,
+ status: data.error ? 'error' : 'completed',
+ })
+ },
onDone: () => {
+ workshopCallbacks.onDone()
chatStore.updateMessage(finalConvId, assistantId, { isLoading: false })
chatStore.setConversationStreaming(finalConvId, false)
if (chatStore.currentConversationId !== finalConvId) {
@@ -114,6 +127,7 @@ export function useChat() {
}
},
onError: (error) => {
+ workshopCallbacks.onError(error)
chatStore.updateMessage(finalConvId, assistantId, {
isLoading: false, error: true,
blocks: [{ type: 'text', content: `Error: ${error}` }],
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index ea49ca0..0693642 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -5,8 +5,13 @@
"cron": "Cron",
"plugins": "Plugins",
"skills": "Skills",
+ "workshop": "Workshop",
"settings": "Settings"
},
+ "workshop": {
+ "title": "Workshop",
+ "placeholder": "3D visualization of agent activity. Start a chat to see the workshop come alive."
+ },
"chat": {
"welcomeMessage": "How can I help you?",
"welcomeMessages": [
diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json
index 23daca8..4d06af1 100644
--- a/src/locales/zh/translation.json
+++ b/src/locales/zh/translation.json
@@ -5,8 +5,13 @@
"cron": "任务",
"plugins": "插件",
"skills": "技能",
+ "workshop": "工作坊",
"settings": "设置"
},
+ "workshop": {
+ "title": "工作坊",
+ "placeholder": "代理活动的3D可视化。开始对话后,工作坊将活跃起来。"
+ },
"chat": {
"welcomeMessage": "有什么可以帮忙的?",
"welcomeMessages": [
diff --git a/src/workshop/bridge/ClawBoxEventTap.ts b/src/workshop/bridge/ClawBoxEventTap.ts
new file mode 100644
index 0000000..c50289d
--- /dev/null
+++ b/src/workshop/bridge/ClawBoxEventTap.ts
@@ -0,0 +1,52 @@
+/**
+ * ClawBoxEventTap — Hooks into the existing chat streaming callbacks
+ * to forward events to the Workshop EventBridge.
+ *
+ * This is a singleton that wraps the streamChat callbacks to also
+ * publish events to the workshop scene.
+ */
+
+import type { EventBridge } from './EventBridge'
+
+let activeBridge: EventBridge | null = null
+
+/** Set the active bridge instance (called when Workshop mounts) */
+export function setActiveBridge(bridge: EventBridge | null): void {
+ activeBridge = bridge
+}
+
+/** Get the active bridge (used by the callback wrappers) */
+export function getActiveBridge(): EventBridge | null {
+ return activeBridge
+}
+
+/**
+ * Workshop-aware callback wrappers.
+ * These are called alongside the existing chat store callbacks
+ * in useChat.ts to forward events to the workshop.
+ */
+export const workshopCallbacks = {
+ onText: (content: string) => {
+ activeBridge?.onText(content)
+ },
+
+ onReasoning: (content: string) => {
+ activeBridge?.onReasoning(content)
+ },
+
+ onToolStart: (data: { name: string; toolCallId: string; args: Record
}) => {
+ activeBridge?.onToolStart(data)
+ },
+
+ onToolEnd: (data: { toolCallId: string; name?: string; result: string; error?: boolean }) => {
+ activeBridge?.onToolEnd(data)
+ },
+
+ onDone: () => {
+ activeBridge?.onDone()
+ },
+
+ onError: (error: string) => {
+ activeBridge?.onError(error)
+ },
+}
diff --git a/src/workshop/bridge/EventBridge.ts b/src/workshop/bridge/EventBridge.ts
new file mode 100644
index 0000000..4c3b58c
--- /dev/null
+++ b/src/workshop/bridge/EventBridge.ts
@@ -0,0 +1,217 @@
+/**
+ * EventBridge — Translates ClawBox SSE chat events into Vibecraft2 EventBus events.
+ *
+ * This is the key integration layer: ClawBox's streamChat callbacks produce
+ * normalized workshop events which are translated to Vibecraft2's EventBus format.
+ */
+
+import { nanoid } from 'nanoid'
+import { EventBus, type EventContext } from '../events/EventBus'
+import { getStationForTool, type ToolCategory, type StationType, TOOL_STATION_MAP } from '../types'
+import { getToolIcon, getToolContext } from '../utils/ToolUtils'
+import { useWorkshopStore, type WorkshopFeedItem } from '../store/workshop'
+
+/** Map ClawBox tool names to Vibecraft2 tool categories */
+function toolNameToCategory(name: string): ToolCategory {
+ const map: Record = {
+ Read: 'read',
+ Write: 'write',
+ Edit: 'edit',
+ Bash: 'execute',
+ Grep: 'search',
+ Glob: 'search',
+ WebFetch: 'network',
+ WebSearch: 'network',
+ Task: 'delegate',
+ TodoWrite: 'plan',
+ AskUserQuestion: 'interact',
+ NotebookEdit: 'edit',
+ }
+ return map[name] ?? 'other'
+}
+
+export class EventBridge {
+ private bus: EventBus
+ private context: EventContext
+ private activeToolIds = new Map() // toolCallId → toolUseId
+
+ constructor(bus: EventBus, context: EventContext) {
+ this.bus = bus
+ this.context = context
+ }
+
+ updateContext(context: Partial): void {
+ Object.assign(this.context, context)
+ }
+
+ /** Called when SSE emits tool_start */
+ onToolStart(data: { name: string; toolCallId: string; args: Record }): void {
+ const toolUseId = nanoid()
+ this.activeToolIds.set(data.toolCallId, toolUseId)
+
+ this.bus.emit('tool_start', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'tool_start',
+ agentId: 'clawbox-main',
+ source: 'clawbox',
+ tool: {
+ name: data.name,
+ category: toolNameToCategory(data.name),
+ id: toolUseId,
+ },
+ input: data.args,
+ context: getToolContext(data.name, data.args) ?? undefined,
+ }, this.context)
+
+ // Also emit legacy pre_tool_use for handlers that listen to that
+ this.bus.emit('pre_tool_use', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'pre_tool_use',
+ sessionId: 'clawbox-main',
+ cwd: '',
+ tool: data.name,
+ toolInput: data.args,
+ toolUseId,
+ }, this.context)
+
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'tool_start',
+ label: `${data.name}`,
+ detail: getToolContext(data.name, data.args) ?? undefined,
+ icon: getToolIcon(data.name),
+ })
+ }
+
+ /** Called when SSE emits tool_end */
+ onToolEnd(data: { toolCallId: string; name?: string; result: string; error?: boolean }): void {
+ const toolUseId = this.activeToolIds.get(data.toolCallId) ?? nanoid()
+ this.activeToolIds.delete(data.toolCallId)
+
+ const toolName = data.name ?? 'tool'
+
+ this.bus.emit('tool_end', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'tool_end',
+ agentId: 'clawbox-main',
+ source: 'clawbox',
+ tool: {
+ name: toolName,
+ category: toolNameToCategory(toolName),
+ id: toolUseId,
+ },
+ success: !data.error,
+ }, this.context)
+
+ this.bus.emit('post_tool_use', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'post_tool_use',
+ sessionId: 'clawbox-main',
+ cwd: '',
+ tool: toolName,
+ toolInput: {},
+ toolResponse: {},
+ toolUseId,
+ success: !data.error,
+ }, this.context)
+
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'tool_end',
+ label: `${toolName} ${data.error ? 'failed' : 'done'}`,
+ detail: data.result?.slice(0, 100),
+ icon: data.error ? '\u274C' : '\u2705',
+ })
+ }
+
+ /** Called when SSE emits text */
+ onText(content: string): void {
+ // Only add feed items for substantial text (debounced)
+ if (content.length > 20) {
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'text',
+ label: 'Response',
+ detail: content.slice(0, 80),
+ icon: '\u{1F4AC}',
+ })
+ }
+ }
+
+ /** Called when SSE emits reasoning */
+ onReasoning(content: string): void {
+ this.bus.emit('agent_thinking', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'agent_thinking',
+ agentId: 'clawbox-main',
+ source: 'clawbox',
+ }, this.context)
+
+ if (content.length > 20) {
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'reasoning',
+ label: 'Thinking',
+ detail: content.slice(0, 80),
+ icon: '\u{1F4AD}',
+ })
+ }
+ }
+
+ /** Called when SSE emits done */
+ onDone(): void {
+ this.bus.emit('agent_idle', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'agent_idle',
+ agentId: 'clawbox-main',
+ source: 'clawbox',
+ }, this.context)
+
+ this.bus.emit('stop', {
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'stop',
+ sessionId: 'clawbox-main',
+ cwd: '',
+ stopHookActive: false,
+ }, this.context)
+
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'done',
+ label: 'Complete',
+ icon: '\u2728',
+ })
+ }
+
+ /** Called when SSE emits error */
+ onError(error: string): void {
+ this.addFeedItem({
+ id: nanoid(),
+ timestamp: Date.now(),
+ type: 'error',
+ label: 'Error',
+ detail: error.slice(0, 100),
+ icon: '\u{1F6A8}',
+ })
+ }
+
+ private addFeedItem(item: WorkshopFeedItem): void {
+ useWorkshopStore.getState().addFeedItem(item)
+ }
+
+ dispose(): void {
+ this.activeToolIds.clear()
+ }
+}
diff --git a/src/workshop/components/WorkshopCanvas.tsx b/src/workshop/components/WorkshopCanvas.tsx
new file mode 100644
index 0000000..cf52f84
--- /dev/null
+++ b/src/workshop/components/WorkshopCanvas.tsx
@@ -0,0 +1,110 @@
+/**
+ * WorkshopCanvas — React host for the Three.js workshop scene.
+ * Handles mount, resize, animate, and dispose lifecycle.
+ */
+
+import { useEffect, useRef, useCallback } from 'react'
+import { WorkshopScene } from '../scene/WorkshopScene'
+import { EventBus } from '../events/EventBus'
+import { EventBridge } from '../bridge/EventBridge'
+import { setActiveBridge } from '../bridge/ClawBoxEventTap'
+import { registerAllHandlers } from '../events/handlers'
+import { Claude } from '../entities/ClaudeMon'
+import { useWorkshopStore } from '../store/workshop'
+
+interface WorkshopCanvasProps {
+ className?: string
+}
+
+export function WorkshopCanvas({ className }: WorkshopCanvasProps) {
+ const containerRef = useRef(null)
+ const sceneRef = useRef(null)
+ const busRef = useRef(null)
+ const bridgeRef = useRef(null)
+ const claudeRef = useRef(null)
+
+ const setupScene = useCallback(() => {
+ const container = containerRef.current
+ if (!container) return
+
+ // Create event bus and scene
+ const bus = new EventBus()
+ busRef.current = bus
+
+ const scene = new WorkshopScene(container, bus)
+ sceneRef.current = scene
+
+ // Register event handlers
+ registerAllHandlers(bus)
+
+ // Create a default zone for the main agent
+ scene.createZone('clawbox-main')
+
+ // Create the Claude character
+ const claude = new Claude(scene, { startStation: 'center' })
+ claudeRef.current = claude
+
+ // Create event bridge with context
+ const bridge = new EventBridge(bus, {
+ scene,
+ session: {
+ id: 'clawbox-main',
+ color: 0x4ac8e8,
+ claude,
+ subagents: null,
+ zone: scene.zones.get('clawbox-main') ?? null,
+ stats: {
+ toolsUsed: 0,
+ filesTouched: new Set(),
+ activeSubagents: 0,
+ },
+ },
+ soundEnabled: false,
+ })
+ bridgeRef.current = bridge
+
+ // Register bridge as active tap
+ setActiveBridge(bridge)
+
+ // Mark scene as ready
+ useWorkshopStore.getState().setSceneReady(true)
+ }, [])
+
+ useEffect(() => {
+ setupScene()
+
+ return () => {
+ // Cleanup
+ setActiveBridge(null)
+ useWorkshopStore.getState().setSceneReady(false)
+
+ if (claudeRef.current) {
+ claudeRef.current.dispose()
+ claudeRef.current = null
+ }
+
+ if (bridgeRef.current) {
+ bridgeRef.current.dispose()
+ bridgeRef.current = null
+ }
+
+ if (busRef.current) {
+ busRef.current.clear()
+ busRef.current = null
+ }
+
+ if (sceneRef.current) {
+ sceneRef.current.dispose()
+ sceneRef.current = null
+ }
+ }
+ }, [setupScene])
+
+ return (
+
+ )
+}
diff --git a/src/workshop/components/WorkshopControls.tsx b/src/workshop/components/WorkshopControls.tsx
new file mode 100644
index 0000000..26bf841
--- /dev/null
+++ b/src/workshop/components/WorkshopControls.tsx
@@ -0,0 +1,32 @@
+/**
+ * WorkshopControls — Camera and settings controls for the workshop view.
+ */
+
+import { Eye, EyeOff, RotateCcw } from 'lucide-react'
+import { useWorkshopStore } from '../store/workshop'
+
+export function WorkshopControls() {
+ const showFeed = useWorkshopStore((s) => s.settings.showFeed)
+ const updateSettings = useWorkshopStore((s) => s.updateSettings)
+ const clearFeed = useWorkshopStore((s) => s.clearFeed)
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/workshop/components/WorkshopFeedOverlay.tsx b/src/workshop/components/WorkshopFeedOverlay.tsx
new file mode 100644
index 0000000..10796f2
--- /dev/null
+++ b/src/workshop/components/WorkshopFeedOverlay.tsx
@@ -0,0 +1,75 @@
+/**
+ * WorkshopFeedOverlay — Activity feed panel overlaid on the 3D scene.
+ * Shows real-time events from the chat bridge.
+ */
+
+import { useEffect, useRef } from 'react'
+import { useWorkshopStore } from '../store/workshop'
+
+export function WorkshopFeedOverlay() {
+ const feed = useWorkshopStore((s) => s.feed)
+ const showFeed = useWorkshopStore((s) => s.settings.showFeed)
+ const scrollRef = useRef(null)
+
+ // Auto-scroll to bottom on new items
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+ }
+ }, [feed.length])
+
+ if (!showFeed) return null
+
+ return (
+
+
+
+
+ Activity
+
+
+
+ {feed.length === 0 ? (
+
+ Waiting for activity...
+
+ ) : (
+ feed.map((item) => (
+
+
+ {item.icon && {item.icon}}
+ {item.label}
+
+ {formatTime(item.timestamp)}
+
+
+ {item.detail && (
+
+ {item.detail}
+
+ )}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+function formatTime(ts: number): string {
+ const d = new Date(ts)
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
diff --git a/src/workshop/components/WorkshopView.tsx b/src/workshop/components/WorkshopView.tsx
new file mode 100644
index 0000000..91a4b54
--- /dev/null
+++ b/src/workshop/components/WorkshopView.tsx
@@ -0,0 +1,24 @@
+/**
+ * WorkshopView — Top-level workshop tab view.
+ * Full-screen layout with 3D canvas, feed overlay, and controls.
+ * No PageShell — takes over the entire content area.
+ */
+
+import { WorkshopCanvas } from './WorkshopCanvas'
+import { WorkshopFeedOverlay } from './WorkshopFeedOverlay'
+import { WorkshopControls } from './WorkshopControls'
+
+export function WorkshopView() {
+ return (
+
+ {/* 3D Scene */}
+
+
+ {/* Overlays (pointer-events-none container, children opt-in) */}
+
+
+
+
+
+ )
+}
diff --git a/src/workshop/entities/ClaudeMon.ts b/src/workshop/entities/ClaudeMon.ts
new file mode 100644
index 0000000..d6b8f4c
--- /dev/null
+++ b/src/workshop/entities/ClaudeMon.ts
@@ -0,0 +1,685 @@
+/**
+ * ClaudeMon — Vendored from Vibecraft2. Cute robot character for the workshop.
+ * Adapted: accepts WorkshopScene via interface, no audio dependency.
+ */
+
+import * as THREE from 'three'
+import type { StationType } from '../types'
+import type { CharacterState, CharacterOptions, ICharacter } from './ICharacter'
+import {
+ IdleBehaviorManager,
+ WorkingBehaviorManager,
+ STATION_ANIMATIONS,
+ type CharacterParts,
+} from './animations'
+
+export type ClaudeState = CharacterState
+export type ClaudeOptions = CharacterOptions
+
+interface SceneHost {
+ scene: THREE.Scene
+ stations: Map
+ onRender(cb: (delta: number) => void): void
+ offRender(cb: (delta: number) => void): void
+}
+
+const DEFAULT_OPTIONS: Required = {
+ scale: 1,
+ color: 0x2a3a4a,
+ statusColor: 0x4ade80,
+ startStation: 'center',
+}
+
+export class Claude implements ICharacter {
+ public readonly mesh: THREE.Group
+ public state: CharacterState = 'idle'
+ public currentStation: StationType = 'center'
+ public readonly id: string
+
+ private host: SceneHost
+ private options: Required
+ private targetPosition: THREE.Vector3 | null = null
+ private moveSpeed = 3
+ private bobTime = 0
+ private workTime = 0
+ private thinkTime = 0
+ private updateCallback: ((delta: number) => void) | null = null
+
+ private head: THREE.Group
+ private visor: THREE.Mesh
+ private leftEye: THREE.Mesh
+ private rightEye: THREE.Mesh
+ private body: THREE.Group
+ private leftArm: THREE.Group
+ private rightArm: THREE.Group
+ private antenna: THREE.Group
+ private statusRing: THREE.Mesh
+ private thoughtBubbles: THREE.Group
+ private glowAccents: THREE.Group
+
+ private idleBehaviorManager: IdleBehaviorManager
+ private workingBehaviorManager: WorkingBehaviorManager
+
+ constructor(host: SceneHost, options: ClaudeOptions = {}) {
+ this.host = host
+ this.options = { ...DEFAULT_OPTIONS, ...options }
+ this.id = Math.random().toString(36).substring(2, 9)
+ this.mesh = new THREE.Group()
+
+ this.head = this.createHead()
+ this.visor = this.head.getObjectByName('visor') as THREE.Mesh
+ this.leftEye = this.head.getObjectByName('leftEye') as THREE.Mesh
+ this.rightEye = this.head.getObjectByName('rightEye') as THREE.Mesh
+ this.body = this.createBody()
+ this.leftArm = this.createArm(-1)
+ this.rightArm = this.createArm(1)
+ this.antenna = this.createAntenna()
+ this.statusRing = this.createStatusRing()
+ this.thoughtBubbles = this.createThoughtBubbles()
+ this.glowAccents = this.createGlowAccents()
+
+ this.mesh.add(this.head)
+ this.mesh.add(this.body)
+ this.mesh.add(this.leftArm)
+ this.mesh.add(this.rightArm)
+ this.mesh.add(this.antenna)
+ this.mesh.add(this.statusRing)
+ this.mesh.add(this.thoughtBubbles)
+ this.mesh.add(this.glowAccents)
+
+ this.idleBehaviorManager = new IdleBehaviorManager()
+ this.workingBehaviorManager = new WorkingBehaviorManager()
+
+ this.mesh.scale.setScalar(this.options.scale)
+
+ this.currentStation = this.options.startStation
+ const startStation = host.stations.get(this.options.startStation)
+ if (startStation) {
+ this.mesh.position.copy(startStation.position)
+ }
+
+ host.scene.add(this.mesh)
+
+ this.updateCallback = (delta: number) => this.update(delta)
+ host.onRender(this.updateCallback)
+ }
+
+ private createHead(): THREE.Group {
+ const group = new THREE.Group()
+
+ const headGeometry = new THREE.SphereGeometry(0.28, 32, 32)
+ headGeometry.scale(1, 0.9, 0.85)
+ const headMaterial = new THREE.MeshStandardMaterial({
+ color: this.options.color,
+ roughness: 0.3,
+ metalness: 0.7,
+ })
+ const head = new THREE.Mesh(headGeometry, headMaterial)
+ head.castShadow = true
+ group.add(head)
+
+ const visorGeometry = new THREE.PlaneGeometry(0.32, 0.18)
+ const visorMaterial = new THREE.MeshBasicMaterial({
+ color: 0x1a1a2e,
+ transparent: true,
+ opacity: 0.9,
+ })
+ const visor = new THREE.Mesh(visorGeometry, visorMaterial)
+ visor.name = 'visor'
+ visor.position.set(0, 0.02, 0.24)
+ group.add(visor)
+
+ const frameGeometry = new THREE.RingGeometry(0.17, 0.19, 32)
+ frameGeometry.scale(1, 0.6, 1)
+ const frameMaterial = new THREE.MeshBasicMaterial({
+ color: 0x67e8f9,
+ transparent: true,
+ opacity: 0.6,
+ })
+ const frame = new THREE.Mesh(frameGeometry, frameMaterial)
+ frame.position.set(0, 0.02, 0.241)
+ group.add(frame)
+
+ const eyeShape = new THREE.Shape()
+ const eyeW = 0.032
+ const eyeH = 0.045
+ const eyeR = 0.012
+ eyeShape.moveTo(-eyeW / 2 + eyeR, -eyeH / 2)
+ eyeShape.lineTo(eyeW / 2 - eyeR, -eyeH / 2)
+ eyeShape.quadraticCurveTo(eyeW / 2, -eyeH / 2, eyeW / 2, -eyeH / 2 + eyeR)
+ eyeShape.lineTo(eyeW / 2, eyeH / 2 - eyeR)
+ eyeShape.quadraticCurveTo(eyeW / 2, eyeH / 2, eyeW / 2 - eyeR, eyeH / 2)
+ eyeShape.lineTo(-eyeW / 2 + eyeR, eyeH / 2)
+ eyeShape.quadraticCurveTo(-eyeW / 2, eyeH / 2, -eyeW / 2, eyeH / 2 - eyeR)
+ eyeShape.lineTo(-eyeW / 2, -eyeH / 2 + eyeR)
+ eyeShape.quadraticCurveTo(-eyeW / 2, -eyeH / 2, -eyeW / 2 + eyeR, -eyeH / 2)
+
+ const eyeGeometry = new THREE.ShapeGeometry(eyeShape)
+ const eyeMaterial = new THREE.MeshBasicMaterial({ color: 0x67e8f9 })
+
+ const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial.clone())
+ leftEye.name = 'leftEye'
+ leftEye.position.set(-0.07, 0.03, 0.242)
+ group.add(leftEye)
+
+ const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial.clone())
+ rightEye.name = 'rightEye'
+ rightEye.position.set(0.07, 0.03, 0.242)
+ group.add(rightEye)
+
+ const mouthCurve = new THREE.QuadraticBezierCurve3(
+ new THREE.Vector3(-0.04, 0, 0),
+ new THREE.Vector3(0, -0.015, 0),
+ new THREE.Vector3(0.04, 0, 0)
+ )
+ const mouthPoints = mouthCurve.getPoints(10)
+ const mouthGeometry = new THREE.BufferGeometry().setFromPoints(mouthPoints)
+ const mouthMaterial = new THREE.LineBasicMaterial({
+ color: 0x67e8f9,
+ transparent: true,
+ opacity: 0.8,
+ })
+ const mouth = new THREE.Line(mouthGeometry, mouthMaterial)
+ mouth.position.set(0, -0.04, 0.242)
+ group.add(mouth)
+
+ const panelGeometry = new THREE.RingGeometry(0.27, 0.275, 32, 1, 0, Math.PI)
+ const panelMaterial = new THREE.MeshBasicMaterial({
+ color: 0x1a2a3a,
+ side: THREE.DoubleSide,
+ })
+ const panelLine = new THREE.Mesh(panelGeometry, panelMaterial)
+ panelLine.rotation.x = Math.PI / 2
+ panelLine.position.y = 0.05
+ group.add(panelLine)
+
+ const earGeometry = new THREE.CylinderGeometry(0.06, 0.06, 0.04, 16)
+ const earMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a4a5a,
+ roughness: 0.4,
+ metalness: 0.6,
+ })
+
+ const leftEar = new THREE.Mesh(earGeometry, earMaterial)
+ leftEar.rotation.z = Math.PI / 2
+ leftEar.position.set(-0.26, 0.02, 0)
+ group.add(leftEar)
+
+ const rightEar = new THREE.Mesh(earGeometry, earMaterial)
+ rightEar.rotation.z = Math.PI / 2
+ rightEar.position.set(0.26, 0.02, 0)
+ group.add(rightEar)
+
+ group.position.y = 0.52
+ return group
+ }
+
+ private createBody(): THREE.Group {
+ const group = new THREE.Group()
+
+ const bodyGeometry = new THREE.CylinderGeometry(0.18, 0.22, 0.3, 16)
+ const bodyMaterial = new THREE.MeshStandardMaterial({
+ color: this.options.color,
+ roughness: 0.35,
+ metalness: 0.65,
+ })
+ const body = new THREE.Mesh(bodyGeometry, bodyMaterial)
+ body.castShadow = true
+ group.add(body)
+
+ const panelGeometry = new THREE.PlaneGeometry(0.16, 0.12)
+ const panelMaterial = new THREE.MeshBasicMaterial({
+ color: 0x1a1a2e,
+ transparent: true,
+ opacity: 0.8,
+ })
+ const panel = new THREE.Mesh(panelGeometry, panelMaterial)
+ panel.position.set(0, 0.02, 0.18)
+ group.add(panel)
+
+ const lightGeometry = new THREE.CircleGeometry(0.03, 16)
+ const lightMaterial = new THREE.MeshBasicMaterial({
+ color: 0xa78bfa,
+ transparent: true,
+ opacity: 0.9,
+ })
+ const chestLight = new THREE.Mesh(lightGeometry, lightMaterial)
+ chestLight.position.set(0, 0.02, 0.181)
+ chestLight.name = 'chestLight'
+ group.add(chestLight)
+
+ const beltGeometry = new THREE.TorusGeometry(0.2, 0.02, 8, 32)
+ const beltMaterial = new THREE.MeshStandardMaterial({
+ color: 0x4a5a6a,
+ roughness: 0.3,
+ metalness: 0.7,
+ })
+ const belt = new THREE.Mesh(beltGeometry, beltMaterial)
+ belt.rotation.x = Math.PI / 2
+ belt.position.y = -0.12
+ group.add(belt)
+
+ const legGeometry = new THREE.CylinderGeometry(0.06, 0.07, 0.15, 12)
+ const legMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a4a5a,
+ roughness: 0.4,
+ metalness: 0.6,
+ })
+
+ const leftLeg = new THREE.Mesh(legGeometry, legMaterial)
+ leftLeg.position.set(-0.1, -0.22, 0)
+ leftLeg.castShadow = true
+ group.add(leftLeg)
+
+ const rightLeg = new THREE.Mesh(legGeometry, legMaterial)
+ rightLeg.position.set(0.1, -0.22, 0)
+ rightLeg.castShadow = true
+ group.add(rightLeg)
+
+ const footGeometry = new THREE.SphereGeometry(0.07, 12, 8)
+ footGeometry.scale(1.2, 0.5, 1.3)
+ const footMaterial = new THREE.MeshStandardMaterial({
+ color: 0x2a3a4a,
+ roughness: 0.4,
+ metalness: 0.6,
+ })
+
+ const leftFoot = new THREE.Mesh(footGeometry, footMaterial)
+ leftFoot.position.set(-0.1, -0.32, 0.02)
+ leftFoot.name = 'leftFoot'
+ group.add(leftFoot)
+
+ const rightFoot = new THREE.Mesh(footGeometry, footMaterial)
+ rightFoot.position.set(0.1, -0.32, 0.02)
+ rightFoot.name = 'rightFoot'
+ group.add(rightFoot)
+
+ group.position.y = 0.22
+ return group
+ }
+
+ private createArm(side: number): THREE.Group {
+ const group = new THREE.Group()
+
+ const shoulderGeometry = new THREE.SphereGeometry(0.05, 12, 12)
+ const jointMaterial = new THREE.MeshStandardMaterial({
+ color: 0x4a5a6a,
+ roughness: 0.3,
+ metalness: 0.7,
+ })
+ const shoulder = new THREE.Mesh(shoulderGeometry, jointMaterial)
+ group.add(shoulder)
+
+ const armGeometry = new THREE.CylinderGeometry(0.035, 0.04, 0.15, 10)
+ const armMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a4a5a,
+ roughness: 0.4,
+ metalness: 0.6,
+ })
+ const arm = new THREE.Mesh(armGeometry, armMaterial)
+ arm.position.y = -0.1
+ arm.castShadow = true
+ group.add(arm)
+
+ const handGeometry = new THREE.SphereGeometry(0.045, 12, 12)
+ const hand = new THREE.Mesh(handGeometry, jointMaterial)
+ hand.position.y = -0.18
+ hand.name = 'hand'
+ group.add(hand)
+
+ group.position.set(side * 0.24, 0.26, 0)
+ return group
+ }
+
+ private createAntenna(): THREE.Group {
+ const group = new THREE.Group()
+
+ const baseGeometry = new THREE.CylinderGeometry(0.03, 0.04, 0.04, 12)
+ const baseMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a4a5a,
+ roughness: 0.4,
+ metalness: 0.6,
+ })
+ group.add(new THREE.Mesh(baseGeometry, baseMaterial))
+
+ const stalkGeometry = new THREE.CylinderGeometry(0.015, 0.02, 0.12, 8)
+ const stalkMaterial = new THREE.MeshStandardMaterial({
+ color: 0x4a5a6a,
+ roughness: 0.3,
+ metalness: 0.7,
+ })
+ const stalk = new THREE.Mesh(stalkGeometry, stalkMaterial)
+ stalk.position.y = 0.08
+ group.add(stalk)
+
+ const tipGeometry = new THREE.SphereGeometry(0.035, 12, 12)
+ const tipMaterial = new THREE.MeshBasicMaterial({
+ color: 0x67e8f9,
+ transparent: true,
+ opacity: 0.9,
+ })
+ const tip = new THREE.Mesh(tipGeometry, tipMaterial)
+ tip.position.y = 0.16
+ tip.name = 'antennaTip'
+ group.add(tip)
+
+ group.position.set(0, 0.78, 0)
+ return group
+ }
+
+ private createGlowAccents(): THREE.Group {
+ const group = new THREE.Group()
+
+ const lineMaterial = new THREE.MeshBasicMaterial({
+ color: 0x67e8f9,
+ transparent: true,
+ opacity: 0.4,
+ })
+
+ const lineGeometry = new THREE.PlaneGeometry(0.01, 0.2)
+
+ const leftLine = new THREE.Mesh(lineGeometry, lineMaterial.clone())
+ leftLine.position.set(-0.12, 0.22, 0.19)
+ group.add(leftLine)
+
+ const rightLine = new THREE.Mesh(lineGeometry, lineMaterial.clone())
+ rightLine.position.set(0.12, 0.22, 0.19)
+ group.add(rightLine)
+
+ return group
+ }
+
+ private createStatusRing(): THREE.Mesh {
+ const geometry = new THREE.RingGeometry(0.28, 0.32, 32)
+ const material = new THREE.MeshBasicMaterial({
+ color: this.options.statusColor,
+ transparent: true,
+ opacity: 0.5,
+ side: THREE.DoubleSide,
+ })
+ const ring = new THREE.Mesh(geometry, material)
+ ring.rotation.x = -Math.PI / 2
+ ring.position.y = 0.01
+ return ring
+ }
+
+ private createThoughtBubbles(): THREE.Group {
+ const group = new THREE.Group()
+
+ const sizes = [0.04, 0.06, 0.09]
+ const positions = [
+ { x: 0.3, y: 0.75, z: 0.1 },
+ { x: 0.42, y: 0.9, z: 0.12 },
+ { x: 0.52, y: 1.1, z: 0.14 },
+ ]
+
+ sizes.forEach((size, i) => {
+ const geometry = new THREE.CircleGeometry(size, 6)
+ const material = new THREE.MeshBasicMaterial({
+ color: 0x67e8f9,
+ transparent: true,
+ opacity: 0.6,
+ })
+ const bubble = new THREE.Mesh(geometry, material)
+ bubble.position.set(positions[i].x, positions[i].y, positions[i].z)
+ bubble.rotation.z = Math.PI / 6
+ bubble.userData.baseY = positions[i].y
+ bubble.userData.offset = i * 0.7
+ group.add(bubble)
+ })
+
+ group.visible = false
+ return group
+ }
+
+ moveTo(station: StationType): void {
+ const targetStation = this.host.stations.get(station)
+ if (!targetStation) return
+
+ this.targetPosition = targetStation.position.clone()
+ this.currentStation = station
+ this.state = 'walking'
+ this.updateStatusColor()
+ }
+
+ moveToPosition(position: THREE.Vector3, station: StationType): void {
+ this.targetPosition = position.clone()
+ this.currentStation = station
+ this.state = 'walking'
+ this.updateStatusColor()
+ }
+
+ setState(state: CharacterState): void {
+ const parts = this.getCharacterParts()
+
+ if (this.state === 'idle' && state !== 'idle') {
+ this.idleBehaviorManager.stop(parts)
+ }
+ if (this.state === 'working' && state !== 'working') {
+ this.workingBehaviorManager.stop(parts)
+ }
+
+ this.state = state
+ this.updateStatusColor()
+
+ if (state === 'working') {
+ this.workTime = 0
+ this.workingBehaviorManager.start(this.currentStation, parts)
+ } else if (state === 'thinking') {
+ this.thinkTime = 0
+ }
+ }
+
+ private getCharacterParts(): CharacterParts {
+ return {
+ head: this.head,
+ leftEye: this.leftEye,
+ rightEye: this.rightEye,
+ leftArm: this.leftArm,
+ rightArm: this.rightArm,
+ antenna: this.antenna,
+ body: this.body,
+ mesh: this.mesh,
+ }
+ }
+
+ private updateStatusColor(): void {
+ const material = this.statusRing.material as THREE.MeshBasicMaterial
+ const antennaTip = this.antenna.getObjectByName('antennaTip') as THREE.Mesh
+ const antennaMaterial = antennaTip.material as THREE.MeshBasicMaterial
+ const leftEyeMat = this.leftEye.material as THREE.MeshBasicMaterial
+ const rightEyeMat = this.rightEye.material as THREE.MeshBasicMaterial
+
+ switch (this.state) {
+ case 'idle':
+ material.color.setHex(0x4ade80)
+ material.opacity = 0.5
+ antennaMaterial.color.setHex(0x4ade80)
+ leftEyeMat.color.setHex(0x67e8f9)
+ rightEyeMat.color.setHex(0x67e8f9)
+ break
+ case 'walking':
+ material.color.setHex(0x60a5fa)
+ material.opacity = 0.6
+ antennaMaterial.color.setHex(0x60a5fa)
+ leftEyeMat.color.setHex(0x60a5fa)
+ rightEyeMat.color.setHex(0x60a5fa)
+ break
+ case 'working':
+ material.color.setHex(0xfbbf24)
+ material.opacity = 0.7
+ antennaMaterial.color.setHex(0xfbbf24)
+ leftEyeMat.color.setHex(0xfbbf24)
+ rightEyeMat.color.setHex(0xfbbf24)
+ break
+ case 'thinking':
+ material.color.setHex(0xa78bfa)
+ material.opacity = 0.6
+ antennaMaterial.color.setHex(0xa78bfa)
+ leftEyeMat.color.setHex(0xa78bfa)
+ rightEyeMat.color.setHex(0xa78bfa)
+ break
+ }
+ }
+
+ private update(delta: number): void {
+ if (this.targetPosition && this.state === 'walking') {
+ const direction = this.targetPosition.clone().sub(this.mesh.position)
+ const distance = direction.length()
+
+ if (distance > 0.1) {
+ direction.normalize()
+ const moveDistance = Math.min(this.moveSpeed * delta, distance)
+ this.mesh.position.add(direction.multiplyScalar(moveDistance))
+
+ const angle = Math.atan2(direction.x, direction.z)
+ this.mesh.rotation.y = angle
+
+ this.bobTime += delta * 12
+ this.head.position.y = 0.52 + Math.abs(Math.sin(this.bobTime)) * 0.04
+ this.leftArm.rotation.x = Math.sin(this.bobTime) * 0.4
+ this.rightArm.rotation.x = Math.sin(this.bobTime + Math.PI) * 0.4
+
+ const leftFoot = this.body.getObjectByName('leftFoot') as THREE.Mesh
+ const rightFoot = this.body.getObjectByName('rightFoot') as THREE.Mesh
+ if (leftFoot && rightFoot) {
+ leftFoot.position.y = -0.32 + Math.max(0, Math.sin(this.bobTime)) * 0.03
+ rightFoot.position.y = -0.32 + Math.max(0, Math.sin(this.bobTime + Math.PI)) * 0.03
+ }
+
+ this.antenna.rotation.x = Math.sin(this.bobTime * 1.5) * 0.15
+ this.leftEye.scale.setScalar(1.1)
+ this.rightEye.scale.setScalar(1.1)
+ } else {
+ this.mesh.position.copy(this.targetPosition)
+ this.targetPosition = null
+ this.setState(this.currentStation === 'center' ? 'idle' : 'working')
+ }
+ }
+
+ if (this.state === 'idle') {
+ this.bobTime += delta * 2
+ const behaviorPlaying = this.idleBehaviorManager.update(this.getCharacterParts(), delta)
+
+ if (!behaviorPlaying) {
+ this.head.position.y = 0.52 + Math.sin(this.bobTime) * 0.015
+ this.antenna.rotation.z = Math.sin(this.bobTime * 0.7) * 0.1
+ this.leftArm.rotation.x = Math.sin(this.bobTime * 0.5) * 0.05
+ this.rightArm.rotation.x = Math.sin(this.bobTime * 0.5 + 0.5) * 0.05
+
+ const blinkCycle = (this.bobTime * 0.4) % (Math.PI * 2)
+ if (blinkCycle < 0.15) {
+ this.leftEye.scale.setScalar(0.3)
+ this.rightEye.scale.setScalar(0.3)
+ } else {
+ this.leftEye.scale.setScalar(1)
+ this.rightEye.scale.setScalar(1)
+ }
+ }
+ }
+
+ if (this.state === 'working') {
+ this.workTime += delta
+ this.workingBehaviorManager.update(this.getCharacterParts(), delta)
+
+ this.thinkTime += delta * 3
+ this.thoughtBubbles.visible = true
+ this.thoughtBubbles.scale.setScalar(0.5)
+ this.thoughtBubbles.children.forEach((bubble) => {
+ const mesh = bubble as THREE.Mesh
+ const baseY = mesh.userData.baseY as number
+ const offset = mesh.userData.offset as number
+ mesh.position.y = baseY + Math.sin(this.thinkTime * 3 + offset) * 0.03
+ const mat = mesh.material as THREE.MeshBasicMaterial
+ mat.opacity = 0.3 + Math.sin(this.thinkTime * 4 + offset) * 0.2
+ })
+ }
+
+ if (this.state === 'thinking') {
+ this.thinkTime += delta * 2
+ this.head.rotation.z = Math.sin(this.thinkTime * 0.5) * 0.1
+ this.head.position.y = 0.52 + Math.sin(this.thinkTime) * 0.01
+ this.rightArm.rotation.x = -0.8
+ this.rightArm.rotation.z = -0.3 + Math.sin(this.thinkTime) * 0.05
+ this.leftArm.rotation.x = Math.sin(this.thinkTime * 0.7) * 0.1
+ this.antenna.rotation.z = Math.sin(this.thinkTime) * 0.2
+ this.antenna.rotation.x = Math.sin(this.thinkTime * 0.7) * 0.15
+ this.leftEye.position.x = -0.07 + Math.sin(this.thinkTime * 0.5) * 0.02
+ this.rightEye.position.x = 0.07 + Math.sin(this.thinkTime * 0.5) * 0.02
+ this.leftEye.position.y = 0.03 + 0.01
+ this.rightEye.position.y = 0.03 + 0.01
+
+ this.thoughtBubbles.visible = true
+ this.thoughtBubbles.scale.setScalar(1)
+ this.thoughtBubbles.children.forEach((bubble) => {
+ const mesh = bubble as THREE.Mesh
+ const baseY = mesh.userData.baseY as number
+ const offset = mesh.userData.offset as number
+ mesh.position.y = baseY + Math.sin(this.thinkTime * 2 + offset) * 0.05
+ const mat = mesh.material as THREE.MeshBasicMaterial
+ mat.opacity = 0.5 + Math.sin(this.thinkTime * 3 + offset) * 0.3
+ })
+ } else if (this.state !== 'working') {
+ this.thoughtBubbles.visible = false
+ this.leftEye.position.set(-0.07, 0.03, 0.242)
+ this.rightEye.position.set(0.07, 0.03, 0.242)
+ this.head.rotation.z = 0
+ this.rightArm.rotation.z = 0
+ }
+
+ this.statusRing.rotation.z += delta * 0.3
+
+ this.glowAccents.children.forEach((line, i) => {
+ const mat = (line as THREE.Mesh).material as THREE.MeshBasicMaterial
+ mat.opacity = 0.3 + Math.sin(Date.now() * 0.002 + i) * 0.2
+ })
+
+ const antennaTip = this.antenna.getObjectByName('antennaTip') as THREE.Mesh
+ if (antennaTip) {
+ const mat = antennaTip.material as THREE.MeshBasicMaterial
+ mat.opacity = 0.7 + Math.sin(Date.now() * 0.003) * 0.2
+ }
+
+ const chestLight = this.body.getObjectByName('chestLight') as THREE.Mesh
+ if (chestLight) {
+ const mat = chestLight.material as THREE.MeshBasicMaterial
+ mat.opacity = 0.6 + Math.sin(Date.now() * 0.004) * 0.3
+ }
+ }
+
+ getIdleBehaviorNames(): string[] {
+ return this.idleBehaviorManager.getBehaviorNames()
+ }
+
+ playIdleBehavior(name: string): boolean {
+ if (this.state !== 'idle') this.setState('idle')
+ return this.idleBehaviorManager.forcePlay(name, this.getCharacterParts())
+ }
+
+ playRandomIdleBehavior(): string | null {
+ if (this.state !== 'idle') this.setState('idle')
+ return this.idleBehaviorManager.forcePlayRandom(this.getCharacterParts())
+ }
+
+ dispose(): void {
+ if (this.updateCallback) {
+ this.host.offRender(this.updateCallback)
+ this.updateCallback = null
+ }
+
+ this.host.scene.remove(this.mesh)
+
+ const disposeMesh = (obj: THREE.Object3D) => {
+ if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) {
+ obj.geometry.dispose()
+ if (Array.isArray(obj.material)) {
+ obj.material.forEach(m => m.dispose())
+ } else if (obj.material) {
+ obj.material.dispose()
+ }
+ }
+ }
+
+ this.mesh.traverse(disposeMesh)
+ }
+}
diff --git a/src/workshop/entities/ICharacter.ts b/src/workshop/entities/ICharacter.ts
new file mode 100644
index 0000000..cb46e65
--- /dev/null
+++ b/src/workshop/entities/ICharacter.ts
@@ -0,0 +1,29 @@
+/**
+ * ICharacter — Vendored from Vibecraft2. Character interface for workshop entities.
+ */
+
+import * as THREE from 'three'
+import type { StationType } from '../types'
+
+export type CharacterState = 'idle' | 'walking' | 'working' | 'thinking'
+
+export interface CharacterOptions {
+ scale?: number
+ color?: number
+ statusColor?: number
+ startStation?: StationType
+}
+
+export interface ICharacter {
+ readonly mesh: THREE.Group
+ state: CharacterState
+ currentStation: StationType
+ readonly id: string
+ moveTo(station: StationType): void
+ moveToPosition(position: THREE.Vector3, station: StationType): void
+ setState(state: CharacterState): void
+ dispose(): void
+}
+
+export type CharacterModel = 'claudemon'
+export const DEFAULT_CHARACTER_MODEL: CharacterModel = 'claudemon'
diff --git a/src/workshop/entities/SubagentManager.ts b/src/workshop/entities/SubagentManager.ts
new file mode 100644
index 0000000..85d6640
--- /dev/null
+++ b/src/workshop/entities/SubagentManager.ts
@@ -0,0 +1,109 @@
+/**
+ * SubagentManager — Vendored from Vibecraft2. Manages subagent visualizations.
+ */
+
+import * as THREE from 'three'
+import { Claude, type ClaudeOptions } from './ClaudeMon'
+
+export interface Subagent {
+ id: string
+ toolUseId: string
+ claude: Claude
+ spawnTime: number
+ description?: string
+}
+
+const TINT_LEVELS = [0.35, 0.5, 0.65, 0.8]
+
+function lightenColor(baseHex: number, amount: number): number {
+ const base = new THREE.Color(baseHex)
+ const white = new THREE.Color(0xffffff)
+ base.lerp(white, amount)
+ return base.getHex()
+}
+
+interface SceneHost {
+ scene: THREE.Scene
+ stations: Map
+ onRender(cb: (delta: number) => void): void
+ offRender(cb: (delta: number) => void): void
+}
+
+export class SubagentManager {
+ private host: SceneHost
+ private subagents: Map = new Map()
+ private spawnIndex = 0
+ private parentColor: number
+
+ constructor(host: SceneHost, parentColor?: number) {
+ this.host = host
+ this.parentColor = parentColor ?? 0x4ac8e8
+ }
+
+ setParentColor(color: number): void {
+ this.parentColor = color
+ }
+
+ spawn(toolUseId: string, description?: string): Subagent {
+ if (this.subagents.has(toolUseId)) {
+ return this.subagents.get(toolUseId)!
+ }
+
+ const tint = TINT_LEVELS[this.spawnIndex % TINT_LEVELS.length]
+ const color = lightenColor(this.parentColor, tint)
+ this.spawnIndex++
+
+ const options: ClaudeOptions = {
+ scale: 0.6,
+ color,
+ statusColor: color,
+ startStation: 'portal',
+ }
+
+ const claude = new Claude(this.host as any, options)
+ claude.setState('thinking')
+
+ const offset = this.subagents.size * 0.5
+ const angle = this.subagents.size * Math.PI * 0.4
+ claude.mesh.position.x += Math.sin(angle) * offset
+ claude.mesh.position.z += Math.cos(angle) * offset
+
+ const subagent: Subagent = {
+ id: claude.id,
+ toolUseId,
+ claude,
+ spawnTime: Date.now(),
+ description,
+ }
+
+ this.subagents.set(toolUseId, subagent)
+ return subagent
+ }
+
+ remove(toolUseId: string): void {
+ const subagent = this.subagents.get(toolUseId)
+ if (subagent) {
+ subagent.claude.dispose()
+ this.subagents.delete(toolUseId)
+ }
+ }
+
+ get(toolUseId: string): Subagent | undefined {
+ return this.subagents.get(toolUseId)
+ }
+
+ getAll(): Subagent[] {
+ return Array.from(this.subagents.values())
+ }
+
+ get count(): number {
+ return this.subagents.size
+ }
+
+ dispose(): void {
+ for (const subagent of this.subagents.values()) {
+ subagent.claude.dispose()
+ }
+ this.subagents.clear()
+ }
+}
diff --git a/src/workshop/entities/animations/AnimationTypes.ts b/src/workshop/entities/animations/AnimationTypes.ts
new file mode 100644
index 0000000..cbbf2bb
--- /dev/null
+++ b/src/workshop/entities/animations/AnimationTypes.ts
@@ -0,0 +1,215 @@
+/**
+ * AnimationTypes - Shared types and utilities for character animations
+ *
+ * This module provides the foundation for all animation behaviors:
+ * - Common interfaces (CharacterParts, AnimationBehavior)
+ * - Easing functions for smooth motion
+ * - Category system for organizing behaviors
+ *
+ * To add new animations:
+ * 1. Create behavior objects implementing AnimationBehavior
+ * 2. Add to the appropriate registry (IDLE_BEHAVIORS, STATION_ANIMATIONS, etc.)
+ * 3. Optionally tag with categories for filtering
+ */
+
+import * as THREE from 'three'
+
+// ============================================================================
+// Character Parts - What can be animated
+// ============================================================================
+
+/** Character parts that behaviors can animate */
+export interface CharacterParts {
+ head: THREE.Group
+ leftEye: THREE.Mesh
+ rightEye: THREE.Mesh
+ leftArm: THREE.Group
+ rightArm: THREE.Group
+ antenna: THREE.Group
+ body: THREE.Group
+ mesh: THREE.Group // Root mesh for whole-body animations
+}
+
+// ============================================================================
+// Animation Behavior Interface
+// ============================================================================
+
+/** Categories for organizing and filtering behaviors */
+export type AnimationCategory =
+ | 'idle' // Random idle fidgets
+ | 'dance' // Dance moves
+ | 'emote' // Emotional expressions
+ | 'work' // Station-specific work
+ | 'reaction' // Success/error/completion reactions
+ | 'transition' // State change animations
+
+/**
+ * Base animation behavior interface
+ *
+ * All animations follow this contract:
+ * - name: unique identifier
+ * - duration: seconds for one cycle
+ * - update: called each frame with progress 0→1
+ * - reset: cleanup when animation ends (optional but recommended)
+ */
+export interface AnimationBehavior {
+ /** Unique name for debugging and lookup */
+ name: string
+
+ /** Duration of one animation cycle in seconds */
+ duration: number
+
+ /** Categories for filtering (e.g., ['idle', 'dance']) */
+ categories?: AnimationCategory[]
+
+ /**
+ * Update the animation
+ * @param parts - Character parts to animate
+ * @param progress - Animation progress 0→1
+ * @param deltaTime - Frame delta time in seconds
+ */
+ update: (parts: CharacterParts, progress: number, deltaTime: number) => void
+
+ /**
+ * Reset character to default pose (called when animation ends)
+ * Important: Always implement this to avoid stuck poses!
+ */
+ reset?: (parts: CharacterParts) => void
+}
+
+/** Idle behavior with weight for random selection */
+export interface IdleBehavior extends AnimationBehavior {
+ /** Probability weight (higher = more likely to be picked) */
+ weight: number
+}
+
+/** Working behavior with loop control */
+export interface WorkingBehavior extends AnimationBehavior {
+ /** If true, animation loops until stopped */
+ loop: boolean
+}
+
+/** Reaction behavior (success, error, etc.) */
+export interface ReactionBehavior extends AnimationBehavior {
+ /** When to trigger: 'success', 'error', 'complete', etc. */
+ trigger: string
+}
+
+// ============================================================================
+// Easing Functions - For smooth, natural motion
+// ============================================================================
+
+/** Smooth ease in-out (slow start, fast middle, slow end) */
+export const easeInOut = (t: number): number =>
+ t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
+
+/** Ease out (fast start, slow end) */
+export const easeOut = (t: number): number =>
+ 1 - Math.pow(1 - t, 3)
+
+/** Ease in (slow start, fast end) */
+export const easeIn = (t: number): number =>
+ t * t * t
+
+/** Bounce easing (playful bouncy motion) */
+export const bounce = (t: number): number => {
+ const n1 = 7.5625
+ const d1 = 2.75
+ if (t < 1 / d1) return n1 * t * t
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375
+ return n1 * (t -= 2.625 / d1) * t + 0.984375
+}
+
+/** Elastic easing (springy overshoot) */
+export const elastic = (t: number): number => {
+ if (t === 0 || t === 1) return t
+ const p = 0.3
+ const s = p / 4
+ return Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1
+}
+
+/** Back easing (slight overshoot) */
+export const easeOutBack = (t: number): number => {
+ const c1 = 1.70158
+ const c3 = c1 + 1
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
+}
+
+/** Linear (no easing) */
+export const linear = (t: number): number => t
+
+// ============================================================================
+// Animation Utilities
+// ============================================================================
+
+/**
+ * Interpolate between two values
+ */
+export const lerp = (a: number, b: number, t: number): number =>
+ a + (b - a) * t
+
+/**
+ * Clamp a value between min and max
+ */
+export const clamp = (value: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, value))
+
+/**
+ * Map a value from one range to another
+ */
+export const mapRange = (
+ value: number,
+ inMin: number,
+ inMax: number,
+ outMin: number,
+ outMax: number
+): number => {
+ return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin)
+}
+
+/**
+ * Create a ping-pong value (0→1→0) from progress
+ */
+export const pingPong = (t: number): number =>
+ t < 0.5 ? t * 2 : 2 - t * 2
+
+/**
+ * Create a stepped value (discrete steps instead of smooth)
+ */
+export const stepped = (t: number, steps: number): number =>
+ Math.floor(t * steps) / steps
+
+// ============================================================================
+// Default Pose - Reset helper
+// ============================================================================
+
+/**
+ * Reset character to default idle pose
+ * Use this in reset() functions or call directly
+ */
+export const resetToDefaultPose = (parts: CharacterParts): void => {
+ // Head
+ parts.head.position.set(0, 0.52, 0)
+ parts.head.rotation.set(0, 0, 0)
+
+ // Eyes
+ parts.leftEye.position.set(-0.07, 0.03, 0.242)
+ parts.rightEye.position.set(0.07, 0.03, 0.242)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+
+ // Arms
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+
+ // Antenna
+ parts.antenna.rotation.set(0, 0, 0)
+
+ // Body
+ parts.body.rotation.set(0, 0, 0)
+
+ // Mesh (root)
+ parts.mesh.rotation.z = 0
+ // Note: Don't reset mesh position as it's controlled by movement system
+}
diff --git a/src/workshop/entities/animations/IdleBehaviors.ts b/src/workshop/entities/animations/IdleBehaviors.ts
new file mode 100644
index 0000000..bb2c07d
--- /dev/null
+++ b/src/workshop/entities/animations/IdleBehaviors.ts
@@ -0,0 +1,884 @@
+/**
+ * IdleBehaviors - Modular idle animations for ClaudeMon
+ *
+ * Each behavior is a self-contained animation that plays during idle state.
+ * Easy to add, remove, or swap behaviors by editing the IDLE_BEHAVIORS array.
+ *
+ * Architecture:
+ * - Uses shared AnimationTypes for interfaces and utilities
+ * - Each behavior has: name, duration, weight (probability), update function
+ * - IdleBehaviorManager picks random behaviors based on weights
+ */
+
+// Re-export shared types for convenience
+export type { CharacterParts, IdleBehavior } from './AnimationTypes'
+
+import {
+ type CharacterParts,
+ type IdleBehavior,
+ easeInOut,
+ easeOut,
+ easeIn,
+ bounce,
+} from './AnimationTypes'
+
+// ============================================================================
+// Idle Behaviors - Add new behaviors here!
+// ============================================================================
+
+const lookAround: IdleBehavior = {
+ name: 'lookAround',
+ duration: 3,
+ weight: 10,
+ update: (parts, progress) => {
+ // Look left, pause, look right, pause, center
+ const t = progress * 4
+ let lookX = 0
+ let lookY = 0
+
+ if (t < 1) {
+ // Look left
+ lookX = -easeInOut(t) * 0.03
+ lookY = easeInOut(t) * 0.01
+ } else if (t < 2) {
+ // Hold left, slight head tilt
+ lookX = -0.03
+ lookY = 0.01
+ parts.head.rotation.z = Math.sin((t - 1) * Math.PI) * 0.05
+ } else if (t < 3) {
+ // Look right
+ const rt = t - 2
+ lookX = -0.03 + easeInOut(rt) * 0.06
+ lookY = 0.01 - easeInOut(rt) * 0.02
+ } else {
+ // Return to center
+ const rt = t - 3
+ lookX = 0.03 - easeOut(rt) * 0.03
+ lookY = -0.01 + easeOut(rt) * 0.01
+ parts.head.rotation.z = 0
+ }
+
+ parts.leftEye.position.x = -0.07 + lookX
+ parts.rightEye.position.x = 0.07 + lookX
+ parts.leftEye.position.y = 0.03 + lookY
+ parts.rightEye.position.y = 0.03 + lookY
+ },
+ reset: (parts) => {
+ parts.leftEye.position.set(-0.07, 0.03, 0.242)
+ parts.rightEye.position.set(0.07, 0.03, 0.242)
+ parts.head.rotation.z = 0
+ }
+}
+
+const curiousTilt: IdleBehavior = {
+ name: 'curiousTilt',
+ duration: 2,
+ weight: 8,
+ update: (parts, progress) => {
+ // Tilt head curiously, antenna perks up
+ const t = progress < 0.5 ? easeOut(progress * 2) : easeIn((1 - progress) * 2)
+
+ parts.head.rotation.z = t * 0.15
+ parts.antenna.rotation.z = -t * 0.2
+ parts.antenna.rotation.x = -t * 0.1
+
+ // Eyes widen slightly
+ const eyeScale = 1 + t * 0.15
+ parts.leftEye.scale.setScalar(eyeScale)
+ parts.rightEye.scale.setScalar(eyeScale)
+ },
+ reset: (parts) => {
+ parts.head.rotation.z = 0
+ parts.antenna.rotation.z = 0
+ parts.antenna.rotation.x = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ }
+}
+
+const happyBounce: IdleBehavior = {
+ name: 'happyBounce',
+ duration: 1.5,
+ weight: 6,
+ update: (parts, progress) => {
+ // Quick happy bounces - whole body bounces!
+ const bounceCount = 3
+ const t = progress * bounceCount
+ const bouncePhase = t % 1
+ const bounceHeight = bounce(bouncePhase) * 0.12 * (1 - progress * 0.5)
+
+ // Bounce the whole mesh, not just the head
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounceHeight
+
+ // Arms swing with bounces
+ const armSwing = Math.sin(t * Math.PI * 2) * 0.3
+ parts.leftArm.rotation.x = armSwing
+ parts.rightArm.rotation.x = -armSwing
+ parts.leftArm.rotation.z = -0.1 - Math.abs(armSwing) * 0.2
+ parts.rightArm.rotation.z = 0.1 + Math.abs(armSwing) * 0.2
+
+ // Antenna bounces with energy
+ parts.antenna.rotation.x = Math.sin(t * Math.PI * 2) * 0.2
+ parts.antenna.rotation.z = Math.sin(t * Math.PI * 4) * 0.1
+ },
+ reset: (parts) => {
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+const stretch: IdleBehavior = {
+ name: 'stretch',
+ duration: 2.5,
+ weight: 5,
+ update: (parts, progress) => {
+ // Big stretch - arms up, lean back
+ let armRaise = 0
+ let lean = 0
+
+ if (progress < 0.3) {
+ // Arms going up
+ const t = easeOut(progress / 0.3)
+ armRaise = t
+ lean = t * 0.1
+ } else if (progress < 0.7) {
+ // Hold stretch
+ armRaise = 1
+ lean = 0.1
+ // Slight wiggle at peak
+ const wiggle = Math.sin((progress - 0.3) * 20) * 0.02
+ parts.leftArm.rotation.z = -0.3 + wiggle
+ parts.rightArm.rotation.z = 0.3 - wiggle
+ } else {
+ // Arms coming down
+ const t = easeIn((progress - 0.7) / 0.3)
+ armRaise = 1 - t
+ lean = 0.1 * (1 - t)
+ }
+
+ parts.leftArm.rotation.x = -armRaise * 2.5
+ parts.rightArm.rotation.x = -armRaise * 2.5
+ parts.leftArm.rotation.z = -armRaise * 0.3
+ parts.rightArm.rotation.z = armRaise * 0.3
+ parts.head.rotation.x = lean
+
+ // Eyes close slightly during stretch
+ const eyeSquint = armRaise * 0.5
+ parts.leftEye.scale.y = 1 - eyeSquint
+ parts.rightEye.scale.y = 1 - eyeSquint
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.x = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ }
+}
+
+const wave: IdleBehavior = {
+ name: 'wave',
+ duration: 2,
+ weight: 4,
+ update: (parts, progress) => {
+ // Friendly wave!
+ let armUp = 0
+ let waveAngle = 0
+
+ if (progress < 0.2) {
+ // Raise arm
+ armUp = easeOut(progress / 0.2)
+ } else if (progress < 0.8) {
+ // Wave back and forth
+ armUp = 1
+ const waveProgress = (progress - 0.2) / 0.6
+ waveAngle = Math.sin(waveProgress * Math.PI * 4) * 0.4
+ } else {
+ // Lower arm
+ armUp = 1 - easeIn((progress - 0.8) / 0.2)
+ }
+
+ parts.rightArm.rotation.x = -armUp * 2.2
+ parts.rightArm.rotation.z = armUp * 0.5 + waveAngle
+
+ // Look at "camera" while waving
+ if (progress > 0.1 && progress < 0.9) {
+ parts.leftEye.position.z = 0.242 + 0.01
+ parts.rightEye.position.z = 0.242 + 0.01
+ }
+ },
+ reset: (parts) => {
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.position.z = 0.242
+ parts.rightEye.position.z = 0.242
+ }
+}
+
+const doubleBlink: IdleBehavior = {
+ name: 'doubleBlink',
+ duration: 0.6,
+ weight: 12,
+ update: (parts, progress) => {
+ // Quick double blink
+ const t = progress * 2
+ let eyeScale = 1
+
+ if (t < 0.5) {
+ // First blink
+ eyeScale = t < 0.25 ? 1 - easeIn(t * 4) * 0.9 : 0.1 + easeOut((t - 0.25) * 4) * 0.9
+ } else if (t < 1) {
+ // Pause
+ eyeScale = 1
+ } else if (t < 1.5) {
+ // Second blink
+ const bt = t - 1
+ eyeScale = bt < 0.25 ? 1 - easeIn(bt * 4) * 0.9 : 0.1 + easeOut((bt - 0.25) * 4) * 0.9
+ }
+
+ parts.leftEye.scale.setScalar(eyeScale)
+ parts.rightEye.scale.setScalar(eyeScale)
+ },
+ reset: (parts) => {
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ }
+}
+
+const antennaTwitch: IdleBehavior = {
+ name: 'antennaTwitch',
+ duration: 0.8,
+ weight: 15,
+ update: (parts, progress) => {
+ // Quick antenna twitch like picking up a signal
+ const t = progress
+ let twitch = 0
+
+ if (t < 0.2) {
+ twitch = easeOut(t / 0.2) * 0.4
+ } else if (t < 0.4) {
+ twitch = 0.4 - easeIn((t - 0.2) / 0.2) * 0.5
+ } else if (t < 0.6) {
+ twitch = -0.1 + easeOut((t - 0.4) / 0.2) * 0.25
+ } else {
+ twitch = 0.15 * (1 - easeOut((t - 0.6) / 0.4))
+ }
+
+ parts.antenna.rotation.z = twitch
+ parts.antenna.rotation.x = Math.abs(twitch) * 0.3
+ },
+ reset: (parts) => {
+ parts.antenna.rotation.z = 0
+ parts.antenna.rotation.x = 0
+ }
+}
+
+const headShake: IdleBehavior = {
+ name: 'headShake',
+ duration: 1,
+ weight: 5,
+ update: (parts, progress) => {
+ // Playful head shake (like "no no no" but cute)
+ const shakes = 3
+ const t = progress * shakes
+ const shake = Math.sin(t * Math.PI * 2) * (1 - progress) * 0.1
+
+ parts.head.rotation.y = shake
+
+ // Eyes follow slightly
+ parts.leftEye.position.x = -0.07 - shake * 0.5
+ parts.rightEye.position.x = 0.07 - shake * 0.5
+ },
+ reset: (parts) => {
+ parts.head.rotation.y = 0
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ }
+}
+
+const peek: IdleBehavior = {
+ name: 'peek',
+ duration: 2.5,
+ weight: 4,
+ update: (parts, progress) => {
+ // Peek to the side like looking around a corner
+ let lean = 0
+ let eyeShift = 0
+
+ if (progress < 0.3) {
+ // Lean to peek
+ lean = easeOut(progress / 0.3)
+ eyeShift = lean
+ } else if (progress < 0.7) {
+ // Hold and look around
+ lean = 1
+ const lookPhase = (progress - 0.3) / 0.4
+ eyeShift = 1 + Math.sin(lookPhase * Math.PI * 2) * 0.3
+ } else {
+ // Return
+ lean = 1 - easeIn((progress - 0.7) / 0.3)
+ eyeShift = lean
+ }
+
+ parts.mesh.rotation.z = lean * 0.15
+ parts.head.rotation.z = -lean * 0.1 // Counter-tilt head
+ parts.leftEye.position.x = -0.07 + eyeShift * 0.02
+ parts.rightEye.position.x = 0.07 + eyeShift * 0.02
+ },
+ reset: (parts) => {
+ parts.mesh.rotation.z = 0
+ parts.head.rotation.z = 0
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ }
+}
+
+const sleepyNod: IdleBehavior = {
+ name: 'sleepyNod',
+ duration: 3,
+ weight: 3,
+ update: (parts, progress) => {
+ // Getting sleepy... head nods forward then snaps back
+ let nod = 0
+ let eyeOpen = 1
+
+ if (progress < 0.5) {
+ // Slowly nodding off
+ const t = easeIn(progress * 2)
+ nod = t * 0.2
+ eyeOpen = 1 - t * 0.7
+ } else if (progress < 0.55) {
+ // Snap awake!
+ const t = (progress - 0.5) / 0.05
+ nod = 0.2 - t * 0.25
+ eyeOpen = 0.3 + t * 0.9
+ } else {
+ // Shake it off
+ const t = (progress - 0.55) / 0.45
+ nod = -0.05 * (1 - easeOut(t))
+ eyeOpen = 1.2 - t * 0.2
+ // Little head shake
+ parts.head.rotation.y = Math.sin(t * Math.PI * 4) * 0.05 * (1 - t)
+ }
+
+ parts.head.rotation.x = nod
+ parts.leftEye.scale.y = Math.max(0.1, eyeOpen)
+ parts.rightEye.scale.y = Math.max(0.1, eyeOpen)
+ parts.antenna.rotation.x = nod * 0.5
+ },
+ reset: (parts) => {
+ parts.head.rotation.x = 0
+ parts.head.rotation.y = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.antenna.rotation.x = 0
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Dance Styles - Various dance moves for extra entertainment!
+// ----------------------------------------------------------------------------
+
+const discoFever: IdleBehavior = {
+ name: 'discoFever',
+ duration: 4,
+ weight: 3,
+ update: (parts, progress) => {
+ // Classic disco: point up alternating arms, hip sway
+ const beatTime = progress * 8
+ const beat = Math.floor(beatTime) % 4
+ const beatProgress = beatTime % 1
+
+ // Hip sway side to side
+ const sway = Math.sin(beatTime * Math.PI * 0.5) * 0.1
+ parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + sway
+ parts.body.rotation.z = -sway * 0.8
+
+ // Bounce on each beat
+ const bounce = Math.abs(Math.sin(beatProgress * Math.PI)) * 0.05
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce
+
+ // Alternating arm points to the sky!
+ if (beat < 2) {
+ // Right arm up pointing
+ parts.rightArm.rotation.x = -2.5
+ parts.rightArm.rotation.z = 0.3 + Math.sin(beatProgress * Math.PI) * 0.2
+ parts.leftArm.rotation.x = 0.3
+ parts.leftArm.rotation.z = -0.2
+ } else {
+ // Left arm up pointing
+ parts.leftArm.rotation.x = -2.5
+ parts.leftArm.rotation.z = -0.3 - Math.sin(beatProgress * Math.PI) * 0.2
+ parts.rightArm.rotation.x = 0.3
+ parts.rightArm.rotation.z = 0.2
+ }
+
+ // Head follows the pointing arm
+ parts.head.rotation.z = beat < 2 ? 0.1 : -0.1
+ parts.head.rotation.y = beat < 2 ? 0.15 : -0.15
+ },
+ reset: (parts) => {
+ parts.mesh.position.x = parts.mesh.userData.originalX ?? 0
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ parts.body.rotation.z = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ }
+}
+
+const robotDance: IdleBehavior = {
+ name: 'robotDance',
+ duration: 3.5,
+ weight: 3,
+ update: (parts, progress) => {
+ // Mechanical robot dance - stiff, isolated movements
+ const phase = Math.floor(progress * 7) % 7
+ const phaseProgress = (progress * 7) % 1
+ const snap = phaseProgress < 0.2 ? easeOut(phaseProgress * 5) : 1
+
+ // Reset all rotations first
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+
+ switch (phase) {
+ case 0: // Arms out horizontal
+ parts.leftArm.rotation.z = -1.5 * snap
+ parts.rightArm.rotation.z = 1.5 * snap
+ break
+ case 1: // Arms bent at elbow (not possible with current rig, so arms forward)
+ parts.leftArm.rotation.x = -1.5 * snap
+ parts.rightArm.rotation.x = -1.5 * snap
+ break
+ case 2: // Head turn left
+ parts.head.rotation.y = -0.4 * snap
+ parts.leftArm.rotation.x = -1.5
+ parts.rightArm.rotation.x = -1.5
+ break
+ case 3: // Head turn right
+ parts.head.rotation.y = 0.4 * snap
+ parts.leftArm.rotation.x = -1.5
+ parts.rightArm.rotation.x = -1.5
+ break
+ case 4: // Body tilt left
+ parts.mesh.rotation.z = 0.15 * snap
+ parts.head.rotation.z = -0.1 * snap
+ break
+ case 5: // Body tilt right
+ parts.mesh.rotation.z = -0.15 * snap
+ parts.head.rotation.z = 0.1 * snap
+ break
+ case 6: // Return to center with bounce
+ const returnSnap = phaseProgress < 0.3 ? easeOut(phaseProgress * 3.3) : 1
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + (1 - returnSnap) * 0.05
+ break
+ }
+ },
+ reset: (parts) => {
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ parts.mesh.rotation.z = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ }
+}
+
+const headBanger: IdleBehavior = {
+ name: 'headBanger',
+ duration: 2.5,
+ weight: 2,
+ update: (parts, progress) => {
+ // Head banging! Heavy metal style
+ const bangSpeed = 6
+ const t = progress * bangSpeed
+ const bangPhase = t % 1
+
+ // Intense head bang forward
+ const bangAngle = Math.sin(bangPhase * Math.PI) * 0.4
+ parts.head.rotation.x = bangAngle
+
+ // Arms pump with the beat
+ const armPump = Math.sin(bangPhase * Math.PI) * 0.5
+ parts.leftArm.rotation.x = -0.5 - armPump
+ parts.rightArm.rotation.x = -0.5 - armPump
+ parts.leftArm.rotation.z = -0.3
+ parts.rightArm.rotation.z = 0.3
+
+ // Slight body movement
+ parts.body.rotation.x = bangAngle * 0.3
+
+ // Antenna goes wild
+ parts.antenna.rotation.x = -bangAngle * 0.8
+ parts.antenna.rotation.z = Math.sin(t * Math.PI * 2) * 0.2
+ },
+ reset: (parts) => {
+ parts.head.rotation.x = 0
+ parts.body.rotation.x = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+const shuffleDance: IdleBehavior = {
+ name: 'shuffleDance',
+ duration: 3,
+ weight: 3,
+ update: (parts, progress) => {
+ // Shuffle side to side with arm pumps
+ const beatTime = progress * 6
+ const beat = Math.floor(beatTime) % 2
+ const beatProgress = beatTime % 1
+
+ // Shuffle position - quick snap to side, then slide back
+ const shuffleEase = beat === 0
+ ? (beatProgress < 0.2 ? easeOut(beatProgress * 5) : 1 - easeIn((beatProgress - 0.2) / 0.8) * 0.5)
+ : (beatProgress < 0.2 ? easeOut(beatProgress * 5) : 1 - easeIn((beatProgress - 0.2) / 0.8) * 0.5)
+
+ const shuffleX = beat === 0 ? shuffleEase * 0.15 : -shuffleEase * 0.15
+ parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + shuffleX
+
+ // Body leans into the shuffle
+ parts.mesh.rotation.z = -shuffleX * 2
+
+ // Bounce on beat
+ const bounce = Math.sin(beatProgress * Math.PI) * 0.06
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce
+
+ // Arms pump up and down
+ const armPump = Math.sin(beatProgress * Math.PI) * 0.8
+ parts.leftArm.rotation.x = -0.3 - armPump
+ parts.rightArm.rotation.x = -0.3 - armPump
+
+ // Head bops
+ parts.head.rotation.z = shuffleX * 1.5
+ },
+ reset: (parts) => {
+ parts.mesh.position.x = parts.mesh.userData.originalX ?? 0
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ parts.mesh.rotation.z = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.z = 0
+ }
+}
+
+const twistDance: IdleBehavior = {
+ name: 'twistDance',
+ duration: 3,
+ weight: 3,
+ update: (parts, progress) => {
+ // Classic twist dance - rotate hips/body opposite to shoulders
+ const twistTime = progress * 4
+ const twist = Math.sin(twistTime * Math.PI * 2) * 0.2
+
+ // Body twists one way
+ parts.body.rotation.y = twist
+
+ // Head/shoulders twist the other way
+ parts.head.rotation.y = -twist * 0.8
+
+ // Arms out and swinging
+ parts.leftArm.rotation.z = -0.5
+ parts.rightArm.rotation.z = 0.5
+ parts.leftArm.rotation.y = twist * 2
+ parts.rightArm.rotation.y = twist * 2
+
+ // Bounce while twisting
+ const bounce = Math.abs(Math.sin(twistTime * Math.PI * 2)) * 0.04
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce
+
+ // Slight side to side
+ parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + twist * 0.3
+ },
+ reset: (parts) => {
+ parts.body.rotation.y = 0
+ parts.head.rotation.y = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.mesh.position.x = parts.mesh.userData.originalX ?? 0
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ }
+}
+
+const victoryDance: IdleBehavior = {
+ name: 'victoryDance',
+ duration: 2.5,
+ weight: 2,
+ update: (parts, progress) => {
+ // Celebratory fist pumps and jumping!
+ const beatTime = progress * 5
+ const beat = Math.floor(beatTime) % 2
+ const beatProgress = beatTime % 1
+
+ // Jump up!
+ const jumpHeight = Math.sin(beatProgress * Math.PI) * 0.15
+ parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + jumpHeight
+
+ // Alternating fist pumps
+ if (beat === 0) {
+ parts.rightArm.rotation.x = -2.8
+ parts.rightArm.rotation.z = 0.2 + Math.sin(beatProgress * Math.PI) * 0.3
+ parts.leftArm.rotation.x = -0.5
+ parts.leftArm.rotation.z = -0.2
+ } else {
+ parts.leftArm.rotation.x = -2.8
+ parts.leftArm.rotation.z = -0.2 - Math.sin(beatProgress * Math.PI) * 0.3
+ parts.rightArm.rotation.x = -0.5
+ parts.rightArm.rotation.z = 0.2
+ }
+
+ // Happy head movements
+ parts.head.rotation.z = Math.sin(beatTime * Math.PI * 2) * 0.1
+ parts.head.rotation.y = Math.sin(beatTime * Math.PI) * 0.1
+
+ // Eyes excited (slightly bigger)
+ const excitement = 1 + Math.sin(beatProgress * Math.PI) * 0.1
+ parts.leftEye.scale.setScalar(excitement)
+ parts.rightEye.scale.setScalar(excitement)
+ },
+ reset: (parts) => {
+ parts.mesh.position.y = parts.mesh.userData.originalY ?? 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ }
+}
+
+const danceMoves: IdleBehavior = {
+ name: 'grooveDance',
+ duration: 3,
+ weight: 3,
+ update: (parts, progress) => {
+ // Little dance! Side to side with arm moves
+ const beatTime = progress * 8
+ const beat = Math.floor(beatTime) % 4
+ const beatProgress = beatTime % 1
+
+ // Body sway
+ const sway = Math.sin(beatTime * Math.PI) * 0.08
+ parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + sway
+ parts.mesh.rotation.z = -sway * 0.3
+
+ // Bounce on beat
+ const bouncePhase = Math.abs(Math.sin(beatProgress * Math.PI))
+ parts.head.position.y = 0.52 + bouncePhase * 0.04
+
+ // Arms move based on beat
+ if (beat === 0 || beat === 2) {
+ parts.leftArm.rotation.z = -0.3 - bouncePhase * 0.2
+ parts.rightArm.rotation.z = 0.3 + bouncePhase * 0.2
+ } else {
+ parts.leftArm.rotation.x = -bouncePhase * 0.5
+ parts.rightArm.rotation.x = -bouncePhase * 0.5
+ }
+
+ // Head bop
+ parts.head.rotation.z = Math.sin(beatTime * Math.PI * 2) * 0.05
+ },
+ reset: (parts) => {
+ parts.mesh.position.x = parts.mesh.userData.originalX ?? 0
+ parts.mesh.rotation.z = 0
+ parts.head.position.y = 0.52
+ parts.head.rotation.z = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ }
+}
+
+// ============================================================================
+// Behavior Registry - Add/remove behaviors here!
+// ============================================================================
+
+export const IDLE_BEHAVIORS: IdleBehavior[] = [
+ // Basic idle animations
+ lookAround,
+ curiousTilt,
+ happyBounce,
+ stretch,
+ wave,
+ doubleBlink,
+ antennaTwitch,
+ headShake,
+ peek,
+ sleepyNod,
+ // Dance styles!
+ danceMoves, // grooveDance - basic side-to-side
+ discoFever, // 70s disco pointing
+ robotDance, // mechanical stiff moves
+ headBanger, // metal head bang
+ shuffleDance, // side shuffle with arm pumps
+ twistDance, // classic 60s twist
+ victoryDance, // celebratory fist pumps
+]
+
+// ============================================================================
+// Behavior Manager
+// ============================================================================
+
+export class IdleBehaviorManager {
+ private behaviors: IdleBehavior[]
+ private currentBehavior: IdleBehavior | null = null
+ private behaviorProgress = 0
+ private cooldown = 0 // Time until next behavior can start
+
+ // === TUNING ===
+ private readonly MIN_COOLDOWN = 2 // Minimum seconds between behaviors
+ private readonly MAX_COOLDOWN = 6 // Maximum seconds between behaviors
+ private readonly BASE_IDLE_WEIGHT = 20 // Weight for "do nothing" (just base idle)
+
+ constructor(behaviors: IdleBehavior[] = IDLE_BEHAVIORS) {
+ this.behaviors = behaviors
+ this.cooldown = this.randomCooldown()
+ }
+
+ private randomCooldown(): number {
+ return this.MIN_COOLDOWN + Math.random() * (this.MAX_COOLDOWN - this.MIN_COOLDOWN)
+ }
+
+ private pickBehavior(): IdleBehavior | null {
+ // Calculate total weight including "do nothing"
+ const totalWeight = this.behaviors.reduce((sum, b) => sum + b.weight, 0) + this.BASE_IDLE_WEIGHT
+
+ let roll = Math.random() * totalWeight
+
+ // Check if we rolled "do nothing"
+ if (roll < this.BASE_IDLE_WEIGHT) {
+ return null
+ }
+ roll -= this.BASE_IDLE_WEIGHT
+
+ // Find which behavior we rolled
+ for (const behavior of this.behaviors) {
+ roll -= behavior.weight
+ if (roll <= 0) {
+ return behavior
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Update the behavior manager
+ * @returns true if a behavior is currently playing
+ */
+ update(parts: CharacterParts, deltaTime: number): boolean {
+ // If a behavior is playing, continue it
+ if (this.currentBehavior) {
+ this.behaviorProgress += deltaTime / this.currentBehavior.duration
+
+ if (this.behaviorProgress >= 1) {
+ // Behavior finished
+ this.currentBehavior.reset?.(parts)
+ this.currentBehavior = null
+ this.behaviorProgress = 0
+ this.cooldown = this.randomCooldown()
+ return false
+ }
+
+ // Run the behavior
+ this.currentBehavior.update(parts, this.behaviorProgress, deltaTime)
+ return true
+ }
+
+ // No behavior playing - count down cooldown
+ this.cooldown -= deltaTime
+ if (this.cooldown <= 0) {
+ // Try to start a new behavior
+ this.currentBehavior = this.pickBehavior()
+ if (this.currentBehavior) {
+ this.behaviorProgress = 0
+ // Store original position for behaviors that move the mesh
+ parts.mesh.userData.originalX = parts.mesh.position.x
+ parts.mesh.userData.originalY = parts.mesh.position.y
+ return true
+ } else {
+ // Rolled "do nothing", set new cooldown
+ this.cooldown = this.randomCooldown()
+ }
+ }
+
+ return false
+ }
+
+ /** Force stop current behavior */
+ stop(parts: CharacterParts): void {
+ if (this.currentBehavior) {
+ this.currentBehavior.reset?.(parts)
+ this.currentBehavior = null
+ this.behaviorProgress = 0
+ }
+ }
+
+ /** Check if a behavior is currently playing */
+ isPlaying(): boolean {
+ return this.currentBehavior !== null
+ }
+
+ /** Get current behavior name (for debugging) */
+ getCurrentBehaviorName(): string | null {
+ return this.currentBehavior?.name ?? null
+ }
+
+ /** Get list of all behavior names (for dev UI) */
+ getBehaviorNames(): string[] {
+ return this.behaviors.map(b => b.name)
+ }
+
+ /** Force play a specific behavior by name (for dev/testing) */
+ forcePlay(name: string, parts: CharacterParts): boolean {
+ const behavior = this.behaviors.find(b => b.name === name)
+ if (!behavior) return false
+
+ // Stop current behavior if any
+ if (this.currentBehavior) {
+ this.currentBehavior.reset?.(parts)
+ }
+
+ // Start the requested behavior
+ this.currentBehavior = behavior
+ this.behaviorProgress = 0
+ parts.mesh.userData.originalX = parts.mesh.position.x
+ parts.mesh.userData.originalY = parts.mesh.position.y
+ return true
+ }
+
+ /** Force play a random behavior (guaranteed to play, ignores "do nothing" weight) */
+ forcePlayRandom(parts: CharacterParts): string | null {
+ if (this.behaviors.length === 0) return null
+
+ // Pick a random behavior (weighted, but excluding "do nothing")
+ const totalWeight = this.behaviors.reduce((sum, b) => sum + b.weight, 0)
+ let roll = Math.random() * totalWeight
+
+ let chosen: IdleBehavior | null = null
+ for (const behavior of this.behaviors) {
+ roll -= behavior.weight
+ if (roll <= 0) {
+ chosen = behavior
+ break
+ }
+ }
+
+ // Fallback to first behavior if somehow nothing was chosen
+ if (!chosen) chosen = this.behaviors[0]
+
+ // Stop current behavior if any
+ if (this.currentBehavior) {
+ this.currentBehavior.reset?.(parts)
+ }
+
+ // Start the chosen behavior
+ this.currentBehavior = chosen
+ this.behaviorProgress = 0
+ parts.mesh.userData.originalX = parts.mesh.position.x
+ parts.mesh.userData.originalY = parts.mesh.position.y
+
+ return chosen.name
+ }
+}
diff --git a/src/workshop/entities/animations/WorkingBehaviors.ts b/src/workshop/entities/animations/WorkingBehaviors.ts
new file mode 100644
index 0000000..af4f194
--- /dev/null
+++ b/src/workshop/entities/animations/WorkingBehaviors.ts
@@ -0,0 +1,1127 @@
+/**
+ * WorkingBehaviors - Station-specific animations for ClaudeMon
+ *
+ * These animations play when Claude is "working" at a specific station.
+ * Each station has its own contextual animation.
+ *
+ * To add a new station animation:
+ * 1. Create a WorkingBehavior object
+ * 2. Add it to STATION_ANIMATIONS with the station name as key
+ */
+
+import {
+ type CharacterParts,
+ type WorkingBehavior,
+ easeInOut,
+ easeOut,
+ pingPong,
+ elastic,
+ bounce,
+} from './AnimationTypes'
+
+// Re-export for convenience
+export type { WorkingBehavior } from './AnimationTypes'
+
+export type StationAnimations = {
+ [station: string]: WorkingBehavior[]
+}
+
+// ============================================================================
+// Station Working Animations
+// ============================================================================
+
+/** Bookshelf (Read) - Reading a book, flipping pages */
+const readingBook: WorkingBehavior = {
+ name: 'readingBook',
+ loop: true,
+ duration: 4,
+ update: (parts, progress) => {
+ // Hold arms like reading a book
+ parts.leftArm.rotation.x = -1.2
+ parts.leftArm.rotation.z = -0.3
+ parts.rightArm.rotation.x = -1.2
+ parts.rightArm.rotation.z = 0.3
+
+ // Eyes scan left to right (reading)
+ const readCycle = progress * 3 // 3 lines per cycle
+ const lineProgress = readCycle % 1
+ const eyeX = (lineProgress < 0.8)
+ ? -0.02 + easeInOut(lineProgress / 0.8) * 0.04 // Read left to right
+ : 0.02 - easeOut((lineProgress - 0.8) / 0.2) * 0.04 // Quick return
+
+ parts.leftEye.position.x = -0.07 + eyeX
+ parts.rightEye.position.x = 0.07 + eyeX
+
+ // Slight head tilt while reading
+ parts.head.rotation.x = 0.15 // Looking down at book
+ parts.head.rotation.z = Math.sin(progress * Math.PI * 2) * 0.03
+
+ // Occasional page flip (at progress 0.5)
+ if (progress > 0.48 && progress < 0.55) {
+ const flipProgress = (progress - 0.48) / 0.07
+ parts.rightArm.rotation.z = 0.3 + Math.sin(flipProgress * Math.PI) * 0.4
+ }
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ parts.head.rotation.set(0, 0, 0)
+ }
+}
+
+/** Workbench (Edit) - Using tools, tinkering */
+const tinkering: WorkingBehavior = {
+ name: 'tinkering',
+ loop: true,
+ duration: 2.5,
+ update: (parts, progress) => {
+ // One arm holds work, other arm uses tool
+ parts.leftArm.rotation.x = -0.8
+ parts.leftArm.rotation.z = -0.2
+
+ // Right arm hammering/working motion
+ const workCycle = progress * 4
+ const hammerPhase = workCycle % 1
+ const hammerMotion = Math.sin(hammerPhase * Math.PI) * 0.6
+ parts.rightArm.rotation.x = -1.0 - hammerMotion
+ parts.rightArm.rotation.z = 0.1
+
+ // Head follows the work
+ parts.head.rotation.x = 0.1
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1
+
+ // Body slight lean into work
+ parts.body.rotation.x = 0.05
+
+ // Eyes focused
+ const focus = Math.sin(workCycle * Math.PI) * 0.01
+ parts.leftEye.position.y = 0.03 + focus
+ parts.rightEye.position.y = 0.03 + focus
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.body.rotation.x = 0
+ parts.leftEye.position.y = 0.03
+ parts.rightEye.position.y = 0.03
+ }
+}
+
+/** Desk (Write) - Writing, thinking, scratching head */
+const writing: WorkingBehavior = {
+ name: 'writing',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ // Writing arm motion
+ const writeCycle = progress * 6
+ const writePhase = writeCycle % 1
+
+ // Right arm writing small movements
+ parts.rightArm.rotation.x = -1.0
+ parts.rightArm.rotation.z = 0.2 + Math.sin(writePhase * Math.PI * 2) * 0.15
+ parts.rightArm.rotation.y = Math.sin(writePhase * Math.PI * 4) * 0.1
+
+ // Left arm resting on desk
+ parts.leftArm.rotation.x = -0.6
+ parts.leftArm.rotation.z = -0.4
+
+ // Head looking down at paper
+ parts.head.rotation.x = 0.2
+
+ // Occasional pause to think (every cycle)
+ const thinkPause = Math.floor(writeCycle) % 3 === 2
+ if (thinkPause && writePhase < 0.5) {
+ parts.head.rotation.x = 0.05 // Look up thinking
+ parts.head.rotation.z = 0.1
+ parts.rightArm.rotation.x = -0.8 // Pause writing
+ }
+
+ // Eyes follow writing
+ parts.leftEye.position.x = -0.07 + Math.sin(writePhase * Math.PI * 2) * 0.01
+ parts.rightEye.position.x = 0.07 + Math.sin(writePhase * Math.PI * 2) * 0.01
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ }
+}
+
+/** Terminal (Bash) - Typing rapidly, looking at screen */
+const typing: WorkingBehavior = {
+ name: 'typing',
+ loop: true,
+ duration: 2,
+ update: (parts, progress) => {
+ // Both arms in typing position
+ const typeCycle = progress * 12 // Fast typing
+ const typePhase = typeCycle % 1
+
+ // Alternating arm typing motions
+ const leftType = Math.sin(typePhase * Math.PI * 2) * 0.1
+ const rightType = Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.1
+
+ parts.leftArm.rotation.x = -0.7 + leftType
+ parts.leftArm.rotation.z = -0.3
+ parts.rightArm.rotation.x = -0.7 + rightType
+ parts.rightArm.rotation.z = 0.3
+
+ // Eyes scanning screen
+ const scanX = Math.sin(progress * Math.PI * 4) * 0.02
+ parts.leftEye.position.x = -0.07 + scanX
+ parts.rightEye.position.x = 0.07 + scanX
+
+ // Occasional head nod (understanding output)
+ const nodCycle = Math.floor(progress * 4) % 4
+ if (nodCycle === 3) {
+ parts.head.rotation.x = Math.sin((progress * 4 % 1) * Math.PI) * 0.1
+ }
+
+ // Slight forward lean (focused)
+ parts.body.rotation.x = 0.05
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ parts.head.rotation.x = 0
+ parts.body.rotation.x = 0
+ }
+}
+
+/** Scanner (Grep/Glob) - Scanning, searching, peering */
+const scanning: WorkingBehavior = {
+ name: 'scanning',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ // Hand shading eyes, searching pose
+ parts.rightArm.rotation.x = -2.0
+ parts.rightArm.rotation.z = 0.5
+ parts.rightArm.rotation.y = -0.3
+
+ // Other arm at side or pointing
+ const pointPhase = progress * 2
+ if (Math.floor(pointPhase) % 2 === 1) {
+ // Pointing at something found
+ parts.leftArm.rotation.x = -1.5
+ parts.leftArm.rotation.z = -0.3
+ } else {
+ parts.leftArm.rotation.x = 0
+ parts.leftArm.rotation.z = 0
+ }
+
+ // Head scanning left to right
+ const scanAngle = Math.sin(progress * Math.PI * 2) * 0.3
+ parts.head.rotation.y = scanAngle
+
+ // Eyes wide, searching
+ parts.leftEye.scale.setScalar(1.1)
+ parts.rightEye.scale.setScalar(1.1)
+
+ // Eyes follow head direction
+ parts.leftEye.position.x = -0.07 + scanAngle * 0.05
+ parts.rightEye.position.x = 0.07 + scanAngle * 0.05
+
+ // Slight body turn with head
+ parts.body.rotation.y = scanAngle * 0.3
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.y = 0
+ parts.body.rotation.y = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ }
+}
+
+/** Antenna (WebFetch/WebSearch) - Receiving signals, tuning */
+const receiving: WorkingBehavior = {
+ name: 'receiving',
+ loop: true,
+ duration: 2.5,
+ update: (parts, progress) => {
+ // Antenna actively receiving - wobbles and perks
+ const signalStrength = Math.sin(progress * Math.PI * 8) * 0.3
+ parts.antenna.rotation.z = signalStrength
+ parts.antenna.rotation.x = -0.1 + Math.abs(signalStrength) * 0.2
+
+ // Hand to "ear" (antenna) like listening
+ parts.rightArm.rotation.x = -2.2
+ parts.rightArm.rotation.z = 0.8
+ parts.rightArm.rotation.y = 0.3
+
+ // Other hand adjusting/tuning gesture
+ const tunePhase = progress * 4
+ parts.leftArm.rotation.x = -1.0
+ parts.leftArm.rotation.z = -0.2 + Math.sin(tunePhase * Math.PI) * 0.2
+
+ // Head tilted, listening
+ parts.head.rotation.z = 0.15
+ parts.head.rotation.y = 0.1
+
+ // Eyes looking up at antenna/signal direction
+ parts.leftEye.position.y = 0.03 + 0.01
+ parts.rightEye.position.y = 0.03 + 0.01
+ },
+ reset: (parts) => {
+ parts.antenna.rotation.set(0, 0, 0)
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.position.y = 0.03
+ parts.rightEye.position.y = 0.03
+ }
+}
+
+/** Portal (Task) - Mystical gestures, channeling energy */
+const channeling: WorkingBehavior = {
+ name: 'channeling',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ // Arms raised, channeling pose
+ const channelPulse = Math.sin(progress * Math.PI * 4)
+
+ parts.leftArm.rotation.x = -1.8 + channelPulse * 0.2
+ parts.leftArm.rotation.z = -0.6
+ parts.rightArm.rotation.x = -1.8 - channelPulse * 0.2
+ parts.rightArm.rotation.z = 0.6
+
+ // Hands circle slightly (channeling motion)
+ const circlePhase = progress * Math.PI * 2
+ parts.leftArm.rotation.y = Math.sin(circlePhase) * 0.3
+ parts.rightArm.rotation.y = -Math.sin(circlePhase) * 0.3
+
+ // Body slight sway
+ parts.mesh.rotation.z = Math.sin(progress * Math.PI * 2) * 0.05
+
+ // Head looking at portal (forward/up)
+ parts.head.rotation.x = -0.1
+
+ // Eyes glowing effect (scale pulse)
+ const glowPulse = 1 + Math.sin(progress * Math.PI * 6) * 0.15
+ parts.leftEye.scale.setScalar(glowPulse)
+ parts.rightEye.scale.setScalar(glowPulse)
+
+ // Antenna resonating
+ parts.antenna.rotation.x = Math.sin(progress * Math.PI * 8) * 0.15
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 6) * 0.1
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.mesh.rotation.z = 0
+ parts.head.rotation.x = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Taskboard (TodoWrite) - Checking items, pointing at board */
+const checkingTasks: WorkingBehavior = {
+ name: 'checkingTasks',
+ loop: true,
+ duration: 3.5,
+ update: (parts, progress) => {
+ const taskCycle = progress * 3 // Check 3 items
+ const taskPhase = taskCycle % 1
+ const taskIndex = Math.floor(taskCycle) % 3
+
+ // Point at different board positions (high, mid, low)
+ const boardY = [0.3, 0, -0.3][taskIndex]
+
+ // Right arm pointing at board
+ parts.rightArm.rotation.x = -1.5 + boardY * 0.5
+ parts.rightArm.rotation.z = 0.3
+
+ // Check motion (arm moves in checkmark)
+ if (taskPhase > 0.6 && taskPhase < 0.9) {
+ const checkProgress = (taskPhase - 0.6) / 0.3
+ parts.rightArm.rotation.z = 0.3 + Math.sin(checkProgress * Math.PI) * 0.3
+ parts.rightArm.rotation.x += Math.sin(checkProgress * Math.PI) * 0.2
+ }
+
+ // Left arm holding clipboard/list
+ parts.leftArm.rotation.x = -1.0
+ parts.leftArm.rotation.z = -0.4
+
+ // Head follows pointing
+ parts.head.rotation.x = -boardY * 0.15
+ parts.head.rotation.y = 0.2
+
+ // Nod when checking off
+ if (taskPhase > 0.8) {
+ parts.head.rotation.x += Math.sin((taskPhase - 0.8) * 5 * Math.PI) * 0.1
+ }
+
+ // Eyes scanning board
+ parts.leftEye.position.y = 0.03 - boardY * 0.01
+ parts.rightEye.position.y = 0.03 - boardY * 0.01
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.position.y = 0.03
+ parts.rightEye.position.y = 0.03
+ }
+}
+
+/** Generic working animation for unmapped stations */
+const genericWorking: WorkingBehavior = {
+ name: 'genericWorking',
+ loop: true,
+ duration: 2,
+ update: (parts, progress) => {
+ // Simple focused working pose
+ parts.leftArm.rotation.x = -0.5
+ parts.rightArm.rotation.x = -0.5
+
+ // Slight body movement showing activity
+ const activity = Math.sin(progress * Math.PI * 4) * 0.03
+ parts.body.rotation.x = 0.05 + activity
+
+ // Head slight movements (thinking)
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1
+ parts.head.rotation.z = Math.sin(progress * Math.PI * 3) * 0.05
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.body.rotation.x = 0
+ parts.head.rotation.set(0, 0, 0)
+ }
+}
+
+// ============================================================================
+// Alternative Station Animations
+// ============================================================================
+
+/** Bookshelf (Read) - Speed-reading through pages frantically */
+const speedReading: WorkingBehavior = {
+ name: 'speedReading',
+ loop: true,
+ duration: 2.5,
+ update: (parts, progress) => {
+ // Hold book close, hunched over
+ parts.leftArm.rotation.x = -1.4
+ parts.leftArm.rotation.z = -0.2
+ parts.rightArm.rotation.x = -1.4
+ parts.rightArm.rotation.z = 0.2
+
+ // Rapid page flipping with right hand
+ const flipCycle = progress * 8
+ const flipPhase = flipCycle % 1
+ parts.rightArm.rotation.z = 0.2 + Math.sin(flipPhase * Math.PI) * 0.5
+
+ // Eyes darting across pages super fast
+ const dartCycle = progress * 16
+ const eyeX = Math.sin(dartCycle * Math.PI) * 0.03
+ parts.leftEye.position.x = -0.07 + eyeX
+ parts.rightEye.position.x = 0.07 + eyeX
+
+ // Eyes get wider as excitement builds
+ const excitement = 1 + progress * 0.2
+ parts.leftEye.scale.setScalar(excitement)
+ parts.rightEye.scale.setScalar(excitement)
+
+ // Head jittering slightly from speed
+ parts.head.rotation.x = 0.15 + Math.sin(progress * Math.PI * 12) * 0.02
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 6) * 0.04
+
+ // Antenna vibrating with brain activity
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 16) * 0.12
+ parts.antenna.rotation.x = -0.1
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.head.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Workbench (Edit) - Precision surgery: careful, delicate adjustments */
+const precisionWork: WorkingBehavior = {
+ name: 'precisionWork',
+ loop: true,
+ duration: 4,
+ update: (parts, progress) => {
+ // Lean in close, body forward
+ parts.body.rotation.x = 0.12
+
+ // Left hand holds steady
+ parts.leftArm.rotation.x = -0.9
+ parts.leftArm.rotation.z = -0.3
+
+ // Right arm making tiny precise movements
+ const precisionCycle = progress * 6
+ const microPhase = precisionCycle % 1
+ parts.rightArm.rotation.x = -1.1 + Math.sin(microPhase * Math.PI * 2) * 0.05
+ parts.rightArm.rotation.y = Math.sin(microPhase * Math.PI * 4) * 0.04
+ parts.rightArm.rotation.z = 0.15
+
+ // Squinting eyes (focused)
+ parts.leftEye.scale.setScalar(0.7)
+ parts.rightEye.scale.setScalar(0.7)
+
+ // Head very still, slight tilt for better view
+ parts.head.rotation.x = 0.2
+ parts.head.rotation.z = 0.08
+
+ // Periodic "step back and examine" (every 75%)
+ if (progress > 0.7 && progress < 0.9) {
+ const examineP = (progress - 0.7) / 0.2
+ parts.body.rotation.x = 0.12 - easeOut(examineP) * 0.15
+ parts.head.rotation.x = 0.2 - easeOut(examineP) * 0.25
+ parts.leftEye.scale.setScalar(0.7 + easeOut(examineP) * 0.4)
+ parts.rightEye.scale.setScalar(0.7 + easeOut(examineP) * 0.4)
+ // Nod of approval
+ if (examineP > 0.6) {
+ parts.head.rotation.x += Math.sin((examineP - 0.6) * 2.5 * Math.PI) * 0.1
+ }
+ }
+
+ // Antenna barely moves - deep concentration
+ parts.antenna.rotation.x = Math.sin(progress * Math.PI * 2) * 0.03
+ },
+ reset: (parts) => {
+ parts.body.rotation.x = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.head.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Desk (Write) - Calligraphy: slow, artistic, admiring work */
+const calligraphy: WorkingBehavior = {
+ name: 'calligraphy',
+ loop: true,
+ duration: 5,
+ update: (parts, progress) => {
+ // Left arm holds paper steady at angle
+ parts.leftArm.rotation.x = -0.7
+ parts.leftArm.rotation.z = -0.5
+ parts.leftArm.rotation.y = 0.2
+
+ // Right arm - slow, sweeping brush strokes
+ const strokeCycle = progress * 3
+ const strokePhase = strokeCycle % 1
+ const strokeIndex = Math.floor(strokeCycle) % 3
+
+ // Different stroke directions per phase
+ const strokes = [
+ { x: -1.0, yStart: -0.3, yEnd: 0.3, zStart: 0.1, zEnd: 0.4 }, // horizontal
+ { x: -0.8, yStart: 0.2, yEnd: -0.2, zStart: 0.3, zEnd: 0.1 }, // diagonal
+ { x: -1.2, yStart: 0.0, yEnd: 0.0, zStart: 0.15, zEnd: 0.35 }, // vertical
+ ]
+ const stroke = strokes[strokeIndex]
+ const smooth = easeInOut(strokePhase)
+ parts.rightArm.rotation.x = stroke.x
+ parts.rightArm.rotation.y = stroke.yStart + (stroke.yEnd - stroke.yStart) * smooth
+ parts.rightArm.rotation.z = stroke.zStart + (stroke.zEnd - stroke.zStart) * smooth
+
+ // Head follows the brush
+ parts.head.rotation.x = 0.15
+ parts.head.rotation.y = parts.rightArm.rotation.y * 0.3
+
+ // Dip brush between strokes
+ if (strokePhase > 0.9) {
+ const dipP = (strokePhase - 0.9) / 0.1
+ parts.rightArm.rotation.x += Math.sin(dipP * Math.PI) * 0.3
+ }
+
+ // Eyes track the work with satisfaction
+ parts.leftEye.position.x = -0.07 + parts.rightArm.rotation.y * 0.02
+ parts.rightEye.position.x = 0.07 + parts.rightArm.rotation.y * 0.02
+
+ // Occasional admiring pause - lean back
+ if (progress > 0.85) {
+ const admireP = (progress - 0.85) / 0.15
+ parts.head.rotation.x = 0.15 - easeOut(admireP) * 0.2
+ parts.head.rotation.z = easeOut(admireP) * 0.1
+ }
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.position.x = -0.07
+ parts.rightEye.position.x = 0.07
+ }
+}
+
+/** Terminal (Bash) - Hacker mode: intense rapid typing, screen glow */
+const hackerMode: WorkingBehavior = {
+ name: 'hackerMode',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ // Hunched forward intensely
+ parts.body.rotation.x = 0.1
+
+ // Super fast alternating typing
+ const typeCycle = progress * 20
+ const typePhase = typeCycle % 1
+ const leftType = Math.sin(typePhase * Math.PI * 2) * 0.15
+ const rightType = Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.15
+
+ parts.leftArm.rotation.x = -0.8 + leftType
+ parts.leftArm.rotation.z = -0.25
+ parts.rightArm.rotation.x = -0.8 + rightType
+ parts.rightArm.rotation.z = 0.25
+
+ // Eyes locked on screen, slightly wide
+ parts.leftEye.scale.setScalar(1.15)
+ parts.rightEye.scale.setScalar(1.15)
+
+ // Eyes scan rapidly - reading output
+ const scanX = Math.sin(progress * Math.PI * 8) * 0.025
+ parts.leftEye.position.x = -0.07 + scanX
+ parts.rightEye.position.x = 0.07 + scanX
+ parts.leftEye.position.y = 0.03 + Math.sin(progress * Math.PI * 3) * 0.008
+ parts.rightEye.position.y = 0.03 + Math.sin(progress * Math.PI * 3) * 0.008
+
+ // Head micro-movements - processing
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 6) * 0.04
+ parts.head.rotation.x = 0.05
+
+ // Antenna flickers like picking up data streams
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 14) * 0.08
+ parts.antenna.rotation.x = Math.sin(progress * Math.PI * 10) * 0.06
+
+ // Dramatic pause to read output (at 60%)
+ if (progress > 0.55 && progress < 0.7) {
+ const pauseP = (progress - 0.55) / 0.15
+ // Hands lift off keyboard
+ parts.leftArm.rotation.x = -0.8 + easeOut(pauseP) * 0.3
+ parts.rightArm.rotation.x = -0.8 + easeOut(pauseP) * 0.3
+ // Eyes widen more
+ const widen = 1.15 + easeOut(pauseP) * 0.15
+ parts.leftEye.scale.setScalar(widen)
+ parts.rightEye.scale.setScalar(widen)
+ // Quick head nod (got it!)
+ if (pauseP > 0.7) {
+ parts.head.rotation.x = 0.05 + Math.sin((pauseP - 0.7) * 3.3 * Math.PI) * 0.1
+ }
+ }
+ },
+ reset: (parts) => {
+ parts.body.rotation.x = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.leftEye.position.set(-0.07, 0.03, 0.242)
+ parts.rightEye.position.set(0.07, 0.03, 0.242)
+ parts.head.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Terminal (Bash) - Frustrated: type, error, facepalm, retry */
+const frustratedTyping: WorkingBehavior = {
+ name: 'frustratedTyping',
+ loop: true,
+ duration: 4,
+ update: (parts, progress) => {
+ if (progress < 0.4) {
+ // Phase 1: Typing confidently
+ const p = progress / 0.4
+ const typeCycle = p * 12
+ const typePhase = typeCycle % 1
+
+ parts.leftArm.rotation.x = -0.7 + Math.sin(typePhase * Math.PI * 2) * 0.1
+ parts.leftArm.rotation.z = -0.3
+ parts.rightArm.rotation.x = -0.7 + Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.1
+ parts.rightArm.rotation.z = 0.3
+ parts.head.rotation.x = 0.05
+ parts.body.rotation.x = 0.05
+ } else if (progress < 0.55) {
+ // Phase 2: Error! Recoil
+ const p = (progress - 0.4) / 0.15
+ const recoil = easeOut(p)
+ parts.body.rotation.x = 0.05 - recoil * 0.15
+ parts.head.rotation.x = 0.05 - recoil * 0.2
+ // Eyes go wide
+ parts.leftEye.scale.setScalar(1 + recoil * 0.3)
+ parts.rightEye.scale.setScalar(1 + recoil * 0.3)
+ // Arms up in surprise
+ parts.leftArm.rotation.x = -0.7 - recoil * 0.5
+ parts.rightArm.rotation.x = -0.7 - recoil * 0.5
+ parts.leftArm.rotation.z = -0.3 - recoil * 0.3
+ parts.rightArm.rotation.z = 0.3 + recoil * 0.3
+ } else if (progress < 0.75) {
+ // Phase 3: Facepalm / head shake
+ const p = (progress - 0.55) / 0.2
+ // Right arm to face
+ parts.rightArm.rotation.x = -2.0
+ parts.rightArm.rotation.z = 0.3
+ parts.leftArm.rotation.x = -0.3
+ parts.leftArm.rotation.z = -0.1
+ // Slow head shake
+ parts.head.rotation.y = Math.sin(p * Math.PI * 3) * 0.2
+ parts.head.rotation.x = 0.1
+ // Eyes squint
+ parts.leftEye.scale.setScalar(0.6)
+ parts.rightEye.scale.setScalar(0.6)
+ parts.body.rotation.x = -0.05
+ } else {
+ // Phase 4: Deep breath, back to typing with determination
+ const p = (progress - 0.75) / 0.25
+ const recovery = easeInOut(p)
+ parts.rightArm.rotation.x = -2.0 + recovery * 1.3
+ parts.rightArm.rotation.z = 0.3
+ parts.leftArm.rotation.x = -0.3 - recovery * 0.4
+ parts.leftArm.rotation.z = -0.1 - recovery * 0.2
+ parts.head.rotation.y = 0
+ parts.head.rotation.x = 0.1 - recovery * 0.05
+ parts.body.rotation.x = -0.05 + recovery * 0.1
+ // Eyes normalize
+ parts.leftEye.scale.setScalar(0.6 + recovery * 0.4)
+ parts.rightEye.scale.setScalar(0.6 + recovery * 0.4)
+ // Antenna perks up - renewed determination
+ parts.antenna.rotation.x = -recovery * 0.15
+ }
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.body.rotation.x = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Scanner (Grep/Glob) - Detective: magnifying glass, crouching, examining */
+const detective: WorkingBehavior = {
+ name: 'detective',
+ loop: true,
+ duration: 4,
+ update: (parts, progress) => {
+ // Crouching posture
+ parts.body.rotation.x = 0.1
+
+ // Right arm holding "magnifying glass" up to eye
+ parts.rightArm.rotation.x = -1.8
+ parts.rightArm.rotation.z = 0.4
+ parts.rightArm.rotation.y = -0.2
+
+ // Left arm behind back (classic detective)
+ parts.leftArm.rotation.x = 0.3
+ parts.leftArm.rotation.z = -0.4
+ parts.leftArm.rotation.y = 0.5
+
+ // Move magnifying glass around examining things
+ const examCycle = progress * 2
+ const examPhase = examCycle % 1
+
+ if (Math.floor(examCycle) % 2 === 0) {
+ // Examining left side
+ const sweep = easeInOut(examPhase)
+ parts.head.rotation.y = 0.15 + sweep * 0.2
+ parts.rightArm.rotation.y = -0.2 + sweep * 0.3
+ parts.body.rotation.y = sweep * 0.1
+ } else {
+ // Examining right side
+ const sweep = easeInOut(examPhase)
+ parts.head.rotation.y = 0.35 - sweep * 0.5
+ parts.rightArm.rotation.y = 0.1 - sweep * 0.4
+ parts.body.rotation.y = 0.1 - sweep * 0.2
+ }
+
+ // One eye big (looking through glass), one normal
+ parts.rightEye.scale.setScalar(1.3)
+ parts.leftEye.scale.setScalar(0.85)
+
+ // Antenna twitching - picking up clues
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 6) * 0.1
+ parts.antenna.rotation.x = -0.1
+
+ // Periodic "aha!" moment
+ if (progress > 0.85 && progress < 0.95) {
+ const ahaP = (progress - 0.85) / 0.1
+ parts.antenna.rotation.x = -0.1 - easeOut(ahaP) * 0.2
+ parts.leftEye.scale.setScalar(0.85 + easeOut(ahaP) * 0.35)
+ parts.head.rotation.x = -easeOut(ahaP) * 0.15
+ }
+ },
+ reset: (parts) => {
+ parts.body.rotation.set(0, 0, 0)
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Antenna (WebFetch) - Broadcasting: arms up like an antenna, sending signals */
+const broadcasting: WorkingBehavior = {
+ name: 'broadcasting',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ // Both arms up like antenna receivers
+ const pulse = Math.sin(progress * Math.PI * 4)
+ parts.leftArm.rotation.x = -2.5
+ parts.leftArm.rotation.z = -0.5 + pulse * 0.15
+ parts.rightArm.rotation.x = -2.5
+ parts.rightArm.rotation.z = 0.5 - pulse * 0.15
+
+ // Body sways with signal
+ parts.body.rotation.z = Math.sin(progress * Math.PI * 2) * 0.06
+ parts.mesh.rotation.z = Math.sin(progress * Math.PI * 2) * 0.03
+
+ // Antenna goes wild - transmitting
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 12) * 0.2
+ parts.antenna.rotation.x = Math.sin(progress * Math.PI * 8) * 0.15
+
+ // Head tilts with signal direction
+ parts.head.rotation.z = Math.sin(progress * Math.PI * 3) * 0.1
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1
+
+ // Eyes pulse like they're emitting/receiving light
+ const eyePulse = 0.9 + Math.sin(progress * Math.PI * 6) * 0.2
+ parts.leftEye.scale.setScalar(eyePulse)
+ parts.rightEye.scale.setScalar(eyePulse)
+
+ // Signal burst moments
+ if (progress > 0.45 && progress < 0.55) {
+ const burstP = (progress - 0.45) / 0.1
+ const burst = Math.sin(burstP * Math.PI)
+ parts.leftArm.rotation.z = -0.5 - burst * 0.3
+ parts.rightArm.rotation.z = 0.5 + burst * 0.3
+ parts.leftEye.scale.setScalar(eyePulse + burst * 0.3)
+ parts.rightEye.scale.setScalar(eyePulse + burst * 0.3)
+ }
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.body.rotation.z = 0
+ parts.mesh.rotation.z = 0
+ parts.antenna.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ }
+}
+
+/** Portal (Task) - Summoning: drawing circles, building energy, dramatic release */
+const summoning: WorkingBehavior = {
+ name: 'summoning',
+ loop: true,
+ duration: 4,
+ update: (parts, progress) => {
+ if (progress < 0.6) {
+ // Phase 1: Drawing summoning circles with arms
+ const p = progress / 0.6
+ const circlePhase = p * Math.PI * 4
+
+ // Arms trace circles in opposite directions
+ parts.leftArm.rotation.x = -1.5 + Math.sin(circlePhase) * 0.4
+ parts.leftArm.rotation.z = -0.4 + Math.cos(circlePhase) * 0.3
+ parts.leftArm.rotation.y = Math.sin(circlePhase) * 0.3
+ parts.rightArm.rotation.x = -1.5 - Math.sin(circlePhase) * 0.4
+ parts.rightArm.rotation.z = 0.4 - Math.cos(circlePhase) * 0.3
+ parts.rightArm.rotation.y = -Math.sin(circlePhase) * 0.3
+
+ // Head focused on the portal
+ parts.head.rotation.x = -0.05
+ parts.head.rotation.y = Math.sin(p * Math.PI * 2) * 0.05
+
+ // Eyes focused and intense
+ parts.leftEye.scale.setScalar(1.1)
+ parts.rightEye.scale.setScalar(1.1)
+
+ // Body slowly rising
+ parts.body.rotation.x = -p * 0.05
+
+ // Antenna building charge
+ parts.antenna.rotation.z = Math.sin(circlePhase * 2) * (0.05 + p * 0.1)
+ } else if (progress < 0.8) {
+ // Phase 2: Energy gathered - arms pull in, building power
+ const p = (progress - 0.6) / 0.2
+ const gather = easeInOut(p)
+
+ parts.leftArm.rotation.x = -1.5 + gather * 0.3
+ parts.leftArm.rotation.z = -0.4 + gather * 0.2
+ parts.rightArm.rotation.x = -1.5 + gather * 0.3
+ parts.rightArm.rotation.z = 0.4 - gather * 0.2
+
+ // Crouch down to build energy
+ parts.body.rotation.x = -0.05 + gather * 0.12
+
+ // Eyes squinting - concentration
+ parts.leftEye.scale.setScalar(1.1 - gather * 0.3)
+ parts.rightEye.scale.setScalar(1.1 - gather * 0.3)
+
+ // Antenna vibrates rapidly
+ parts.antenna.rotation.z = Math.sin(p * Math.PI * 20) * 0.15
+ } else {
+ // Phase 3: Release! Arms thrown wide, dramatic
+ const p = (progress - 0.8) / 0.2
+ const release = elastic(Math.min(p * 1.5, 1))
+
+ parts.leftArm.rotation.x = -1.2 - release * 1.0
+ parts.leftArm.rotation.z = -0.2 - release * 0.8
+ parts.rightArm.rotation.x = -1.2 - release * 1.0
+ parts.rightArm.rotation.z = 0.2 + release * 0.8
+
+ // Head thrown back
+ parts.head.rotation.x = release * -0.2
+ parts.body.rotation.x = 0.07 - release * 0.1
+
+ // Eyes wide with power
+ const widen = 1 + release * 0.4
+ parts.leftEye.scale.setScalar(Math.min(widen, 1.4))
+ parts.rightEye.scale.setScalar(Math.min(widen, 1.4))
+
+ // Antenna springs up
+ parts.antenna.rotation.x = -release * 0.3
+ }
+ },
+ reset: (parts) => {
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.body.rotation.x = 0
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Taskboard (TodoWrite) - Panicking: frantically checking items, overwhelmed */
+const panicking: WorkingBehavior = {
+ name: 'panicking',
+ loop: true,
+ duration: 3,
+ update: (parts, progress) => {
+ const panicCycle = progress * 5
+ const panicPhase = panicCycle % 1
+
+ // Head darting between items
+ const item = Math.floor(panicCycle) % 5
+ const headTargets = [-0.3, 0.2, -0.1, 0.3, -0.2]
+ const headYTargets = [0.3, 0.15, 0.25, 0.1, 0.35]
+ parts.head.rotation.y = headTargets[item] + Math.sin(panicPhase * Math.PI) * 0.05
+ parts.head.rotation.x = headYTargets[item] * -0.3
+
+ // Frantic arm movements - checking things off
+ parts.rightArm.rotation.x = -1.5 + Math.sin(panicPhase * Math.PI * 2) * 0.4
+ parts.rightArm.rotation.z = 0.3 + Math.sin(panicPhase * Math.PI * 3) * 0.2
+
+ // Left arm holding head in distress periodically
+ if (item % 3 === 2) {
+ parts.leftArm.rotation.x = -2.0
+ parts.leftArm.rotation.z = -0.5
+ } else {
+ parts.leftArm.rotation.x = -0.8
+ parts.leftArm.rotation.z = -0.3
+ }
+
+ // Eyes wide with stress
+ parts.leftEye.scale.setScalar(1.2)
+ parts.rightEye.scale.setScalar(1.2)
+
+ // Body jittery
+ parts.body.rotation.y = Math.sin(progress * Math.PI * 10) * 0.04
+ parts.body.rotation.x = 0.05
+
+ // Antenna drooping from stress
+ parts.antenna.rotation.x = 0.15
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI * 8) * 0.08
+ },
+ reset: (parts) => {
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.body.rotation.set(0, 0, 0)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+/** Center - Deep focus: hunched, minimal movement, laser concentration */
+const deepFocus: WorkingBehavior = {
+ name: 'deepFocus',
+ loop: true,
+ duration: 5,
+ update: (parts, progress) => {
+ // Very still, hunched forward
+ parts.body.rotation.x = 0.08
+
+ // Arms in "thinking" position - one on chin, one crossed
+ parts.rightArm.rotation.x = -1.8
+ parts.rightArm.rotation.z = 0.3
+ parts.rightArm.rotation.y = -0.2
+ parts.leftArm.rotation.x = -0.6
+ parts.leftArm.rotation.z = -0.4
+
+ // Head barely moves - deep thought
+ parts.head.rotation.x = 0.08
+ parts.head.rotation.y = Math.sin(progress * Math.PI * 0.5) * 0.03
+
+ // Eyes narrow - intense focus
+ parts.leftEye.scale.setScalar(0.75)
+ parts.rightEye.scale.setScalar(0.75)
+
+ // Occasional eye shift (considering different angles)
+ const thinkCycle = progress * 3
+ if (Math.floor(thinkCycle) % 3 === 1) {
+ const lookP = thinkCycle % 1
+ parts.leftEye.position.x = -0.07 + Math.sin(lookP * Math.PI) * 0.015
+ parts.rightEye.position.x = 0.07 + Math.sin(lookP * Math.PI) * 0.015
+ parts.leftEye.position.y = 0.03 + Math.sin(lookP * Math.PI) * 0.008
+ parts.rightEye.position.y = 0.03 + Math.sin(lookP * Math.PI) * 0.008
+ }
+
+ // Antenna very slow sway - subconscious processing
+ parts.antenna.rotation.z = Math.sin(progress * Math.PI) * 0.04
+ parts.antenna.rotation.x = -0.05
+
+ // Very subtle breathing motion
+ const breathe = Math.sin(progress * Math.PI * 2) * 0.01
+ parts.body.rotation.x = 0.08 + breathe
+ },
+ reset: (parts) => {
+ parts.body.rotation.x = 0
+ parts.leftArm.rotation.set(0, 0, 0)
+ parts.rightArm.rotation.set(0, 0, 0)
+ parts.head.rotation.set(0, 0, 0)
+ parts.leftEye.scale.setScalar(1)
+ parts.rightEye.scale.setScalar(1)
+ parts.leftEye.position.set(-0.07, 0.03, 0.242)
+ parts.rightEye.position.set(0.07, 0.03, 0.242)
+ parts.antenna.rotation.set(0, 0, 0)
+ }
+}
+
+// ============================================================================
+// Station to Animation Mapping
+// ============================================================================
+
+export const STATION_ANIMATIONS: StationAnimations = {
+ bookshelf: [readingBook, speedReading],
+ workbench: [tinkering, precisionWork],
+ desk: [writing, calligraphy],
+ terminal: [typing, hackerMode, frustratedTyping],
+ scanner: [scanning, detective],
+ antenna: [receiving, broadcasting],
+ portal: [channeling, summoning],
+ taskboard: [checkingTasks, panicking],
+ center: [genericWorking, deepFocus],
+}
+
+// ============================================================================
+// Working Behavior Manager
+// ============================================================================
+
+export class WorkingBehaviorManager {
+ private currentBehavior: WorkingBehavior | null = null
+ private behaviorProgress = 0
+ private currentStation: string | null = null
+
+ /**
+ * Start a working animation for a specific station.
+ * Randomly selects from available animations for that station.
+ */
+ start(station: string, parts: CharacterParts): void {
+ // Stop current behavior if any
+ if (this.currentBehavior) {
+ this.currentBehavior.reset?.(parts)
+ }
+
+ // Get animation pool for this station
+ const pool = STATION_ANIMATIONS[station] ?? STATION_ANIMATIONS.center
+ // Randomly pick one
+ this.currentBehavior = pool[Math.floor(Math.random() * pool.length)]
+ this.currentStation = station
+ this.behaviorProgress = 0
+
+ // Store original positions
+ parts.mesh.userData.originalX = parts.mesh.position.x
+ parts.mesh.userData.originalY = parts.mesh.position.y
+ }
+
+ /**
+ * Stop the current working animation
+ */
+ stop(parts: CharacterParts): void {
+ if (this.currentBehavior) {
+ this.currentBehavior.reset?.(parts)
+ this.currentBehavior = null
+ this.currentStation = null
+ this.behaviorProgress = 0
+ }
+ }
+
+ /**
+ * Update the working animation
+ * @returns true if animation is playing
+ */
+ update(parts: CharacterParts, deltaTime: number): boolean {
+ if (!this.currentBehavior) return false
+
+ this.behaviorProgress += deltaTime / this.currentBehavior.duration
+
+ // Loop the animation
+ if (this.behaviorProgress >= 1) {
+ if (this.currentBehavior.loop) {
+ this.behaviorProgress = this.behaviorProgress % 1
+ } else {
+ this.stop(parts)
+ return false
+ }
+ }
+
+ this.currentBehavior.update(parts, this.behaviorProgress, deltaTime)
+ return true
+ }
+
+ /**
+ * Check if currently playing
+ */
+ isPlaying(): boolean {
+ return this.currentBehavior !== null
+ }
+
+ /**
+ * Get current station being animated
+ */
+ getCurrentStation(): string | null {
+ return this.currentStation
+ }
+
+ /**
+ * Get current behavior name
+ */
+ getCurrentBehaviorName(): string | null {
+ return this.currentBehavior?.name ?? null
+ }
+}
diff --git a/src/workshop/entities/animations/index.ts b/src/workshop/entities/animations/index.ts
new file mode 100644
index 0000000..4a26273
--- /dev/null
+++ b/src/workshop/entities/animations/index.ts
@@ -0,0 +1,42 @@
+/**
+ * Character Animations - Barrel Export
+ *
+ * Import everything animation-related from here:
+ *
+ * ```typescript
+ * import {
+ * // Types
+ * CharacterParts,
+ * IdleBehavior,
+ * WorkingBehavior,
+ * AnimationCategory,
+ *
+ * // Easing functions
+ * easeInOut,
+ * easeOut,
+ * bounce,
+ *
+ * // Utilities
+ * lerp,
+ * clamp,
+ * resetToDefaultPose,
+ *
+ * // Managers
+ * IdleBehaviorManager,
+ * WorkingBehaviorManager,
+ *
+ * // Registries
+ * IDLE_BEHAVIORS,
+ * STATION_ANIMATIONS,
+ * } from './animations'
+ * ```
+ */
+
+// Types and utilities
+export * from './AnimationTypes'
+
+// Idle behaviors
+export { IDLE_BEHAVIORS, IdleBehaviorManager } from './IdleBehaviors'
+
+// Working behaviors
+export { STATION_ANIMATIONS, WorkingBehaviorManager } from './WorkingBehaviors'
diff --git a/src/workshop/events/EventBus.ts b/src/workshop/events/EventBus.ts
new file mode 100644
index 0000000..ff311fd
--- /dev/null
+++ b/src/workshop/events/EventBus.ts
@@ -0,0 +1,114 @@
+/**
+ * EventBus — Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * Removed singleton export; instances are created per workshop lifecycle.
+ */
+
+import type {
+ PreToolUseEvent,
+ PostToolUseEvent,
+ StopEvent,
+ UserPromptSubmitEvent,
+ ClaudeEvent,
+ ToolStartEvent,
+ ToolEndEvent,
+ AgentIdleEvent,
+ AgentThinkingEvent,
+ UserInputEvent,
+ AgentNotificationEvent,
+ SubagentSpawnEvent,
+ SubagentEndEvent,
+} from '../types'
+import type { WorkshopScene } from '../scene/WorkshopScene'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface EventContext {
+ scene: WorkshopScene | null
+ session: SessionContext | null
+ soundEnabled: boolean
+}
+
+export interface SessionContext {
+ id: string
+ color: number
+ claude: any
+ subagents: any
+ zone: any
+ stats: {
+ toolsUsed: number
+ filesTouched: Set
+ activeSubagents: number
+ }
+}
+
+export interface EventTypeMap {
+ 'pre_tool_use': PreToolUseEvent
+ 'post_tool_use': PostToolUseEvent
+ 'stop': StopEvent
+ 'user_prompt_submit': UserPromptSubmitEvent
+ 'session_start': ClaudeEvent
+ 'notification': ClaudeEvent
+ 'tool_start': ToolStartEvent
+ 'tool_end': ToolEndEvent
+ 'agent_idle': AgentIdleEvent
+ 'agent_thinking': AgentThinkingEvent
+ 'user_input': UserInputEvent
+ 'agent_notification': AgentNotificationEvent
+ 'subagent_spawn': SubagentSpawnEvent
+ 'subagent_end': SubagentEndEvent
+}
+
+export type EventType = keyof EventTypeMap
+
+export type EventHandler = (
+ event: EventTypeMap[T],
+ context: EventContext
+) => void
+
+// ============================================================================
+// EventBus Class
+// ============================================================================
+
+export class EventBus {
+ private handlers: Map>> = new Map()
+
+ on(type: T, handler: EventHandler): () => void {
+ if (!this.handlers.has(type)) {
+ this.handlers.set(type, new Set())
+ }
+ this.handlers.get(type)!.add(handler)
+ return () => {
+ this.handlers.get(type)?.delete(handler)
+ }
+ }
+
+ emit(type: T, event: EventTypeMap[T], context: EventContext): void {
+ const handlers = this.handlers.get(type)
+ if (!handlers) return
+
+ for (const handler of handlers) {
+ try {
+ handler(event, context)
+ } catch (error) {
+ console.error(`[Workshop EventBus] Error in handler for ${type}:`, error)
+ }
+ }
+ }
+
+ off(type: EventType): void {
+ this.handlers.delete(type)
+ }
+
+ clear(): void {
+ this.handlers.clear()
+ }
+
+ getHandlerCount(type?: EventType): number {
+ if (type) return this.handlers.get(type)?.size ?? 0
+ let total = 0
+ for (const handlers of this.handlers.values()) total += handlers.size
+ return total
+ }
+}
diff --git a/src/workshop/events/handlers/animationHandlers.ts b/src/workshop/events/handlers/animationHandlers.ts
new file mode 100644
index 0000000..008690e
--- /dev/null
+++ b/src/workshop/events/handlers/animationHandlers.ts
@@ -0,0 +1,102 @@
+/**
+ * Animation Handlers
+ *
+ * Triggers context-aware animations based on tool completion.
+ * Claude reacts to what just happened with relevant idle animations.
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ */
+
+import type { EventBus } from '../EventBus'
+import type { PostToolUseEvent } from '../../types'
+
+/** Inline BashToolInput shape (not exported from types) */
+interface BashToolInput {
+ command: string
+ description?: string
+}
+
+// Command patterns for detecting specific bash operations
+const PATTERNS = {
+ gitCommit: /\bgit\s+commit\b/,
+ gitPush: /\bgit\s+push\b/,
+ npmTest: /\b(npm\s+(run\s+)?test|jest|vitest|pytest|go\s+test|bun\s+test)\b/,
+ npmBuild: /\b(npm\s+run\s+build|make\b|cargo\s+build|tsc\b|vite\s+build)\b/,
+}
+
+// Track recent reads for "exploring" detection
+let recentReadCount = 0
+let recentReadTimer: ReturnType | null = null
+
+/**
+ * Pick an animation based on what just happened
+ */
+function pickAnimation(event: PostToolUseEvent): string | null {
+ const { tool, toolInput, success, duration } = event
+
+ // Bash command patterns - most specific first
+ if (tool === 'Bash') {
+ const cmd = (toolInput as unknown as BashToolInput).command || ''
+
+ // Git operations
+ if (PATTERNS.gitCommit.test(cmd) && success) return 'victoryDance'
+ if (PATTERNS.gitPush.test(cmd) && success) return 'wave'
+
+ // Test results - animate regardless of other factors
+ if (PATTERNS.npmTest.test(cmd)) {
+ return success ? 'happyBounce' : 'headShake'
+ }
+
+ // Build completion
+ if (PATTERNS.npmBuild.test(cmd) && success) return 'stretch'
+ }
+
+ // Long-running commands make Claude sleepy
+ if (duration && duration > 10000) return 'sleepyNod'
+
+ // Write = created something new
+ if (tool === 'Write' && success) return 'happyBounce'
+
+ // Track reads for "exploring" behavior
+ if (tool === 'Read' && success) {
+ recentReadCount++
+ if (recentReadTimer) clearTimeout(recentReadTimer)
+ recentReadTimer = setTimeout(() => {
+ recentReadCount = 0
+ }, 5000) // Reset after 5s of no reads
+
+ // Many reads in quick succession = exploring
+ if (recentReadCount >= 5) {
+ recentReadCount = 0
+ return 'curiousTilt'
+ }
+ }
+
+ // Generic failure - but not for every tool (too noisy)
+ // Only for important tools where failure is notable
+ if (!success && ['Bash', 'Write', 'Edit'].includes(tool)) {
+ return 'headShake'
+ }
+
+ return null // No special animation
+}
+
+export function registerAnimationHandlers(bus: EventBus): void {
+ bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => {
+ // Need a character to animate
+ if (!ctx.session?.claude) return
+
+ const animation = pickAnimation(event)
+ if (animation) {
+ // Small delay so character settles into idle first
+ setTimeout(() => {
+ // Re-check session exists (could be gone after delay)
+ if (ctx.session?.claude) {
+ ctx.session.claude.playIdleBehavior(animation)
+ }
+ }, 300)
+ }
+ })
+}
diff --git a/src/workshop/events/handlers/characterHandlers.ts b/src/workshop/events/handlers/characterHandlers.ts
new file mode 100644
index 0000000..3dce85d
--- /dev/null
+++ b/src/workshop/events/handlers/characterHandlers.ts
@@ -0,0 +1,150 @@
+/**
+ * Character Movement Event Handlers
+ *
+ * Handles Claude character movement in response to tool use events.
+ * Moves character to appropriate stations and sets context labels.
+ *
+ * Supports both legacy Claude Code events (pre_tool_use/post_tool_use)
+ * and universal agent protocol events (tool_start/tool_end).
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ * - Sound references removed
+ */
+
+import type { EventBus } from '../EventBus'
+import { getToolContext } from '../../utils/ToolUtils'
+import { getStationForTool, getStationForCategory } from '../../types'
+import type {
+ StationType,
+ PreToolUseEvent,
+ PostToolUseEvent,
+ StopEvent,
+ UserPromptSubmitEvent,
+ ToolStartEvent,
+ ToolEndEvent,
+ AgentIdleEvent,
+ UserInputEvent,
+ ToolCategory,
+} from '../../types'
+
+/**
+ * Resolve station from either a tool name (legacy) or tool category (universal).
+ */
+function resolveStation(toolName: string, category?: string): StationType {
+ // If category is available, prefer it (universal protocol)
+ if (category) {
+ return getStationForCategory(category as ToolCategory) as StationType
+ }
+ // Fall back to tool name lookup (legacy)
+ return getStationForTool(toolName)
+}
+
+/**
+ * Register character movement event handlers
+ */
+export function registerCharacterHandlers(bus: EventBus): void {
+ // Move character to station when tool starts (legacy)
+ bus.on('pre_tool_use', (event: PreToolUseEvent, ctx) => {
+ if (!ctx.session) return
+
+ const station = resolveStation(event.tool)
+
+ // Move character to station (skip 'center' - those are MCP browser tools)
+ if (station !== 'center') {
+ const zoneStation = ctx.session.zone.stations.get(station)
+ if (zoneStation) {
+ ctx.session.claude.moveToPosition(zoneStation.position, station)
+ }
+ }
+
+ // Set context text above station
+ if (ctx.scene && station !== 'center') {
+ const context = getToolContext(event.tool, event.toolInput)
+ if (context) {
+ ctx.scene.setStationContext(station, context, event.sessionId)
+ }
+
+ // Pulse station ring to highlight activity
+ ctx.scene.pulseStation(event.sessionId, station)
+ }
+ })
+
+ // Move character to station when tool starts (universal)
+ bus.on('tool_start', (event: ToolStartEvent, ctx) => {
+ if (!ctx.session) return
+
+ const station = resolveStation(event.tool.name, event.tool.category)
+
+ if (station !== 'center') {
+ const zoneStation = ctx.session.zone.stations.get(station)
+ if (zoneStation) {
+ ctx.session.claude.moveToPosition(zoneStation.position, station)
+ }
+ }
+
+ if (ctx.scene && station !== 'center') {
+ const context = event.context || getToolContext(event.tool.name, event.input || {})
+ if (context) {
+ ctx.scene.setStationContext(station, context, event.agentId)
+ }
+ ctx.scene.pulseStation(event.agentId, station)
+ }
+ })
+
+ // Set idle state when tool completes (legacy)
+ bus.on('post_tool_use', (_event: PostToolUseEvent, ctx) => {
+ if (!ctx.session) return
+
+ // Only set idle if character isn't walking
+ if (ctx.session.claude.state !== 'walking') {
+ ctx.session.claude.setState('idle')
+ }
+ })
+
+ // Set idle state when tool completes (universal)
+ bus.on('tool_end', (_event: ToolEndEvent, ctx) => {
+ if (!ctx.session) return
+ if (ctx.session.claude.state !== 'walking') {
+ ctx.session.claude.setState('idle')
+ }
+ })
+
+ // Move character back to center when stopped (legacy)
+ bus.on('stop', (event: StopEvent, ctx) => {
+ if (!ctx.session || !ctx.scene) return
+
+ // Move to zone center
+ const centerStation = ctx.session.zone.stations.get('center')
+ if (centerStation) {
+ ctx.session.claude.moveToPosition(centerStation.position, 'center')
+ }
+
+ // Clear station context labels
+ ctx.scene.clearAllContexts(event.sessionId)
+ })
+
+ // Move character back to center when idle (universal)
+ bus.on('agent_idle', (event: AgentIdleEvent, ctx) => {
+ if (!ctx.session || !ctx.scene) return
+
+ const centerStation = ctx.session.zone.stations.get('center')
+ if (centerStation) {
+ ctx.session.claude.moveToPosition(centerStation.position, 'center')
+ }
+ ctx.scene.clearAllContexts(event.agentId)
+ })
+
+ // Set thinking state when user submits prompt (legacy)
+ bus.on('user_prompt_submit', (_event: UserPromptSubmitEvent, ctx) => {
+ if (!ctx.session) return
+ ctx.session.claude.setState('thinking')
+ })
+
+ // Set thinking state when user sends input (universal)
+ bus.on('user_input', (_event: UserInputEvent, ctx) => {
+ if (!ctx.session) return
+ ctx.session.claude.setState('thinking')
+ })
+}
diff --git a/src/workshop/events/handlers/feedHandlers.ts b/src/workshop/events/handlers/feedHandlers.ts
new file mode 100644
index 0000000..3e6fc01
--- /dev/null
+++ b/src/workshop/events/handlers/feedHandlers.ts
@@ -0,0 +1,35 @@
+/**
+ * Feed/UI Event Handlers
+ *
+ * Handles thinking indicator visibility in the activity feed.
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ * - FeedManager DOM references removed (no-op stubs for now;
+ * will be wired to React state later)
+ */
+
+import type { EventBus } from '../EventBus'
+import type { PreToolUseEvent, StopEvent } from '../../types'
+
+/**
+ * Register feed-related event handlers
+ */
+export function registerFeedHandlers(bus: EventBus): void {
+ // Hide thinking indicator when tool starts
+ // NOTE: feedManager removed; this is a no-op until wired to React state
+ bus.on('pre_tool_use', (_event: PreToolUseEvent, _ctx) => {
+ // Original: ctx.feedManager.hideThinking(_event.sessionId)
+ // Will be wired to React state later
+ })
+
+ // Hide thinking indicator on stop
+ bus.on('stop', (_event: StopEvent, _ctx) => {
+ // Original: ctx.feedManager.hideThinking(event.sessionId)
+ // Will be wired to React state later
+ })
+
+ // NOTE: showThinking for user_prompt_submit is handled in main.ts
+ // AFTER feedManager.add() to ensure correct ordering in the feed
+}
diff --git a/src/workshop/events/handlers/index.ts b/src/workshop/events/handlers/index.ts
new file mode 100644
index 0000000..a402e3d
--- /dev/null
+++ b/src/workshop/events/handlers/index.ts
@@ -0,0 +1,42 @@
+/**
+ * Event Handlers - Barrel Export
+ *
+ * Import and call registerAllHandlers(bus) to set up EventBus handlers.
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - All register functions accept an EventBus instance (no singleton).
+ */
+
+import type { EventBus } from '../EventBus'
+import { registerSoundHandlers } from './soundHandlers'
+import { registerNotificationHandlers } from './notificationHandlers'
+import { registerCharacterHandlers } from './characterHandlers'
+import { registerSubagentHandlers } from './subagentHandlers'
+import { registerZoneHandlers } from './zoneHandlers'
+import { registerFeedHandlers } from './feedHandlers'
+import { registerAnimationHandlers } from './animationHandlers'
+
+/**
+ * Register all EventBus handlers
+ * Call this once during app initialization
+ */
+export function registerAllHandlers(bus: EventBus): void {
+ registerSoundHandlers(bus)
+ registerNotificationHandlers(bus)
+ registerCharacterHandlers(bus)
+ registerSubagentHandlers(bus)
+ registerZoneHandlers(bus)
+ registerFeedHandlers(bus)
+ registerAnimationHandlers(bus)
+}
+
+// Re-export individual registrations for testing
+export {
+ registerSoundHandlers,
+ registerNotificationHandlers,
+ registerCharacterHandlers,
+ registerSubagentHandlers,
+ registerZoneHandlers,
+ registerFeedHandlers,
+ registerAnimationHandlers,
+}
diff --git a/src/workshop/events/handlers/notificationHandlers.ts b/src/workshop/events/handlers/notificationHandlers.ts
new file mode 100644
index 0000000..ca1e06e
--- /dev/null
+++ b/src/workshop/events/handlers/notificationHandlers.ts
@@ -0,0 +1,227 @@
+/**
+ * Zone Notification Event Handlers
+ *
+ * Shows floating notifications above zones when tools complete.
+ * Uses ZoneNotifications system for tool-specific styling.
+ *
+ * Supports both legacy Claude Code events and universal agent protocol events.
+ * Universal events use category-based notification formatting.
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ * - Sound references removed
+ * - Inline notification formatting (ZoneNotifications not yet vendored)
+ */
+
+import type { EventBus } from '../EventBus'
+import type {
+ PostToolUseEvent,
+ StationType,
+ ToolEndEvent,
+ ToolCategory,
+} from '../../types'
+import { getStationForTool, getStationForCategory } from '../../types'
+
+// ============================================================================
+// Notification text formatting helpers
+// (Inlined from Vibecraft2 scene/ZoneNotifications — not yet vendored)
+// ============================================================================
+
+function formatFileChange(
+ fileName: string,
+ info: { added?: number; removed?: number; lines?: number },
+): string {
+ if (info.lines !== undefined) {
+ return `${fileName} (${info.lines} lines)`
+ }
+ const parts: string[] = [fileName]
+ if (info.added) parts.push(`+${info.added}`)
+ if (info.removed) parts.push(`-${info.removed}`)
+ return parts.join(' ')
+}
+
+function formatCommandResult(command: string): string {
+ // Truncate long commands
+ const trimmed = command.trim()
+ if (trimmed.length > 40) return trimmed.slice(0, 37) + '...'
+ return trimmed
+}
+
+function formatSearchResult(pattern: string): string {
+ if (pattern.length > 30) return pattern.slice(0, 27) + '...'
+ return pattern
+}
+
+// ============================================================================
+// Legacy (tool-name based) notification text
+// ============================================================================
+
+/**
+ * Extract notification text from tool input based on tool name (legacy).
+ */
+function getNotificationTextByTool(tool: string, input: Record): string | null {
+ switch (tool) {
+ case 'Edit': {
+ const filePath = input.file_path as string | undefined
+ if (filePath) {
+ const fileName = filePath.split('/').pop() || filePath
+ const oldStr = input.old_string as string | undefined
+ const newStr = input.new_string as string | undefined
+ if (oldStr && newStr) {
+ const oldLines = (oldStr.match(/\n/g) || []).length + 1
+ const newLines = (newStr.match(/\n/g) || []).length + 1
+ const added = Math.max(0, newLines - oldLines)
+ const removed = Math.max(0, oldLines - newLines)
+ return formatFileChange(fileName, { added, removed })
+ }
+ return fileName
+ }
+ return null
+ }
+ case 'Write': {
+ const filePath = input.file_path as string | undefined
+ if (filePath) {
+ const fileName = filePath.split('/').pop() || filePath
+ const content = input.content as string | undefined
+ if (content) {
+ const lines = (content.match(/\n/g) || []).length + 1
+ return formatFileChange(fileName, { lines })
+ }
+ return fileName
+ }
+ return null
+ }
+ case 'Read': {
+ const filePath = input.file_path as string | undefined
+ if (filePath) return filePath.split('/').pop() || filePath
+ return null
+ }
+ case 'Bash': {
+ const command = input.command as string | undefined
+ return command ? formatCommandResult(command) : null
+ }
+ case 'Grep':
+ case 'Glob': {
+ const pattern = input.pattern as string | undefined
+ return pattern ? formatSearchResult(pattern) : null
+ }
+ case 'WebFetch':
+ case 'WebSearch': {
+ const url = input.url as string | undefined
+ const query = input.query as string | undefined
+ if (url) {
+ try { return new URL(url).hostname } catch { return url.slice(0, 30) }
+ }
+ if (query) return formatSearchResult(query)
+ return null
+ }
+ case 'Task': {
+ const description = input.description as string | undefined
+ return description ? description.slice(0, 25) : null
+ }
+ case 'TodoWrite': {
+ const todos = input.todos as Array<{ content?: string }> | undefined
+ return (todos && todos.length > 0) ? `${todos.length} items` : null
+ }
+ default:
+ return null
+ }
+}
+
+// ============================================================================
+// Universal (category-based) notification text
+// ============================================================================
+
+/**
+ * Extract notification text from tool output based on category (universal).
+ */
+function getNotificationTextByCategory(
+ category: ToolCategory,
+ toolName: string,
+ output?: Record,
+): string | null {
+ // Use the tool name as a basic notification
+ const name = toolName.length > 25 ? toolName.slice(0, 25) + '...' : toolName
+ switch (category) {
+ case 'read':
+ case 'write':
+ case 'edit':
+ return output?.file_path
+ ? (output.file_path as string).split('/').pop() || name
+ : name
+ case 'execute':
+ return output?.command
+ ? formatCommandResult(output.command as string)
+ : name
+ case 'search':
+ return output?.pattern
+ ? formatSearchResult(output.pattern as string)
+ : name
+ case 'network':
+ if (output?.url) {
+ try { return new URL(output.url as string).hostname } catch { /* fallthrough */ }
+ }
+ return name
+ case 'delegate':
+ return output?.description
+ ? (output.description as string).slice(0, 25)
+ : name
+ case 'plan':
+ return name
+ default:
+ return name
+ }
+}
+
+// ============================================================================
+// Handler registration
+// ============================================================================
+
+/**
+ * Register notification-related event handlers
+ */
+export function registerNotificationHandlers(bus: EventBus): void {
+ // Tool completion notifications (legacy)
+ bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => {
+ if (!event.success || !ctx.scene) return
+
+ const input = event.toolInput as Record
+ const notificationText = getNotificationTextByTool(event.tool, input)
+
+ if (notificationText) {
+ ctx.scene.zoneNotifications.showForTool(event.sessionId, event.tool, notificationText)
+
+ const station = getStationForTool(event.tool)
+ if (station !== 'center') {
+ ctx.scene.stationPanels.addToolUse(event.sessionId, station, {
+ text: notificationText,
+ success: event.success,
+ })
+ }
+ }
+ })
+
+ // Tool completion notifications (universal)
+ bus.on('tool_end', (event: ToolEndEvent, ctx) => {
+ if (!event.success || !ctx.scene) return
+
+ const notificationText = getNotificationTextByCategory(
+ event.tool.category,
+ event.tool.name,
+ event.output,
+ )
+
+ if (notificationText) {
+ ctx.scene.zoneNotifications.showForTool(event.agentId, event.tool.name, notificationText)
+
+ const station = getStationForCategory(event.tool.category) as StationType
+ if (station !== 'center') {
+ ctx.scene.stationPanels.addToolUse(event.agentId, station as StationType, {
+ text: notificationText,
+ success: event.success,
+ })
+ }
+ }
+ })
+}
diff --git a/src/workshop/events/handlers/soundHandlers.ts b/src/workshop/events/handlers/soundHandlers.ts
new file mode 100644
index 0000000..12fdfd2
--- /dev/null
+++ b/src/workshop/events/handlers/soundHandlers.ts
@@ -0,0 +1,21 @@
+/**
+ * Sound Event Handlers — NO-OP VERSION
+ *
+ * Sound is not vendored in ClawBox. This module exports a no-op
+ * registration function to keep the handler barrel consistent.
+ *
+ * The original Vibecraft2 version registers spatial audio for tool
+ * start/end, git commits, subagent spawn/despawn, stop, prompt,
+ * and notification events via soundManager.
+ */
+
+import type { EventBus } from '../EventBus'
+
+/**
+ * Register sound-related event handlers (no-op in ClawBox)
+ */
+export function registerSoundHandlers(_bus: EventBus): void {
+ // Sound is not vendored — intentionally empty.
+ // See Vibecraft2 src/events/handlers/soundHandlers.ts for the
+ // full implementation if audio support is added later.
+}
diff --git a/src/workshop/events/handlers/subagentHandlers.ts b/src/workshop/events/handlers/subagentHandlers.ts
new file mode 100644
index 0000000..97685b0
--- /dev/null
+++ b/src/workshop/events/handlers/subagentHandlers.ts
@@ -0,0 +1,82 @@
+/**
+ * Subagent Event Handlers
+ *
+ * Handles spawning and removing subagent visualizations
+ * when Task tools start and complete.
+ *
+ * Supports both legacy Claude Code events (Task tool) and
+ * universal agent protocol events (delegate category / subagent_spawn).
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ */
+
+import type { EventBus } from '../EventBus'
+import type {
+ PreToolUseEvent,
+ PostToolUseEvent,
+ ToolStartEvent,
+ ToolEndEvent,
+ SubagentSpawnEvent,
+ SubagentEndEvent,
+} from '../../types'
+
+/**
+ * Register subagent-related event handlers
+ */
+export function registerSubagentHandlers(bus: EventBus): void {
+ // Spawn subagent when Task tool starts (legacy)
+ bus.on('pre_tool_use', (event: PreToolUseEvent, ctx) => {
+ if (!ctx.session) return
+ if (event.tool !== 'Task') return
+
+ const description = (event.toolInput as { description?: string }).description
+ ctx.session.subagents.spawn(event.toolUseId, description)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+
+ // Remove subagent when Task tool completes (legacy)
+ bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => {
+ if (!ctx.session) return
+ if (event.tool !== 'Task') return
+
+ ctx.session.subagents.remove(event.toolUseId)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+
+ // Spawn subagent when delegate tool starts (universal)
+ bus.on('tool_start', (event: ToolStartEvent, ctx) => {
+ if (!ctx.session) return
+ if (event.tool.category !== 'delegate') return
+
+ const description = event.context || event.input?.description as string | undefined
+ ctx.session.subagents.spawn(event.tool.id, description)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+
+ // Remove subagent when delegate tool completes (universal)
+ bus.on('tool_end', (event: ToolEndEvent, ctx) => {
+ if (!ctx.session) return
+ if (event.tool.category !== 'delegate') return
+
+ ctx.session.subagents.remove(event.tool.id)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+
+ // Explicit subagent spawn event (universal)
+ bus.on('subagent_spawn', (event: SubagentSpawnEvent, ctx) => {
+ if (!ctx.session) return
+ const id = event.toolUseId || event.id
+ ctx.session.subagents.spawn(id, event.description)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+
+ // Explicit subagent end event (universal)
+ bus.on('subagent_end', (event: SubagentEndEvent, ctx) => {
+ if (!ctx.session) return
+ const id = event.toolUseId || event.id
+ ctx.session.subagents.remove(id)
+ ctx.session.stats.activeSubagents = ctx.session.subagents.count
+ })
+}
diff --git a/src/workshop/events/handlers/zoneHandlers.ts b/src/workshop/events/handlers/zoneHandlers.ts
new file mode 100644
index 0000000..e3bbed3
--- /dev/null
+++ b/src/workshop/events/handlers/zoneHandlers.ts
@@ -0,0 +1,36 @@
+/**
+ * Zone Status Event Handlers
+ *
+ * Handles zone status updates (working, attention, etc.)
+ * and attention states for questions and completion.
+ *
+ * Vendored from Vibecraft2, adapted for ClawBox embedding.
+ * - EventBus passed as parameter (no singleton)
+ * - Types imported from ../../types
+ */
+
+import type { EventBus } from '../EventBus'
+import type { StopEvent, UserPromptSubmitEvent } from '../../types'
+
+/**
+ * Register zone status event handlers
+ */
+export function registerZoneHandlers(bus: EventBus): void {
+ // Set attention state when Claude stops (finished work)
+ bus.on('stop', (event: StopEvent, ctx) => {
+ if (!ctx.session || !ctx.scene) return
+
+ // Set finished attention - agent completed its work
+ ctx.scene.setZoneAttention(event.sessionId, 'finished')
+ ctx.scene.setZoneStatus(event.sessionId, 'attention') // Red glow
+ })
+
+ // Clear attention and set working when user submits prompt
+ bus.on('user_prompt_submit', (event: UserPromptSubmitEvent, ctx) => {
+ if (!ctx.session || !ctx.scene) return
+
+ // Clear attention - user is now engaged
+ ctx.scene.clearZoneAttention(event.sessionId)
+ ctx.scene.setZoneStatus(event.sessionId, 'working') // Cyan glow
+ })
+}
diff --git a/src/workshop/scene/SpawnBeam.ts b/src/workshop/scene/SpawnBeam.ts
new file mode 100644
index 0000000..e102a8d
--- /dev/null
+++ b/src/workshop/scene/SpawnBeam.ts
@@ -0,0 +1,188 @@
+/**
+ * SpawnBeam - Glowing particle trail arcing between zones
+ *
+ * Used when the Architect spawns a sub-agent: a stream of particles
+ * travels along a quadratic bezier arc from the portal station to the
+ * new sub-agent zone center.
+ */
+
+import * as THREE from 'three'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ActiveBeam {
+ points: THREE.Points
+ geometry: THREE.BufferGeometry
+ /** Per-particle progress along the arc (0→1), staggered */
+ particleProgress: Float32Array
+ /** Bezier control points */
+ p0: THREE.Vector3
+ p1: THREE.Vector3 // elevated midpoint
+ p2: THREE.Vector3
+ /** Overall beam age in seconds */
+ age: number
+ color: THREE.Color
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const PARTICLE_COUNT = 15
+const BEAM_DURATION = 1.2 // total seconds
+const TRAVEL_END = 0.7 // progress at which lead particle arrives
+const FADE_START = 0.85 // progress at which fade begins
+const ARC_HEIGHT = 4 // how high the midpoint control point rises
+const PARTICLE_SIZE = 0.25
+const SCATTER_RADIUS = 0.3 // scatter at arrival
+
+// ============================================================================
+// SpawnBeamManager
+// ============================================================================
+
+export class SpawnBeamManager {
+ private scene: THREE.Scene
+ private beams: ActiveBeam[] = []
+
+ constructor(scene: THREE.Scene) {
+ this.scene = scene
+ }
+
+ /**
+ * Launch a beam from one world position to another.
+ * @param from Source position (portal station)
+ * @param to Target position (zone center)
+ * @param color Beam color (hex number, e.g. 0x60a5fa)
+ */
+ launch(from: THREE.Vector3, to: THREE.Vector3, color: number): void {
+ const geometry = new THREE.BufferGeometry()
+ const positions = new Float32Array(PARTICLE_COUNT * 3)
+ const alphas = new Float32Array(PARTICLE_COUNT)
+ const sizes = new Float32Array(PARTICLE_COUNT)
+
+ // All particles start at the source
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ positions[i * 3] = from.x
+ positions[i * 3 + 1] = from.y
+ positions[i * 3 + 2] = from.z
+ alphas[i] = 0
+ sizes[i] = PARTICLE_SIZE
+ }
+
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+ geometry.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1))
+ geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
+
+ const material = new THREE.PointsMaterial({
+ color,
+ size: PARTICLE_SIZE,
+ transparent: true,
+ opacity: 1,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ sizeAttenuation: true,
+ })
+
+ const points = new THREE.Points(geometry, material)
+ this.scene.add(points)
+
+ // Quadratic bezier: midpoint elevated
+ const mid = new THREE.Vector3().lerpVectors(from, to, 0.5)
+ mid.y += ARC_HEIGHT
+
+ // Stagger each particle so they form a trail
+ const particleProgress = new Float32Array(PARTICLE_COUNT)
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ // Stagger: particle 0 leads, last particle trails
+ particleProgress[i] = -(i / PARTICLE_COUNT) * TRAVEL_END
+ }
+
+ this.beams.push({
+ points,
+ geometry,
+ particleProgress,
+ p0: from.clone(),
+ p1: mid,
+ p2: to.clone(),
+ age: 0,
+ color: new THREE.Color(color),
+ })
+ }
+
+ /**
+ * Advance all active beams. Called each frame from the render loop.
+ */
+ update(delta: number): void {
+ const toRemove: number[] = []
+
+ for (let b = 0; b < this.beams.length; b++) {
+ const beam = this.beams[b]
+ beam.age += delta
+
+ const overallProgress = beam.age / BEAM_DURATION
+ if (overallProgress >= 1) {
+ toRemove.push(b)
+ continue
+ }
+
+ const pos = beam.geometry.getAttribute('position') as THREE.BufferAttribute
+ const material = beam.points.material as THREE.PointsMaterial
+
+ // Global fade during the fade phase
+ if (overallProgress >= FADE_START) {
+ const fadeProg = (overallProgress - FADE_START) / (1 - FADE_START)
+ material.opacity = 1 - fadeProg
+ }
+
+ const tmp = new THREE.Vector3()
+
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ // Advance this particle's progress along the arc
+ beam.particleProgress[i] += delta / (BEAM_DURATION * TRAVEL_END)
+
+ const t = Math.max(0, Math.min(1, beam.particleProgress[i]))
+
+ // Quadratic bezier: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
+ const omt = 1 - t
+ tmp.set(
+ omt * omt * beam.p0.x + 2 * omt * t * beam.p1.x + t * t * beam.p2.x,
+ omt * omt * beam.p0.y + 2 * omt * t * beam.p1.y + t * t * beam.p2.y,
+ omt * omt * beam.p0.z + 2 * omt * t * beam.p1.z + t * t * beam.p2.z,
+ )
+
+ // Scatter slightly at arrival
+ if (t >= 0.95) {
+ const scatter = (t - 0.95) / 0.05 * SCATTER_RADIUS
+ tmp.x += (Math.random() - 0.5) * scatter
+ tmp.y += (Math.random() - 0.5) * scatter
+ tmp.z += (Math.random() - 0.5) * scatter
+ }
+
+ pos.setXYZ(i, tmp.x, tmp.y, tmp.z)
+ }
+
+ pos.needsUpdate = true
+ }
+
+ // Remove finished beams (iterate in reverse to keep indices valid)
+ for (let i = toRemove.length - 1; i >= 0; i--) {
+ const beam = this.beams[toRemove[i]]
+ this.scene.remove(beam.points)
+ beam.geometry.dispose()
+ ;(beam.points.material as THREE.PointsMaterial).dispose()
+ this.beams.splice(toRemove[i], 1)
+ }
+ }
+
+ /** Clean up all active beams */
+ dispose(): void {
+ for (const beam of this.beams) {
+ this.scene.remove(beam.points)
+ beam.geometry.dispose()
+ ;(beam.points.material as THREE.PointsMaterial).dispose()
+ }
+ this.beams = []
+ }
+}
diff --git a/src/workshop/scene/StationPanels.ts b/src/workshop/scene/StationPanels.ts
new file mode 100644
index 0000000..a3d8cd3
--- /dev/null
+++ b/src/workshop/scene/StationPanels.ts
@@ -0,0 +1,328 @@
+/**
+ * Station Panels
+ *
+ * Shows recent tool activity history for each workstation.
+ * Toggled with P key, hidden by default.
+ */
+
+import * as THREE from 'three'
+import type { StationType } from '../types'
+
+export interface ToolHistoryItem {
+ text: string // "npm test" or "config.ts"
+ success: boolean
+ timestamp: number
+}
+
+interface StationPanel {
+ sprite: THREE.Sprite
+ history: ToolHistoryItem[]
+ needsUpdate: boolean
+}
+
+// Station display names and colors
+const STATION_CONFIG: Record<
+ StationType,
+ { name: string; color: string; icon: string }
+> = {
+ center: { name: 'CENTER', color: '#4ac8e8', icon: '' },
+ bookshelf: { name: 'LIBRARY', color: '#fbbf24', icon: '' },
+ desk: { name: 'DESK', color: '#4ade80', icon: '' },
+ workbench: { name: 'WORKBENCH', color: '#f97316', icon: '' },
+ terminal: { name: 'TERMINAL', color: '#22d3ee', icon: '' },
+ scanner: { name: 'SCANNER', color: '#60a5fa', icon: '' },
+ antenna: { name: 'ANTENNA', color: '#4ac8e8', icon: '' },
+ portal: { name: 'PORTAL', color: '#22d3d8', icon: '' },
+ taskboard: { name: 'TASKBOARD', color: '#fb923c', icon: '' },
+}
+
+// Station positions (relative to zone center)
+const STATION_OFFSETS: Record = {
+ center: [0, 0, 0],
+ bookshelf: [0, 0, -4],
+ desk: [4, 0, 0],
+ workbench: [-4, 0, 0],
+ terminal: [0, 0, 4],
+ scanner: [3, 0, -3],
+ antenna: [-3, 0, -3],
+ portal: [-3, 0, 3],
+ taskboard: [3, 0, 3],
+}
+
+const MAX_HISTORY = 3
+const CANVAS_WIDTH = 256
+const CANVAS_HEIGHT = 160
+const PANEL_SCALE = 2.5
+
+export class StationPanels {
+ private panels: Map> = new Map() // zoneId -> stationType -> panel
+ private scene: THREE.Scene
+ private visible = false
+
+ constructor(scene: THREE.Scene) {
+ this.scene = scene
+ }
+
+ /**
+ * Create panels for a zone
+ */
+ createPanelsForZone(
+ zoneId: string,
+ zonePosition: THREE.Vector3,
+ zoneColor: number
+ ): void {
+ const zonePanels = new Map()
+
+ for (const [stationType, offset] of Object.entries(STATION_OFFSETS)) {
+ if (stationType === 'center') continue // Skip center station
+
+ const sprite = this.createPanelSprite(stationType as StationType)
+
+ // Position panel offset from station, raised and angled back
+ const [ox, , oz] = offset
+ sprite.position.set(
+ zonePosition.x + ox * 0.7, // Closer to center
+ zonePosition.y + 3.5, // Above station
+ zonePosition.z + oz * 0.7
+ )
+
+ sprite.visible = this.visible
+ this.scene.add(sprite)
+
+ zonePanels.set(stationType as StationType, {
+ sprite,
+ history: [],
+ needsUpdate: false,
+ })
+ }
+
+ this.panels.set(zoneId, zonePanels)
+ }
+
+ /**
+ * Remove panels for a zone
+ */
+ removePanelsForZone(zoneId: string): void {
+ const zonePanels = this.panels.get(zoneId)
+ if (!zonePanels) return
+
+ for (const [, panel] of zonePanels) {
+ this.scene.remove(panel.sprite)
+ panel.sprite.material.map?.dispose()
+ ;(panel.sprite.material as THREE.SpriteMaterial).dispose()
+ }
+
+ this.panels.delete(zoneId)
+ }
+
+ /**
+ * Add a tool use to station history
+ */
+ addToolUse(
+ zoneId: string,
+ station: StationType,
+ item: Omit
+ ): void {
+ const zonePanels = this.panels.get(zoneId)
+ if (!zonePanels) return
+
+ const panel = zonePanels.get(station)
+ if (!panel) return
+
+ // Add new item
+ panel.history.push({
+ ...item,
+ timestamp: Date.now(),
+ })
+
+ // Trim to max
+ while (panel.history.length > MAX_HISTORY) {
+ panel.history.shift()
+ }
+
+ panel.needsUpdate = true
+ }
+
+ /**
+ * Toggle visibility of all panels
+ */
+ setVisible(visible: boolean): void {
+ this.visible = visible
+ for (const [, zonePanels] of this.panels) {
+ for (const [, panel] of zonePanels) {
+ panel.sprite.visible = visible
+ }
+ }
+ }
+
+ /**
+ * Get visibility state
+ */
+ isVisible(): boolean {
+ return this.visible
+ }
+
+ /**
+ * Update panels that need re-rendering
+ */
+ update(): void {
+ for (const [, zonePanels] of this.panels) {
+ for (const [stationType, panel] of zonePanels) {
+ if (panel.needsUpdate) {
+ this.renderPanel(panel, stationType)
+ panel.needsUpdate = false
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a panel sprite for a station
+ */
+ private createPanelSprite(stationType: StationType): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ canvas.width = CANVAS_WIDTH
+ canvas.height = CANVAS_HEIGHT
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.minFilter = THREE.LinearFilter
+ texture.magFilter = THREE.LinearFilter
+
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false,
+ })
+
+ const sprite = new THREE.Sprite(material)
+ sprite.scale.set(
+ PANEL_SCALE,
+ PANEL_SCALE * (CANVAS_HEIGHT / CANVAS_WIDTH),
+ 1
+ )
+
+ // Render initial state
+ this.renderPanelCanvas(canvas, stationType, [])
+
+ return sprite
+ }
+
+ /**
+ * Re-render a panel's canvas
+ */
+ private renderPanel(panel: StationPanel, stationType: StationType): void {
+ const material = panel.sprite.material as THREE.SpriteMaterial
+ const texture = material.map as THREE.CanvasTexture
+ const canvas = texture.image as HTMLCanvasElement
+
+ this.renderPanelCanvas(canvas, stationType, panel.history)
+ texture.needsUpdate = true
+ }
+
+ /**
+ * Render panel content to canvas
+ */
+ private renderPanelCanvas(
+ canvas: HTMLCanvasElement,
+ stationType: StationType,
+ history: ToolHistoryItem[]
+ ): void {
+ const ctx = canvas.getContext('2d')!
+ const config = STATION_CONFIG[stationType]
+
+ // Clear
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+
+ // Background
+ ctx.fillStyle = 'rgba(10, 15, 25, 0.9)'
+ this.roundRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8)
+ ctx.fill()
+
+ // Border
+ ctx.strokeStyle = config.color
+ ctx.lineWidth = 2
+ ctx.globalAlpha = 0.6
+ this.roundRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8)
+ ctx.stroke()
+ ctx.globalAlpha = 1
+
+ // Header
+ ctx.fillStyle = config.color
+ ctx.font = 'bold 16px system-ui, -apple-system, sans-serif'
+ ctx.textAlign = 'left'
+ ctx.textBaseline = 'top'
+ ctx.fillText(config.name, 20, 20)
+
+ // Divider
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.moveTo(20, 44)
+ ctx.lineTo(canvas.width - 20, 44)
+ ctx.stroke()
+
+ // History items
+ const startY = 54
+ const lineHeight = 32
+
+ if (history.length === 0) {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'
+ ctx.font = '13px system-ui, -apple-system, sans-serif'
+ ctx.fillText('No activity yet', 20, startY + 8)
+ } else {
+ ctx.font = '13px system-ui, -apple-system, sans-serif'
+
+ history.forEach((item, i) => {
+ const y = startY + i * lineHeight
+
+ // Status indicator
+ ctx.fillStyle = item.success ? '#4ade80' : '#f87171'
+ ctx.beginPath()
+ ctx.arc(26, y + 12, 4, 0, Math.PI * 2)
+ ctx.fill()
+
+ // Text
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
+ const maxTextWidth = canvas.width - 60
+ let displayText = item.text
+
+ // Truncate if needed
+ if (ctx.measureText(displayText).width > maxTextWidth) {
+ while (
+ ctx.measureText(displayText + '...').width > maxTextWidth &&
+ displayText.length > 0
+ ) {
+ displayText = displayText.slice(0, -1)
+ }
+ displayText += '...'
+ }
+
+ ctx.fillText(displayText, 38, y + 8)
+ })
+ }
+ }
+
+ /**
+ * Draw a rounded rectangle path
+ */
+ private roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number
+ ): void {
+ ctx.beginPath()
+ ctx.moveTo(x + radius, y)
+ ctx.lineTo(x + width - radius, y)
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
+ ctx.lineTo(x + width, y + height - radius)
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
+ ctx.lineTo(x + radius, y + height)
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
+ ctx.lineTo(x, y + radius)
+ ctx.quadraticCurveTo(x, y, x + radius, y)
+ ctx.closePath()
+ }
+}
diff --git a/src/workshop/scene/WorkshopScene.ts b/src/workshop/scene/WorkshopScene.ts
new file mode 100644
index 0000000..9e3aae5
--- /dev/null
+++ b/src/workshop/scene/WorkshopScene.ts
@@ -0,0 +1,1903 @@
+/**
+ * WorkshopScene — Simplified 3D workshop environment for ClawBox
+ *
+ * Vendored and adapted from Vibecraft2's WorkshopScene.ts.
+ * Removed: audio, drawMode, text tiles, hex painting, permission modals,
+ * context menus, pending zone spinners, git labels, multiple camera animation
+ * targets, FPS counter.
+ * Kept: full visual quality, hex grid, stations, particles, camera,
+ * zone lifecycle, click pulses, notifications, panels, spawn beams.
+ */
+
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import type { StationType } from '../types'
+import { HexGrid } from '../utils/HexGrid'
+import { ZoneNotifications, type NotificationStyle } from './ZoneNotifications'
+import { SpawnBeamManager } from './SpawnBeam'
+import { StationPanels } from './StationPanels'
+import {
+ addBookshelfDetails,
+ addTerminalDetails,
+ addAntennaDetails,
+ addPortalDetails,
+ addScannerDetails,
+ addDeskDetails,
+ addWorkbenchDetails,
+ addTaskboardDetails,
+} from './stations'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface Station {
+ type: StationType
+ position: THREE.Vector3 // World position (updated when zone elevation changes)
+ localPosition: THREE.Vector3 // Position relative to zone
+ mesh: THREE.Group
+ label: string
+ contextSprite?: THREE.Sprite
+}
+
+export type AttentionReason = 'question' | 'finished' | 'error' | null
+
+export interface Zone {
+ id: string
+ group: THREE.Group
+ stations: Map
+ platform: THREE.Mesh
+ ring: THREE.Mesh
+ floor: THREE.Mesh
+ color: number
+ position: THREE.Vector3
+ label?: THREE.Sprite
+ pulseIntensity: number
+ attentionReason: AttentionReason
+ attentionTime: number
+ particles: THREE.Points
+ particleVelocities: Float32Array
+ status: 'idle' | 'working' | 'waiting' | 'attention' | 'offline'
+ animationState?: 'entering' | 'exiting'
+ animationProgress?: number
+ elevation: number
+ edgeLines?: THREE.LineSegments
+ sideMesh?: THREE.Mesh
+}
+
+export type CameraMode = 'focused' | 'overview'
+
+// Zone colors — ice/cyan theme
+export const ZONE_COLORS = [
+ 0x4ac8e8, // Cyan (primary)
+ 0x60a5fa, // Blue
+ 0x22d3d8, // Teal
+ 0x4ade80, // Green
+ 0xa78bfa, // Purple
+ 0xfbbf24, // Orange
+ 0xf472b6, // Pink
+ 0xa3e635, // Lime
+]
+
+// ============================================================================
+// EventBus interface (optional dependency injection)
+// ============================================================================
+
+export interface EventBus {
+ emit(event: string, ...args: unknown[]): void
+ on(event: string, handler: (...args: unknown[]) => void): void
+ off(event: string, handler: (...args: unknown[]) => void): void
+}
+
+// ============================================================================
+// WorkshopScene
+// ============================================================================
+
+export class WorkshopScene {
+ public scene: THREE.Scene
+ public camera: THREE.PerspectiveCamera
+ public renderer: THREE.WebGLRenderer
+ public controls: OrbitControls
+
+ // Multi-zone support
+ public zones: Map = new Map()
+ public hexGrid: HexGrid
+ private zoneColorIndex = 0
+
+ // Camera modes
+ public cameraMode: CameraMode = 'focused'
+ public focusedZoneId: string | null = null
+ private onCameraModeChange: ((mode: CameraMode) => void) | null = null
+ private onZoneElevationChange: ((sessionId: string, elevation: number) => void) | null = null
+
+ // Camera animation
+ private cameraTargetPos = new THREE.Vector3()
+ private cameraTargetLookAt = new THREE.Vector3()
+ private cameraAnimating = false
+ private readonly cameraLerpSpeed = 8
+
+ // Legacy single-zone compat (points to first zone)
+ public stations: Map = new Map()
+
+ private container: HTMLElement
+ private eventBus: EventBus | null
+ private animationId: number | null = null
+ private onRenderCallbacks: Array<(delta: number) => void> = []
+ private clock = new THREE.Clock()
+
+ // Click pulse effects
+ private clickPulses: Array<{
+ mesh: THREE.Mesh | THREE.Line
+ age: number
+ maxAge: number
+ type?: 'ring' | 'hex' | 'ripple'
+ delay?: number
+ startOpacity?: number
+ baseOpacity?: number
+ highlightColor?: THREE.Color
+ baseColor?: THREE.Color
+ }> = []
+
+ // Station glow pulses
+ private stationPulses: Array<{
+ ring: THREE.Mesh
+ age: number
+ maxAge: number
+ baseOpacity: number
+ peakOpacity: number
+ }> = []
+
+ // Zone notification system
+ public zoneNotifications: ZoneNotifications
+
+ // Station info panels
+ public stationPanels: StationPanels
+
+ // Spawn beam effects
+ public spawnBeams: SpawnBeamManager
+
+ // Ambient floating particles
+ private ambientParticles: THREE.Points | null = null
+ private ambientParticleData: Array<{
+ baseY: number
+ phase: number
+ speed: number
+ radius: number
+ angle: number
+ }> = []
+
+ // Time accumulator for animations
+ private time = 0
+
+ // World hex grid overlay
+ private worldHexGrid: THREE.Group | THREE.LineSegments | null = null
+
+ // Hover highlight
+ private hoverHighlight: THREE.Line | null = null
+ private hoverRaycaster = new THREE.Raycaster()
+ private hoverMouse = new THREE.Vector2()
+ private lastHoveredHex: { q: number; r: number } | null = null
+
+ // World grid size (number of hex rings from center)
+ private gridRange = 20
+
+ // World floor for click detection
+ public worldFloor: THREE.Mesh | null = null
+
+ // Floating notifications (legacy)
+ private notifications: Array<{
+ sprite: THREE.Sprite
+ startY: number
+ age: number
+ maxAge: number
+ }> = []
+
+ constructor(container: HTMLElement, eventBus?: EventBus) {
+ this.container = container
+ this.eventBus = eventBus ?? null
+
+ // Scene — dark blue-black like ice cave
+ this.scene = new THREE.Scene()
+ this.scene.background = new THREE.Color(0x080c14)
+
+ // Hex grid for zone placement
+ this.hexGrid = new HexGrid(10, 1.0)
+
+ // Camera
+ this.camera = new THREE.PerspectiveCamera(
+ 50,
+ container.clientWidth / container.clientHeight,
+ 0.1,
+ 500,
+ )
+ const isMobile = window.innerWidth <= 640
+ this.camera.position.set(isMobile ? 40 : 8, isMobile ? 32 : 6, isMobile ? 40 : 8)
+ this.camera.lookAt(0, 0, 0)
+
+ // Renderer
+ this.renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ alpha: false,
+ powerPreference: 'high-performance',
+ })
+ this.renderer.setSize(container.clientWidth, container.clientHeight)
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))
+ this.renderer.shadowMap.enabled = true
+ this.renderer.shadowMap.type = THREE.BasicShadowMap
+ container.appendChild(this.renderer.domElement)
+
+ // Controls
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement)
+ this.controls.enableDamping = true
+ this.controls.dampingFactor = 0.05
+ this.controls.maxPolarAngle = Math.PI / 2.1
+ this.controls.minDistance = 5
+ this.controls.maxDistance = 150
+ this.controls.target.set(0, 0, 0)
+
+ // Stop camera animation when user manually drags
+ this.controls.addEventListener('start', () => {
+ this.cameraAnimating = false
+ })
+
+ // Build the world
+ this.setupLighting()
+ this.createWorldFloor()
+ this.createWorldHexGrid()
+ this.createAmbientParticles()
+ this.setupHoverHighlight()
+
+ // Subsystems
+ this.zoneNotifications = new ZoneNotifications(this.scene)
+ this.stationPanels = new StationPanels(this.scene)
+ this.spawnBeams = new SpawnBeamManager(this.scene)
+
+ // Resize
+ window.addEventListener('resize', this.handleResize)
+ }
+
+ // ==========================================================================
+ // Lighting
+ // ==========================================================================
+
+ private setupLighting(): void {
+ // Ambient
+ const ambient = new THREE.AmbientLight(0x606080, 0.8)
+ this.scene.add(ambient)
+
+ // Directional (sun)
+ const sun = new THREE.DirectionalLight(0xfff5e6, 1.2)
+ sun.position.set(5, 10, 5)
+ sun.castShadow = true
+ sun.shadow.mapSize.width = 512
+ sun.shadow.mapSize.height = 512
+ sun.shadow.camera.near = 1
+ sun.shadow.camera.far = 20
+ sun.shadow.camera.left = -8
+ sun.shadow.camera.right = 8
+ sun.shadow.camera.top = 8
+ sun.shadow.camera.bottom = -8
+ this.scene.add(sun)
+
+ // Hemisphere fill
+ const hemi = new THREE.HemisphereLight(0xfff5e6, 0x404060, 0.4)
+ this.scene.add(hemi)
+ }
+
+ // ==========================================================================
+ // World floor (invisible, for raycasting)
+ // ==========================================================================
+
+ private createWorldFloor(): void {
+ const geo = new THREE.PlaneGeometry(500, 500)
+ const mat = new THREE.MeshBasicMaterial({ visible: false })
+ const floor = new THREE.Mesh(geo, mat)
+ floor.rotation.x = -Math.PI / 2
+ floor.position.y = -0.05
+ floor.name = 'worldFloor'
+ this.scene.add(floor)
+ this.worldFloor = floor
+ }
+
+ // ==========================================================================
+ // World hex grid overlay (single merged LineSegments for performance)
+ // ==========================================================================
+
+ private createWorldHexGrid(): void {
+ const hexRadius = this.hexGrid.hexRadius
+ const gridRange = this.gridRange
+ const vertices: number[] = []
+
+ // Precompute corner angles (pointy-top)
+ const angles: number[] = []
+ for (let i = 0; i < 6; i++) {
+ angles.push((Math.PI / 3) * i - Math.PI / 2)
+ }
+
+ for (let q = -gridRange; q <= gridRange; q++) {
+ for (let r = -gridRange; r <= gridRange; r++) {
+ if (Math.abs(q) + Math.abs(r) + Math.abs(-q - r) > gridRange * 2) continue
+ const { x, z } = this.hexGrid.axialToCartesian({ q, r })
+
+ for (let i = 0; i < 6; i++) {
+ const sa = angles[i]
+ const ea = angles[(i + 1) % 6]
+ vertices.push(x + hexRadius * Math.cos(sa), 0, z + hexRadius * Math.sin(sa))
+ vertices.push(x + hexRadius * Math.cos(ea), 0, z + hexRadius * Math.sin(ea))
+ }
+ }
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
+
+ const mat = new THREE.LineBasicMaterial({
+ color: 0x4ac8e8,
+ transparent: true,
+ opacity: 0.35,
+ })
+
+ const lines = new THREE.LineSegments(geo, mat)
+ lines.position.y = 0.01
+ this.scene.add(lines)
+ this.worldHexGrid = lines
+ }
+
+ // ==========================================================================
+ // Hover highlight
+ // ==========================================================================
+
+ private setupHoverHighlight(): void {
+ const hexRadius = this.hexGrid.hexRadius
+ const pts: THREE.Vector3[] = []
+ for (let i = 0; i <= 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ pts.push(new THREE.Vector3(hexRadius * Math.cos(angle), 0.03, hexRadius * Math.sin(angle)))
+ }
+
+ const geo = new THREE.BufferGeometry().setFromPoints(pts)
+ const mat = new THREE.LineBasicMaterial({ color: 0x8eeeff, transparent: true, opacity: 0.7 })
+
+ this.hoverHighlight = new THREE.Line(geo, mat)
+ this.hoverHighlight.visible = false
+ this.scene.add(this.hoverHighlight)
+
+ this.renderer.domElement.addEventListener('mousemove', this.handleHover)
+ this.renderer.domElement.addEventListener('mouseleave', this.handleHoverLeave)
+ }
+
+ private handleHover = (event: MouseEvent): void => {
+ if (!this.hoverHighlight || !this.worldFloor) return
+
+ const rect = this.renderer.domElement.getBoundingClientRect()
+ this.hoverMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+ this.hoverMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+
+ this.hoverRaycaster.setFromCamera(this.hoverMouse, this.camera)
+ const intersects = this.hoverRaycaster.intersectObject(this.worldFloor)
+
+ if (intersects.length > 0) {
+ const point = intersects[0].point
+ const hexCoord = this.hexGrid.cartesianToHex(point.x, point.z)
+ const hexCenter = this.hexGrid.axialToCartesian(hexCoord)
+
+ const isNewHex =
+ !this.lastHoveredHex ||
+ this.lastHoveredHex.q !== hexCoord.q ||
+ this.lastHoveredHex.r !== hexCoord.r
+
+ if (isNewHex) {
+ this.lastHoveredHex = { q: hexCoord.q, r: hexCoord.r }
+ }
+
+ this.hoverHighlight.position.set(hexCenter.x, 0, hexCenter.z)
+ this.hoverHighlight.visible = true
+ } else {
+ this.hoverHighlight.visible = false
+ this.lastHoveredHex = null
+ }
+ }
+
+ private handleHoverLeave = (): void => {
+ if (this.hoverHighlight) this.hoverHighlight.visible = false
+ this.lastHoveredHex = null
+ }
+
+ // ==========================================================================
+ // Ambient particles
+ // ==========================================================================
+
+ private createAmbientParticles(): void {
+ const count = 60
+ const positions = new Float32Array(count * 3)
+
+ for (let i = 0; i < count; i++) {
+ const radius = 2 + Math.random() * 15
+ const angle = Math.random() * Math.PI * 2
+ const baseY = 6 + Math.random() * 12
+
+ positions[i * 3] = Math.cos(angle) * radius
+ positions[i * 3 + 1] = baseY
+ positions[i * 3 + 2] = Math.sin(angle) * radius
+
+ this.ambientParticleData.push({
+ baseY,
+ phase: Math.random() * Math.PI * 2,
+ speed: 0.3 + Math.random() * 0.5,
+ radius,
+ angle,
+ })
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+
+ const mat = new THREE.PointsMaterial({
+ color: 0x4ac8e8,
+ size: 0.12,
+ transparent: true,
+ opacity: 0.4,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ })
+
+ this.ambientParticles = new THREE.Points(geo, mat)
+ this.scene.add(this.ambientParticles)
+ }
+
+ private updateAmbientParticles(delta: number): void {
+ if (!this.ambientParticles) return
+
+ const positions = this.ambientParticles.geometry.attributes.position.array as Float32Array
+
+ for (let i = 0; i < this.ambientParticleData.length; i++) {
+ const d = this.ambientParticleData[i]
+ d.angle += delta * 0.02 * d.speed
+ const yOff = Math.sin(this.time * d.speed + d.phase) * 1.5
+
+ positions[i * 3] = Math.cos(d.angle) * d.radius
+ positions[i * 3 + 1] = d.baseY + yOff
+ positions[i * 3 + 2] = Math.sin(d.angle) * d.radius
+ }
+
+ this.ambientParticles.geometry.attributes.position.needsUpdate = true
+ }
+
+ // ==========================================================================
+ // Zone creation
+ // ==========================================================================
+
+ createZone(
+ sessionId: string,
+ options?: { color?: number; hintPosition?: { x: number; z: number } },
+ ): Zone {
+ const existing = this.zones.get(sessionId)
+ if (existing) return existing
+
+ // Hex placement
+ const hexCoord = options?.hintPosition
+ ? this.hexGrid.findNearestFree(this.hexGrid.cartesianToHex(options.hintPosition.x, options.hintPosition.z))
+ : this.hexGrid.getNextInSpiral()
+
+ this.hexGrid.occupy(hexCoord, sessionId)
+
+ const zoneColor = options?.color ?? ZONE_COLORS[this.zoneColorIndex++ % ZONE_COLORS.length]
+ const { x, z } = this.hexGrid.axialToCartesian(hexCoord)
+ const position = new THREE.Vector3(x, 0, z)
+
+ // Group
+ const group = new THREE.Group()
+ group.position.copy(position)
+ this.scene.add(group)
+ group.updateMatrixWorld(true)
+
+ // Platform, ring, floor
+ const { platform, ring, floor } = this.createZonePlatform(group, zoneColor)
+
+ // Stations
+ const stations = this.createZoneStations(group, zoneColor)
+
+ // Floating label
+ const label = this.createZoneLabel(sessionId, zoneColor)
+ label.position.set(0, 4, 0)
+ group.add(label)
+
+ // Particle system
+ const { particles, velocities } = this.createParticleSystem(zoneColor)
+ group.add(particles)
+
+ // Edge lines (hidden until elevated)
+ const edgeLines = this.createZoneEdgeLines(zoneColor)
+ edgeLines.visible = false
+ this.scene.add(edgeLines)
+ edgeLines.position.copy(position)
+
+ // Side mesh (hidden until elevated)
+ const sideMesh = this.createZoneSideMesh(zoneColor)
+ sideMesh.visible = false
+ this.scene.add(sideMesh)
+ sideMesh.position.copy(position)
+
+ const zone: Zone = {
+ id: sessionId,
+ group,
+ stations,
+ platform,
+ ring,
+ floor,
+ color: zoneColor,
+ position,
+ label,
+ pulseIntensity: 0,
+ attentionReason: null,
+ attentionTime: 0,
+ particles,
+ particleVelocities: velocities,
+ status: 'idle',
+ animationState: 'entering',
+ animationProgress: 0,
+ elevation: 0,
+ edgeLines,
+ sideMesh,
+ }
+
+ // Start at scale 0 for enter animation
+ group.scale.setScalar(0.01)
+ for (const station of stations.values()) station.mesh.visible = false
+ if (label) label.visible = false
+ particles.visible = false
+
+ this.zones.set(sessionId, zone)
+
+ // Register with subsystems
+ this.zoneNotifications.registerZone(sessionId, position)
+ this.stationPanels.createPanelsForZone(sessionId, position, zoneColor)
+
+ // Legacy compat: first zone's stations become default
+ if (this.zones.size === 1) {
+ this.stations = stations
+ this.focusZone(sessionId)
+ }
+
+ return zone
+ }
+
+ getZone(sessionId: string): Zone | undefined {
+ return this.zones.get(sessionId)
+ }
+
+ getZoneWorldPosition(sessionId: string): { x: number; z: number } | null {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return null
+ return { x: zone.position.x, z: zone.position.z }
+ }
+
+ getZoneHexPosition(sessionId: string): { q: number; r: number } | null {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return null
+ return this.hexGrid.cartesianToHex(zone.position.x, zone.position.z)
+ }
+
+ getZoneByIndex(index: number): Zone | undefined {
+ return Array.from(this.zones.values())[index]
+ }
+
+ getZoneAtHex(hex: { q: number; r: number }): Zone | null {
+ const sessionId = this.hexGrid.getOccupant(hex)
+ if (!sessionId) return null
+ return this.zones.get(sessionId) ?? null
+ }
+
+ // ==========================================================================
+ // Zone deletion
+ // ==========================================================================
+
+ deleteZone(sessionId: string): boolean {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return false
+ if (zone.animationState === 'exiting') return true
+
+ zone.animationState = 'exiting'
+ zone.animationProgress = 0
+ this.hexGrid.release(sessionId)
+ return true
+ }
+
+ private finalizeZoneDelete(sessionId: string): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ this.zoneNotifications.unregisterZone(sessionId)
+ this.stationPanels.removePanelsForZone(sessionId)
+
+ this.scene.remove(zone.group)
+ zone.group.traverse((obj) => {
+ if (obj instanceof THREE.Mesh) {
+ obj.geometry?.dispose()
+ if (Array.isArray(obj.material)) {
+ obj.material.forEach((m) => m.dispose())
+ } else if (obj.material) {
+ obj.material.dispose()
+ }
+ } else if (obj instanceof THREE.Sprite) {
+ obj.material.map?.dispose()
+ obj.material.dispose()
+ } else if (obj instanceof THREE.Points) {
+ obj.geometry?.dispose()
+ ;(obj.material as THREE.PointsMaterial)?.dispose()
+ }
+ })
+
+ if (zone.label) {
+ const mat = zone.label.material as THREE.SpriteMaterial
+ mat.map?.dispose()
+ mat.dispose()
+ }
+
+ for (const station of zone.stations.values()) {
+ if (station.contextSprite) {
+ station.contextSprite.material.map?.dispose()
+ station.contextSprite.material.dispose()
+ }
+ }
+
+ if (zone.edgeLines) {
+ this.scene.remove(zone.edgeLines)
+ zone.edgeLines.geometry.dispose()
+ ;(zone.edgeLines.material as THREE.LineBasicMaterial).dispose()
+ }
+
+ if (zone.sideMesh) {
+ this.scene.remove(zone.sideMesh)
+ zone.sideMesh.geometry.dispose()
+ ;(zone.sideMesh.material as THREE.MeshStandardMaterial).dispose()
+ }
+
+ this.zones.delete(sessionId)
+
+ if (this.focusedZoneId === sessionId) {
+ this.focusedZoneId = null
+ }
+ }
+
+ // ==========================================================================
+ // Camera
+ // ==========================================================================
+
+ focusZone(sessionId: string, animate = true): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ this.focusedZoneId = sessionId
+ this.cameraMode = 'focused'
+
+ const target = zone.position.clone()
+ target.y += zone.elevation
+
+ const isMobile = window.innerWidth <= 640
+ const offset = isMobile ? new THREE.Vector3(40, 32, 40) : new THREE.Vector3(8, 6, 8)
+ const cameraPos = target.clone().add(offset)
+
+ if (animate) {
+ this.animateCameraTo(cameraPos, target)
+ } else {
+ this.controls.target.copy(target)
+ this.camera.position.copy(cameraPos)
+ }
+
+ this.notifyCameraModeChange()
+ }
+
+ setOverviewMode(): void {
+ this.cameraMode = 'overview'
+ this.focusedZoneId = null
+
+ if (this.zones.size === 0) return
+
+ let minX = Infinity,
+ maxX = -Infinity,
+ minZ = Infinity,
+ maxZ = -Infinity
+
+ for (const zone of this.zones.values()) {
+ minX = Math.min(minX, zone.position.x - 10)
+ maxX = Math.max(maxX, zone.position.x + 10)
+ minZ = Math.min(minZ, zone.position.z - 10)
+ maxZ = Math.max(maxZ, zone.position.z + 10)
+ }
+
+ const cx = (minX + maxX) / 2
+ const cz = (minZ + maxZ) / 2
+ const extent = Math.max(maxX - minX, maxZ - minZ, 30)
+
+ const isMobile = window.innerWidth <= 640
+ const hMul = isMobile ? 1.0 : 0.8
+ const height = extent * hMul
+ const lookAt = new THREE.Vector3(cx, 0, cz)
+ const pos = new THREE.Vector3(cx, height, cz + extent * (isMobile ? 0.2 : 0.3))
+
+ this.animateCameraTo(pos, lookAt)
+ this.notifyCameraModeChange()
+ }
+
+ private animateCameraTo(position: THREE.Vector3, lookAt: THREE.Vector3): void {
+ this.cameraTargetPos.copy(position)
+ this.cameraTargetLookAt.copy(lookAt)
+ this.cameraAnimating = true
+ }
+
+ onCameraMode(callback: (mode: CameraMode) => void): void {
+ this.onCameraModeChange = callback
+ }
+
+ private notifyCameraModeChange(): void {
+ this.onCameraModeChange?.(this.cameraMode)
+ }
+
+ // ==========================================================================
+ // Zone elevation
+ // ==========================================================================
+
+ onZoneElevation(callback: (sessionId: string, elevation: number) => void): void {
+ this.onZoneElevationChange = callback
+ }
+
+ private notifyZoneElevationChange(sessionId: string, elevation: number): void {
+ this.updateStationPositions(sessionId)
+ this.zoneNotifications.updateZoneElevation(sessionId, elevation)
+ this.onZoneElevationChange?.(sessionId, elevation)
+ }
+
+ private updateStationPositions(sessionId: string): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ const origScale = zone.group.scale.clone()
+ zone.group.scale.setScalar(1)
+ zone.group.updateMatrixWorld(true)
+
+ for (const station of zone.stations.values()) {
+ const worldPos = station.localPosition.clone()
+ zone.group.localToWorld(worldPos)
+ station.position.copy(worldPos)
+ }
+
+ zone.group.scale.copy(origScale)
+ zone.group.updateMatrixWorld(true)
+ }
+
+ setZoneElevation(sessionId: string, elevation: number): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ zone.elevation = elevation
+ zone.group.position.y = elevation
+ this.updateZoneEdgeLines(zone)
+ this.updateZoneSideMesh(zone)
+ this.notifyZoneElevationChange(sessionId, elevation)
+ }
+
+ // ==========================================================================
+ // Zone platform / floor
+ // ==========================================================================
+
+ private createHexagonShape(radius: number): THREE.Shape {
+ const shape = new THREE.Shape()
+ for (let i = 0; i < 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ const x = radius * Math.cos(angle)
+ const y = radius * Math.sin(angle)
+ if (i === 0) shape.moveTo(x, y)
+ else shape.lineTo(x, y)
+ }
+ shape.closePath()
+ return shape
+ }
+
+ private createZonePlatform(
+ group: THREE.Group,
+ color: number,
+ ): { platform: THREE.Mesh; ring: THREE.Mesh; floor: THREE.Mesh } {
+ const hexRadius = 10
+
+ // Floor
+ const floorShape = this.createHexagonShape(hexRadius)
+ const floorGeo = new THREE.ShapeGeometry(floorShape)
+ const floorMat = new THREE.MeshStandardMaterial({
+ color: 0x1a2535,
+ roughness: 0.7,
+ metalness: 0.15,
+ emissive: color,
+ emissiveIntensity: 0.02,
+ })
+ const floor = new THREE.Mesh(floorGeo, floorMat)
+ floor.rotation.x = -Math.PI / 2
+ floor.receiveShadow = true
+ group.add(floor)
+
+ // Ring
+ const outerShape = this.createHexagonShape(hexRadius)
+ const innerShape = this.createHexagonShape(hexRadius - 0.5)
+ outerShape.holes.push(innerShape as unknown as THREE.Path)
+ const ringGeo = new THREE.ShapeGeometry(outerShape)
+ const ringMat = new THREE.MeshBasicMaterial({
+ color,
+ transparent: true,
+ opacity: 0.5,
+ side: THREE.DoubleSide,
+ })
+ const ring = new THREE.Mesh(ringGeo, ringMat)
+ ring.rotation.x = -Math.PI / 2
+ ring.position.y = 0.02
+ group.add(ring)
+
+ // Center platform (pedestal)
+ const platformGeo = new THREE.CylinderGeometry(1, 1.2, 0.2, 6)
+ const platformMat = new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.5,
+ metalness: 0.3,
+ emissive: color,
+ emissiveIntensity: 0.1,
+ })
+ const platform = new THREE.Mesh(platformGeo, platformMat)
+ platform.position.y = 0.1
+ platform.rotation.y = Math.PI / 6
+ platform.receiveShadow = true
+ platform.castShadow = true
+ group.add(platform)
+
+ return { platform, ring, floor }
+ }
+
+ // ==========================================================================
+ // Zone edge lines + side mesh (shown when elevated)
+ // ==========================================================================
+
+ private createZoneEdgeLines(color: number): THREE.LineSegments {
+ const hexRadius = 10
+ const positions: number[] = []
+
+ for (let i = 0; i < 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ const x = hexRadius * Math.cos(angle)
+ const z = hexRadius * Math.sin(angle)
+ positions.push(x, 0, z, x, 1, z)
+ }
+
+ for (let i = 0; i < 6; i++) {
+ const a1 = (Math.PI / 3) * i - Math.PI / 2
+ const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2
+ positions.push(
+ hexRadius * Math.cos(a1), 1, hexRadius * Math.sin(a1),
+ hexRadius * Math.cos(a2), 1, hexRadius * Math.sin(a2),
+ )
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
+
+ const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, linewidth: 2 })
+ return new THREE.LineSegments(geo, mat)
+ }
+
+ private createZoneSideMesh(color: number): THREE.Mesh {
+ const hexRadius = 10
+ const vertexCount = 6 * 6
+ const positions = new Float32Array(vertexCount * 3)
+ const normals = new Float32Array(vertexCount * 3)
+
+ let idx = 0
+ for (let i = 0; i < 6; i++) {
+ const a1 = (Math.PI / 3) * i - Math.PI / 2
+ const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2
+ const x1 = hexRadius * Math.cos(a1), z1 = hexRadius * Math.sin(a1)
+ const x2 = hexRadius * Math.cos(a2), z2 = hexRadius * Math.sin(a2)
+ const midAngle = (a1 + a2) / 2
+ const nx = Math.cos(midAngle), nz = Math.sin(midAngle)
+
+ // Triangle 1
+ positions[idx * 3] = x1; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z1
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+ positions[idx * 3] = x2; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z2
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+ positions[idx * 3] = x2; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z2
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+
+ // Triangle 2
+ positions[idx * 3] = x1; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z1
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+ positions[idx * 3] = x2; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z2
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+ positions[idx * 3] = x1; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z1
+ normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+ geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3))
+
+ const mat = new THREE.MeshStandardMaterial({
+ color,
+ transparent: true,
+ opacity: 0.7,
+ side: THREE.DoubleSide,
+ })
+
+ return new THREE.Mesh(geo, mat)
+ }
+
+ updateZoneEdgeLines(zone: Zone): void {
+ if (!zone.edgeLines) return
+ const el = zone.elevation
+ if (el <= 0) { zone.edgeLines.visible = false; return }
+
+ zone.edgeLines.visible = true
+ const pos = zone.edgeLines.geometry.attributes.position as THREE.BufferAttribute
+ const hexRadius = 10
+ let idx = 0
+
+ for (let i = 0; i < 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ const x = hexRadius * Math.cos(angle)
+ const z = hexRadius * Math.sin(angle)
+ pos.setXYZ(idx++, x, 0, z)
+ pos.setXYZ(idx++, x, el, z)
+ }
+
+ for (let i = 0; i < 6; i++) {
+ const a1 = (Math.PI / 3) * i - Math.PI / 2
+ const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2
+ pos.setXYZ(idx++, hexRadius * Math.cos(a1), el, hexRadius * Math.sin(a1))
+ pos.setXYZ(idx++, hexRadius * Math.cos(a2), el, hexRadius * Math.sin(a2))
+ }
+
+ pos.needsUpdate = true
+ }
+
+ updateZoneSideMesh(zone: Zone): void {
+ if (!zone.sideMesh) return
+ const el = zone.elevation
+ if (el <= 0) { zone.sideMesh.visible = false; return }
+
+ zone.sideMesh.visible = true
+ const pos = zone.sideMesh.geometry.attributes.position as THREE.BufferAttribute
+ const hexRadius = 10
+ let idx = 0
+
+ for (let i = 0; i < 6; i++) {
+ const a1 = (Math.PI / 3) * i - Math.PI / 2
+ const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2
+ const x1 = hexRadius * Math.cos(a1), z1 = hexRadius * Math.sin(a1)
+ const x2 = hexRadius * Math.cos(a2), z2 = hexRadius * Math.sin(a2)
+
+ pos.setXYZ(idx++, x1, 0, z1)
+ pos.setXYZ(idx++, x2, 0, z2)
+ pos.setXYZ(idx++, x2, el, z2)
+
+ pos.setXYZ(idx++, x1, 0, z1)
+ pos.setXYZ(idx++, x2, el, z2)
+ pos.setXYZ(idx++, x1, el, z1)
+ }
+
+ pos.needsUpdate = true
+ }
+
+ // ==========================================================================
+ // Stations
+ // ==========================================================================
+
+ private createZoneStations(group: THREE.Group, zoneColor: number): Map {
+ const stations = new Map()
+
+ const configs: Array<{
+ type: StationType
+ position: [number, number, number]
+ label: string
+ color: number
+ }> = [
+ { type: 'center', position: [0, 0, 0], label: 'Center', color: zoneColor },
+ { type: 'bookshelf', position: [0, 0, -4], label: 'Library', color: 0x2a4a5a },
+ { type: 'desk', position: [4, 0, 0], label: 'Desk', color: 0x3a4a5a },
+ { type: 'workbench', position: [-4, 0, 0], label: 'Workbench', color: 0x3a4a55 },
+ { type: 'terminal', position: [0, 0, 4], label: 'Terminal', color: 0x1a2a3a },
+ { type: 'scanner', position: [3, 0, -3], label: 'Scanner', color: 0x2a4a6a },
+ { type: 'antenna', position: [-3, 0, -3], label: 'Antenna', color: 0x3a5a6a },
+ { type: 'portal', position: [-3, 0, 3], label: 'Portal', color: 0x3a4a6a },
+ { type: 'taskboard', position: [3, 0, 3], label: 'Task Board', color: 0x3a4a5a },
+ ]
+
+ for (const cfg of configs) {
+ stations.set(cfg.type, this.createStationInZone(group, cfg))
+ }
+
+ return stations
+ }
+
+ private createStationInZone(
+ zoneGroup: THREE.Group,
+ config: { type: StationType; position: [number, number, number]; label: string; color: number },
+ ): Station {
+ const stationGroup = new THREE.Group()
+ const [x, y, z] = config.position
+
+ if (config.type === 'center') {
+ stationGroup.position.set(x, y, z)
+ zoneGroup.add(stationGroup)
+
+ const localPos = new THREE.Vector3(x, 0.3, z)
+ const worldPos = localPos.clone()
+ zoneGroup.localToWorld(worldPos)
+
+ return { type: config.type, position: worldPos, localPosition: localPos, mesh: stationGroup, label: config.label }
+ }
+
+ // Base/table
+ const baseGeo = new THREE.BoxGeometry(1.5, 0.8, 1)
+ const baseMat = new THREE.MeshStandardMaterial({ color: config.color, roughness: 0.7, metalness: 0.2 })
+ const base = new THREE.Mesh(baseGeo, baseMat)
+ base.position.y = 0.4
+ base.castShadow = true
+ base.receiveShadow = true
+ stationGroup.add(base)
+
+ // Station-specific details
+ switch (config.type) {
+ case 'bookshelf': addBookshelfDetails(stationGroup); break
+ case 'desk': addDeskDetails(stationGroup); break
+ case 'workbench': addWorkbenchDetails(stationGroup); break
+ case 'terminal': addTerminalDetails(stationGroup); break
+ case 'antenna': addAntennaDetails(stationGroup); break
+ case 'portal': addPortalDetails(stationGroup); break
+ case 'scanner': addScannerDetails(stationGroup); break
+ case 'taskboard': addTaskboardDetails(stationGroup); break
+ }
+
+ // Station indicator ring
+ const ringGeo = new THREE.RingGeometry(0.9, 1, 32)
+ const ringMat = new THREE.MeshBasicMaterial({ color: config.color, transparent: true, opacity: 0.3, side: THREE.DoubleSide })
+ const ring = new THREE.Mesh(ringGeo, ringMat)
+ ring.rotation.x = -Math.PI / 2
+ ring.position.y = 0.02
+ stationGroup.add(ring)
+
+ stationGroup.position.set(x, y, z)
+ zoneGroup.add(stationGroup)
+
+ // Calculate world position for Claude to stand
+ const localPos = new THREE.Vector3(x, 0.3, z)
+ const toCenter = new THREE.Vector3(-x, 0, -z).normalize()
+ localPos.add(toCenter.multiplyScalar(1.2))
+ const worldPos = localPos.clone()
+ zoneGroup.localToWorld(worldPos)
+
+ return { type: config.type, position: worldPos, localPosition: localPos, mesh: stationGroup, label: config.label }
+ }
+
+ // ==========================================================================
+ // Zone labels
+ // ==========================================================================
+
+ private createZoneLabel(sessionId: string, color: number): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+ canvas.width = 512
+ canvas.height = 96
+
+ this.drawLabelText(ctx, canvas.width, canvas.height, color, sessionId.slice(0, 8))
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.minFilter = THREE.LinearFilter
+ texture.magFilter = THREE.LinearFilter
+
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
+ const sprite = new THREE.Sprite(mat)
+ sprite.scale.set(5, 1.2, 1)
+ return sprite
+ }
+
+ private drawLabelText(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ color: number,
+ text: string,
+ keybind?: string,
+ ): void {
+ const colorHex = `#${color.toString(16).padStart(6, '0')}`
+ ctx.clearRect(0, 0, width, height)
+
+ ctx.font = '600 36px system-ui, -apple-system, sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ // Truncate if needed
+ let display = text
+ const maxW = width - 80
+ let tw = ctx.measureText(display).width
+ if (tw > maxW) {
+ while (tw > maxW && display.length > 3) {
+ display = display.slice(0, -1)
+ tw = ctx.measureText(display + '\u2026').width
+ }
+ display += '\u2026'
+ }
+
+ const full = keybind ? `${keybind} ${display}` : display
+ const cx = width / 2
+ const cy = height / 2
+
+ // Dark backdrop
+ ctx.save()
+ ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 12
+ ctx.fillStyle = 'rgba(0,0,0,0.8)'
+ ctx.fillText(full, cx, cy); ctx.fillText(full, cx, cy)
+ ctx.restore()
+
+ // Outer glow
+ ctx.save()
+ ctx.shadowColor = colorHex; ctx.shadowBlur = 30
+ ctx.fillStyle = colorHex; ctx.globalAlpha = 0.3
+ ctx.fillText(full, cx, cy); ctx.fillText(full, cx, cy)
+ ctx.restore()
+
+ // Middle glow
+ ctx.save()
+ ctx.shadowColor = colorHex; ctx.shadowBlur = 12
+ ctx.fillStyle = colorHex; ctx.globalAlpha = 0.5
+ ctx.fillText(full, cx, cy)
+ ctx.restore()
+
+ // Inner glow
+ ctx.save()
+ ctx.shadowColor = colorHex; ctx.shadowBlur = 4
+ ctx.fillStyle = colorHex; ctx.globalAlpha = 0.8
+ ctx.fillText(full, cx, cy)
+ ctx.restore()
+
+ // Crisp white text
+ ctx.fillStyle = '#ffffff'
+ ctx.fillText(full, cx, cy)
+ }
+
+ updateZoneLabel(sessionId: string, newLabel: string, keybind?: string): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone?.label) return
+
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+ canvas.width = 512
+ canvas.height = 96
+
+ this.drawLabelText(ctx, canvas.width, canvas.height, zone.color, newLabel, keybind)
+
+ const mat = zone.label.material as THREE.SpriteMaterial
+ mat.map?.dispose()
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.minFilter = THREE.LinearFilter
+ texture.magFilter = THREE.LinearFilter
+ mat.map = texture
+ mat.needsUpdate = true
+ }
+
+ // ==========================================================================
+ // Station context text
+ // ==========================================================================
+
+ private createTextSprite(text: string, color = '#ffffff'): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+ canvas.width = 512
+ canvas.height = 96
+ const maxW = canvas.width - 60
+
+ let fontSize = 28
+ const minFontSize = 14
+ ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`
+ while (ctx.measureText(text).width > maxW && fontSize > minFontSize) {
+ fontSize -= 2
+ ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`
+ }
+
+ let display = text
+ if (ctx.measureText(text).width > maxW) {
+ if (text.includes('/')) {
+ const parts = text.split('/')
+ display = parts.length >= 2 ? '.../' + parts.slice(-2).join('/') : '.../' + parts.pop()!
+ }
+ if (ctx.measureText(display).width > maxW) {
+ const mc = Math.floor(maxW / (fontSize * 0.6))
+ const half = Math.floor((mc - 3) / 2)
+ display = text.slice(0, half) + '...' + text.slice(-half)
+ }
+ }
+
+ const cx = canvas.width / 2
+ const cy = canvas.height / 2
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ // Dark backdrop
+ ctx.save()
+ ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 10
+ ctx.fillStyle = 'rgba(0,0,0,0.8)'
+ ctx.fillText(display, cx, cy); ctx.fillText(display, cx, cy)
+ ctx.restore()
+
+ // Outer glow
+ ctx.save()
+ ctx.shadowColor = color; ctx.shadowBlur = 20
+ ctx.fillStyle = color; ctx.globalAlpha = 0.4
+ ctx.fillText(display, cx, cy); ctx.fillText(display, cx, cy)
+ ctx.restore()
+
+ // Inner glow
+ ctx.save()
+ ctx.shadowColor = color; ctx.shadowBlur = 6
+ ctx.fillStyle = color; ctx.globalAlpha = 0.7
+ ctx.fillText(display, cx, cy)
+ ctx.restore()
+
+ // Main text
+ ctx.fillStyle = '#ffffff'
+ ctx.fillText(display, cx, cy)
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.minFilter = THREE.LinearFilter
+ texture.magFilter = THREE.LinearFilter
+
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
+ const sprite = new THREE.Sprite(mat)
+ sprite.scale.set(4, 0.8, 1)
+ return sprite
+ }
+
+ setStationContext(stationType: StationType, context: string | null, sessionId?: string): void {
+ let stations: Map
+ if (sessionId) {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+ stations = zone.stations
+ } else {
+ stations = this.stations
+ }
+
+ const station = stations.get(stationType)
+ if (!station) return
+
+ if (station.contextSprite) {
+ station.mesh.remove(station.contextSprite)
+ station.contextSprite.material.map?.dispose()
+ station.contextSprite.material.dispose()
+ station.contextSprite = undefined
+ }
+
+ if (context) {
+ const c = this.getStationColor(stationType)
+ station.contextSprite = this.createTextSprite(context, c)
+ station.contextSprite.position.set(0, 2.5, 0)
+ station.mesh.add(station.contextSprite)
+ }
+ }
+
+ clearAllContexts(sessionId?: string): void {
+ if (sessionId) {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+ for (const [type] of zone.stations) this.setStationContext(type, null, sessionId)
+ } else {
+ for (const [zoneId, zone] of this.zones) {
+ for (const [type] of zone.stations) this.setStationContext(type, null, zoneId)
+ }
+ }
+ }
+
+ private getStationColor(type: StationType): string {
+ const colors: Record = {
+ center: '#4ac8e8',
+ bookshelf: '#fbbf24',
+ desk: '#4ade80',
+ workbench: '#f97316',
+ terminal: '#22d3ee',
+ scanner: '#60a5fa',
+ antenna: '#4ac8e8',
+ portal: '#22d3d8',
+ taskboard: '#fb923c',
+ }
+ return colors[type] || '#ffffff'
+ }
+
+ // ==========================================================================
+ // Zone activity / attention
+ // ==========================================================================
+
+ pulseZone(sessionId: string): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+ zone.pulseIntensity = 1.0
+ this.emitParticles(zone)
+ }
+
+ setZoneAttention(sessionId: string, reason: AttentionReason): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+ zone.attentionReason = reason
+ zone.attentionTime = 0
+ }
+
+ clearZoneAttention(sessionId: string): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+ zone.attentionReason = null
+ zone.attentionTime = 0
+ zone.ring.scale.setScalar(1)
+ }
+
+ setZoneStatus(sessionId: string, status: Zone['status']): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ zone.status = status
+ const floorMat = zone.floor.material as THREE.MeshStandardMaterial
+ const ringMat = zone.ring.material as THREE.MeshBasicMaterial
+
+ const statusColors: Record = {
+ idle: { emissive: zone.color, intensity: 0.02, ring: zone.color, ringOpacity: 0.4 },
+ working: { emissive: 0x22d3ee, intensity: 0.08, ring: 0x22d3ee, ringOpacity: 0.5 },
+ waiting: { emissive: 0xfbbf24, intensity: 0.06, ring: 0xfbbf24, ringOpacity: 0.6 },
+ attention: { emissive: 0xf87171, intensity: 0.10, ring: 0xf87171, ringOpacity: 0.7 },
+ offline: { emissive: 0x404050, intensity: 0.01, ring: 0x404050, ringOpacity: 0.2 },
+ }
+
+ const c = statusColors[status]
+ floorMat.emissive.setHex(c.emissive)
+ floorMat.emissiveIntensity = c.intensity
+ ringMat.color.setHex(c.ring)
+ ringMat.opacity = c.ringOpacity
+ }
+
+ getZonesNeedingAttention(): { id: string; reason: AttentionReason }[] {
+ const result: { id: string; reason: AttentionReason }[] = []
+ for (const [id, zone] of this.zones) {
+ if (zone.attentionReason) result.push({ id, reason: zone.attentionReason })
+ }
+ return result
+ }
+
+ // ==========================================================================
+ // Station pulse
+ // ==========================================================================
+
+ pulseStation(sessionId: string, stationType: StationType): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ const station = zone.stations.get(stationType)
+ if (!station || stationType === 'center') return
+
+ let ring: THREE.Mesh | undefined
+ station.mesh.traverse((child) => {
+ if (child instanceof THREE.Mesh && child.geometry instanceof THREE.RingGeometry) ring = child
+ })
+ if (!ring) return
+
+ const ringMat = ring.material as THREE.MeshBasicMaterial
+ const baseOpacity = ringMat.opacity
+
+ if (this.stationPulses.some((p) => p.ring === ring)) return
+
+ this.stationPulses.push({
+ ring,
+ age: 0,
+ maxAge: 1.3,
+ baseOpacity,
+ peakOpacity: Math.min(1, baseOpacity + 0.5),
+ })
+ }
+
+ // ==========================================================================
+ // Particles
+ // ==========================================================================
+
+ private createParticleSystem(color: number): { particles: THREE.Points; velocities: Float32Array } {
+ const count = 20
+ const positions = new Float32Array(count * 3)
+ const velocities = new Float32Array(count * 3)
+
+ for (let i = 0; i < count; i++) {
+ positions[i * 3] = 0
+ positions[i * 3 + 1] = -1000
+ positions[i * 3 + 2] = 0
+ }
+
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+
+ const mat = new THREE.PointsMaterial({
+ color,
+ size: 0.15,
+ transparent: true,
+ opacity: 0.8,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ })
+
+ return { particles: new THREE.Points(geo, mat), velocities }
+ }
+
+ private emitParticles(zone: Zone): void {
+ const positions = zone.particles.geometry.attributes.position.array as Float32Array
+ const velocities = zone.particleVelocities
+ let activated = 0
+
+ for (let i = 0; i < positions.length / 3 && activated < 5; i++) {
+ if (positions[i * 3 + 1] < -5) {
+ positions[i * 3] = (Math.random() - 0.5) * 2
+ positions[i * 3 + 1] = 0.5
+ positions[i * 3 + 2] = (Math.random() - 0.5) * 2
+
+ velocities[i * 3] = (Math.random() - 0.5) * 2
+ velocities[i * 3 + 1] = 2 + Math.random() * 2
+ velocities[i * 3 + 2] = (Math.random() - 0.5) * 2
+ activated++
+ }
+ }
+
+ zone.particles.geometry.attributes.position.needsUpdate = true
+ }
+
+ // ==========================================================================
+ // Click pulses
+ // ==========================================================================
+
+ spawnClickPulse(x: number, z: number, color = 0x4ac8e8, y = 0.03): void {
+ const hexRadius = this.hexGrid.hexRadius
+ const clickedHex = this.hexGrid.cartesianToHex(x, z)
+
+ // Expanding ring
+ const ringGeo = new THREE.RingGeometry(0.2, 0.4, 32)
+ const ringMat = new THREE.MeshBasicMaterial({
+ color: 0x8eefff,
+ transparent: true,
+ opacity: 0.9,
+ side: THREE.DoubleSide,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ })
+ const ring = new THREE.Mesh(ringGeo, ringMat)
+ ring.rotation.x = -Math.PI / 2
+ ring.position.set(x, y, z)
+ this.scene.add(ring)
+
+ this.clickPulses.push({ mesh: ring, age: 0, maxAge: 0.5, type: 'ring' })
+
+ // Hex wave ripple
+ const spawnHexRing = (ringNum: number, strength: number) => {
+ const hexes = ringNum === 0 ? [clickedHex] : this.getHexRing(clickedHex, ringNum)
+
+ for (const hex of hexes) {
+ const center = this.hexGrid.axialToCartesian(hex)
+ const pts: THREE.Vector3[] = []
+ for (let i = 0; i <= 6; i++) {
+ const angle = (Math.PI / 3) * i - Math.PI / 2
+ pts.push(new THREE.Vector3(
+ center.x + hexRadius * Math.cos(angle),
+ 0.02,
+ center.z + hexRadius * Math.sin(angle),
+ ))
+ }
+
+ const geo = new THREE.BufferGeometry().setFromPoints(pts)
+ const mat = new THREE.LineBasicMaterial({
+ color: 0x8eefff,
+ transparent: true,
+ opacity: strength,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ })
+ const line = new THREE.Line(geo, mat)
+ this.scene.add(line)
+
+ this.clickPulses.push({
+ mesh: line,
+ age: 0,
+ maxAge: 0.5,
+ type: 'ripple',
+ startOpacity: strength,
+ })
+ }
+ }
+
+ const maxRings = 7
+ const msPerRing = 45
+
+ for (let r = 0; r <= maxRings; r++) {
+ const strength = Math.pow(0.6, r)
+ if (strength < 0.03) continue
+ if (r === 0) {
+ spawnHexRing(0, strength)
+ } else {
+ setTimeout(() => spawnHexRing(r, strength), r * msPerRing)
+ }
+ }
+ }
+
+ private getHexRing(center: { q: number; r: number }, ring: number): Array<{ q: number; r: number }> {
+ if (ring === 0) return [center]
+
+ const results: Array<{ q: number; r: number }> = []
+ const directions = [
+ { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 },
+ { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 },
+ ]
+
+ let hex = { q: center.q + ring, r: center.r }
+
+ for (let side = 0; side < 6; side++) {
+ for (let step = 0; step < ring; step++) {
+ results.push({ ...hex })
+ const dir = directions[(side + 2) % 6]
+ hex = { q: hex.q + dir.q, r: hex.r + dir.r }
+ }
+ }
+
+ return results
+ }
+
+ private updateClickPulses(delta: number): void {
+ for (let i = this.clickPulses.length - 1; i >= 0; i--) {
+ const pulse = this.clickPulses[i]
+
+ if (pulse.delay && pulse.delay > 0) { pulse.delay -= delta; continue }
+
+ pulse.age += delta
+ const progress = pulse.age / pulse.maxAge
+
+ if (progress >= 1) {
+ this.scene.remove(pulse.mesh)
+ pulse.mesh.geometry.dispose()
+ ;(pulse.mesh.material as THREE.Material).dispose()
+ this.clickPulses.splice(i, 1)
+ } else if (pulse.type === 'ring') {
+ const scale = 1 + progress * 4
+ pulse.mesh.scale.set(scale, scale, 1)
+ ;(pulse.mesh.material as THREE.MeshBasicMaterial).opacity = 0.9 * (1 - progress * progress)
+ } else if (pulse.type === 'ripple') {
+ const mat = pulse.mesh.material as THREE.LineBasicMaterial
+ const peak = pulse.startOpacity ?? 1.0
+ mat.opacity = peak * Math.pow(1 - progress, 2)
+ } else {
+ const pulsePhase = Math.sin(progress * Math.PI * 2) * 0.3
+ const fadeOut = 1 - progress * progress
+ ;(pulse.mesh.material as THREE.LineBasicMaterial).opacity = Math.min(1, (0.7 + pulsePhase) * fadeOut)
+ }
+ }
+ }
+
+ private updateStationPulses(delta: number): void {
+ for (let i = this.stationPulses.length - 1; i >= 0; i--) {
+ const pulse = this.stationPulses[i]
+ pulse.age += delta
+ const progress = pulse.age / pulse.maxAge
+
+ if (progress >= 1) {
+ (pulse.ring.material as THREE.MeshBasicMaterial).opacity = pulse.baseOpacity
+ this.stationPulses.splice(i, 1)
+ } else {
+ const mat = pulse.ring.material as THREE.MeshBasicMaterial
+ const fadeInEnd = 0.23
+ const holdEnd = 0.62
+
+ let opacity: number
+ if (progress < fadeInEnd) {
+ opacity = pulse.baseOpacity + (pulse.peakOpacity - pulse.baseOpacity) * (progress / fadeInEnd)
+ } else if (progress < holdEnd) {
+ opacity = pulse.peakOpacity
+ } else {
+ const t = (progress - holdEnd) / (1 - holdEnd)
+ opacity = pulse.peakOpacity - (pulse.peakOpacity - pulse.baseOpacity) * t
+ }
+ mat.opacity = opacity
+ }
+ }
+ }
+
+ // ==========================================================================
+ // Spawn beams
+ // ==========================================================================
+
+ launchSpawnBeam(fromSessionId: string, toSessionId: string): void {
+ const fromZone = this.zones.get(fromSessionId)
+ const toZone = this.zones.get(toSessionId)
+ if (!fromZone || !toZone) return
+
+ const portal = fromZone.stations.get('portal')
+ if (!portal) return
+
+ const from = new THREE.Vector3(
+ fromZone.position.x + portal.localPosition.x,
+ fromZone.elevation + 0.5,
+ fromZone.position.z + portal.localPosition.z,
+ )
+ const to = new THREE.Vector3(
+ toZone.position.x,
+ toZone.elevation + 0.5,
+ toZone.position.z,
+ )
+
+ this.spawnBeams.launch(from, to, toZone.color)
+ }
+
+ // ==========================================================================
+ // Floating notifications (legacy)
+ // ==========================================================================
+
+ showNotification(sessionId: string, text: string, color = '#4ade80'): void {
+ const zone = this.zones.get(sessionId)
+ if (!zone) return
+
+ const sprite = this.createNotificationSprite(text, color)
+ const zoneCenter = zone.floor.position.clone()
+ const startY = 2.5
+ sprite.position.set(zoneCenter.x, startY, zoneCenter.z)
+ this.scene.add(sprite)
+
+ this.notifications.push({ sprite, startY, age: 0, maxAge: 3 })
+ }
+
+ private createNotificationSprite(text: string, color: string): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+ canvas.width = 512
+ canvas.height = 64
+
+ const fontSize = 24
+ ctx.font = `600 ${fontSize}px ui-monospace, SFMono-Regular, monospace`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const cx = canvas.width / 2
+ const cy = canvas.height / 2
+
+ const tw = ctx.measureText(text).width
+ const pad = 20
+ const pw = tw + pad * 2
+ const ph = 40
+
+ ctx.fillStyle = 'rgba(0,0,0,0.85)'
+ ctx.beginPath()
+ ctx.roundRect(cx - pw / 2, cy - ph / 2, pw, ph, 8)
+ ctx.fill()
+
+ ctx.fillStyle = color
+ ctx.fillText(text, cx, cy)
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.needsUpdate = true
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 1, depthTest: false })
+ const sprite = new THREE.Sprite(mat)
+ sprite.scale.set(4, 0.5, 1)
+ return sprite
+ }
+
+ private updateNotifications(delta: number): void {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ const n = this.notifications[i]
+ n.age += delta
+ const progress = n.age / n.maxAge
+
+ if (progress >= 1) {
+ this.scene.remove(n.sprite)
+ n.sprite.material.map?.dispose()
+ n.sprite.material.dispose()
+ this.notifications.splice(i, 1)
+ } else {
+ n.sprite.position.y = n.startY + progress * 1.5
+ const fadeStart = 0.6
+ n.sprite.material.opacity = progress < fadeStart ? 1 : 1 - (progress - fadeStart) / (1 - fadeStart)
+ }
+ }
+ }
+
+ // ==========================================================================
+ // Resize
+ // ==========================================================================
+
+ private handleResize = (): void => {
+ const w = this.container.clientWidth
+ const h = this.container.clientHeight
+ this.camera.aspect = w / h
+ this.camera.updateProjectionMatrix()
+ this.renderer.setSize(w, h)
+ }
+
+ // ==========================================================================
+ // Render callbacks
+ // ==========================================================================
+
+ onRender(callback: (delta: number) => void): void {
+ this.onRenderCallbacks.push(callback)
+ }
+
+ offRender(callback: (delta: number) => void): void {
+ const idx = this.onRenderCallbacks.indexOf(callback)
+ if (idx !== -1) this.onRenderCallbacks.splice(idx, 1)
+ }
+
+ // ==========================================================================
+ // Animation loop
+ // ==========================================================================
+
+ start(): void {
+ const animate = () => {
+ this.animationId = requestAnimationFrame(animate)
+ const delta = this.clock.getDelta()
+
+ // Camera animation
+ if (this.cameraAnimating) {
+ const lf = 1 - Math.exp(-this.cameraLerpSpeed * delta)
+ this.camera.position.lerp(this.cameraTargetPos, lf)
+ this.controls.target.lerp(this.cameraTargetLookAt, lf)
+
+ if (
+ this.camera.position.distanceTo(this.cameraTargetPos) < 0.01 &&
+ this.controls.target.distanceTo(this.cameraTargetLookAt) < 0.01
+ ) {
+ this.camera.position.copy(this.cameraTargetPos)
+ this.controls.target.copy(this.cameraTargetLookAt)
+ this.cameraAnimating = false
+ }
+ }
+
+ this.controls.update()
+ this.time += delta
+
+ // External callbacks
+ for (const cb of this.onRenderCallbacks) cb(delta)
+
+ // Ambient particles
+ this.updateAmbientParticles(delta)
+
+ // Zone animations
+ const zonesToFinalize: string[] = []
+ for (const zone of this.zones.values()) {
+ // Enter animation
+ if (zone.animationState === 'entering') {
+ zone.animationProgress = Math.min(1, (zone.animationProgress ?? 0) + delta * 2)
+ const t = zone.animationProgress
+ const eased = 1 - Math.pow(1 - t, 3)
+
+ zone.group.scale.setScalar(eased)
+
+ const ringMat = zone.ring.material as THREE.MeshBasicMaterial
+ const floorMat = zone.floor.material as THREE.MeshStandardMaterial
+ ringMat.opacity = eased * 0.4
+ floorMat.opacity = eased
+
+ if (t > 0.5) {
+ for (const s of zone.stations.values()) s.mesh.visible = true
+ zone.particles.visible = true
+ }
+ if (t > 0.7 && zone.label) zone.label.visible = true
+ if (t >= 1) {
+ zone.animationState = undefined
+ zone.animationProgress = undefined
+ }
+ }
+ // Exit animation
+ else if (zone.animationState === 'exiting') {
+ zone.animationProgress = Math.min(1, (zone.animationProgress ?? 0) + delta * 2.5)
+ const t = zone.animationProgress
+ const eased = 1 - Math.pow(t, 2)
+
+ zone.group.scale.setScalar(Math.max(0.01, eased))
+
+ const ringMat = zone.ring.material as THREE.MeshBasicMaterial
+ const floorMat = zone.floor.material as THREE.MeshStandardMaterial
+ ringMat.opacity = eased * 0.4
+ floorMat.opacity = eased
+
+ if (t > 0.3) { if (zone.label) zone.label.visible = false; zone.particles.visible = false }
+ if (t > 0.5) { for (const s of zone.stations.values()) s.mesh.visible = false }
+ if (t >= 1) zonesToFinalize.push(zone.id)
+ }
+
+ // Station floating bob
+ for (const station of zone.stations.values()) {
+ if (station.type !== 'center') {
+ station.mesh.position.y = Math.sin(this.time * 1.5 + station.position.x * 0.5) * 0.03
+ }
+ }
+
+ // Ring pulse / attention animation
+ if (zone.attentionReason) {
+ zone.attentionTime += delta
+ if (zone.attentionReason === 'finished') {
+ const p = Math.sin(zone.attentionTime * 2) * 0.5 + 0.5
+ zone.ring.scale.setScalar(1 + p * 0.02)
+ } else {
+ const p = Math.sin(zone.attentionTime * 4) * 0.5 + 0.5
+ zone.ring.scale.setScalar(1 + p * 0.08)
+ }
+ } else if (zone.pulseIntensity > 0) {
+ zone.pulseIntensity = Math.max(0, zone.pulseIntensity - delta * 0.5)
+ zone.ring.scale.setScalar(1 + zone.pulseIntensity * 0.05)
+ } else {
+ zone.ring.scale.setScalar(1)
+ }
+
+ // Update zone particles
+ const positions = zone.particles.geometry.attributes.position.array as Float32Array
+ const velocities = zone.particleVelocities
+ let needsUpdate = false
+
+ for (let i = 0; i < positions.length / 3; i++) {
+ if (positions[i * 3 + 1] > -5) {
+ positions[i * 3] += velocities[i * 3] * delta
+ positions[i * 3 + 1] += velocities[i * 3 + 1] * delta
+ positions[i * 3 + 2] += velocities[i * 3 + 2] * delta
+ velocities[i * 3 + 1] -= 5 * delta
+ if (positions[i * 3 + 1] < 0) positions[i * 3 + 1] = -1000
+ needsUpdate = true
+ }
+ }
+ if (needsUpdate) zone.particles.geometry.attributes.position.needsUpdate = true
+ }
+
+ // Finalize deletions
+ for (const id of zonesToFinalize) this.finalizeZoneDelete(id)
+
+ // Update effects
+ this.updateClickPulses(delta)
+ this.updateStationPulses(delta)
+ this.updateNotifications(delta)
+
+ // Update subsystems
+ this.zoneNotifications.update(delta)
+ this.spawnBeams.update(delta)
+ this.stationPanels.update()
+
+ // Render
+ this.renderer.render(this.scene, this.camera)
+ }
+
+ animate()
+ }
+
+ stop(): void {
+ if (this.animationId !== null) {
+ cancelAnimationFrame(this.animationId)
+ this.animationId = null
+ }
+ }
+
+ // ==========================================================================
+ // Dispose
+ // ==========================================================================
+
+ dispose(): void {
+ this.stop()
+ this.clearAllContexts()
+ this.zoneNotifications.dispose()
+
+ for (const notif of this.notifications) {
+ this.scene.remove(notif.sprite)
+ notif.sprite.material.map?.dispose()
+ notif.sprite.material.dispose()
+ }
+ this.notifications = []
+
+ for (const pulse of this.clickPulses) {
+ this.scene.remove(pulse.mesh)
+ pulse.mesh.geometry.dispose()
+ ;(pulse.mesh.material as THREE.MeshBasicMaterial).dispose()
+ }
+ this.clickPulses = []
+
+ this.renderer.domElement.removeEventListener('mousemove', this.handleHover)
+ this.renderer.domElement.removeEventListener('mouseleave', this.handleHoverLeave)
+ window.removeEventListener('resize', this.handleResize)
+ this.renderer.dispose()
+ this.container.removeChild(this.renderer.domElement)
+ }
+}
diff --git a/src/workshop/scene/ZoneNotifications.ts b/src/workshop/scene/ZoneNotifications.ts
new file mode 100644
index 0000000..5e3e5b8
--- /dev/null
+++ b/src/workshop/scene/ZoneNotifications.ts
@@ -0,0 +1,378 @@
+/**
+ * ZoneNotifications - Floating notification system for zones
+ *
+ * Shows contextual notifications that float up and fade out above zones.
+ * Useful for showing file changes, command results, search results, etc.
+ *
+ * Features:
+ * - Multiple notification styles (success, info, warning, error)
+ * - Stacking: multiple notifications don't overlap
+ * - Configurable duration and animation
+ * - Icon support for quick visual scanning
+ */
+
+import * as THREE from 'three'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type NotificationStyle = 'success' | 'info' | 'warning' | 'error' | 'muted'
+
+export interface NotificationOptions {
+ /** Text to display */
+ text: string
+ /** Optional icon/emoji prefix */
+ icon?: string
+ /** Style determines color */
+ style?: NotificationStyle
+ /** Duration in seconds (default: 3) */
+ duration?: number
+ /** Custom color override (hex string like '#ff0000') */
+ color?: string
+}
+
+interface ActiveNotification {
+ sprite: THREE.Sprite
+ zoneId: string
+ startY: number
+ targetY: number // For stacking
+ age: number
+ maxAge: number
+ slot: number // Vertical slot for stacking
+}
+
+// ============================================================================
+// Style Configuration
+// ============================================================================
+
+const STYLE_COLORS: Record = {
+ success: '#4ade80', // Green
+ info: '#60a5fa', // Blue
+ warning: '#fbbf24', // Amber
+ error: '#f87171', // Red
+ muted: '#9ca3af', // Gray
+}
+
+// Tool to style mapping
+const TOOL_STYLES: Record = {
+ // File operations
+ Read: { style: 'info', icon: '📖' },
+ Edit: { style: 'warning', icon: '✏️' },
+ Write: { style: 'success', icon: '📝' },
+
+ // Search operations
+ Grep: { style: 'info', icon: '🔍' },
+ Glob: { style: 'info', icon: '📁' },
+
+ // Terminal
+ Bash: { style: 'muted', icon: '⚡' },
+
+ // Web
+ WebFetch: { style: 'info', icon: '🌐' },
+ WebSearch: { style: 'info', icon: '🔎' },
+
+ // Tasks
+ Task: { style: 'success', icon: '🚀' },
+ TodoWrite: { style: 'info', icon: '☑️' },
+
+ // Other
+ AskUserQuestion: { style: 'warning', icon: '❓' },
+ NotebookEdit: { style: 'warning', icon: '📓' },
+}
+
+// ============================================================================
+// ZoneNotifications Class
+// ============================================================================
+
+export class ZoneNotifications {
+ private scene: THREE.Scene
+ private notifications: ActiveNotification[] = []
+ private zonePositions: Map = new Map()
+ private zoneElevations: Map = new Map()
+
+ // Configuration
+ private readonly BASE_Y = 2.5 // Starting height above zone
+ private readonly STACK_SPACING = 0.6 // Vertical space between stacked notifications
+ private readonly MAX_STACK = 5 // Max notifications per zone
+ private readonly FLOAT_DISTANCE = 1.5 // How far to float up
+ private readonly DEFAULT_DURATION = 3 // Default seconds
+
+ constructor(scene: THREE.Scene) {
+ this.scene = scene
+ }
+
+ /**
+ * Register a zone's position for notifications
+ */
+ registerZone(zoneId: string, position: THREE.Vector3): void {
+ this.zonePositions.set(zoneId, position.clone())
+ }
+
+ /**
+ * Unregister a zone
+ */
+ unregisterZone(zoneId: string): void {
+ this.zonePositions.delete(zoneId)
+ this.zoneElevations.delete(zoneId)
+ // Remove any active notifications for this zone
+ this.clearZone(zoneId)
+ }
+
+ /**
+ * Update a zone's elevation (for raised zones)
+ */
+ updateZoneElevation(zoneId: string, elevation: number): void {
+ this.zoneElevations.set(zoneId, elevation)
+ }
+
+ /**
+ * Show a notification above a zone
+ */
+ show(zoneId: string, options: NotificationOptions): void {
+ const zonePos = this.zonePositions.get(zoneId)
+ if (!zonePos) return
+
+ const style = options.style ?? 'info'
+ const color = options.color ?? STYLE_COLORS[style]
+ const duration = options.duration ?? this.DEFAULT_DURATION
+ const displayText = options.icon ? `${options.icon} ${options.text}` : options.text
+
+ // Find next available slot for this zone
+ const slot = this.findNextSlot(zoneId)
+ if (slot >= this.MAX_STACK) {
+ // Too many notifications, skip or remove oldest
+ this.removeOldestForZone(zoneId)
+ }
+
+ // Create sprite
+ const sprite = this.createSprite(displayText, color)
+
+ // Position with stacking offset, accounting for zone elevation
+ const zoneElevation = this.zoneElevations.get(zoneId) ?? 0
+ const startY = zoneElevation + this.BASE_Y + slot * this.STACK_SPACING
+ sprite.position.set(zonePos.x, startY, zonePos.z)
+ this.scene.add(sprite)
+
+ this.notifications.push({
+ sprite,
+ zoneId,
+ startY,
+ targetY: startY + this.FLOAT_DISTANCE,
+ age: 0,
+ maxAge: duration,
+ slot,
+ })
+ }
+
+ /**
+ * Show a tool-specific notification with automatic styling
+ */
+ showForTool(
+ zoneId: string,
+ tool: string,
+ text: string,
+ options?: Partial
+ ): void {
+ const toolConfig = TOOL_STYLES[tool] ?? { style: 'info' as NotificationStyle, icon: '🔧' }
+
+ this.show(zoneId, {
+ text,
+ icon: options?.icon ?? toolConfig.icon,
+ style: options?.style ?? toolConfig.style,
+ duration: options?.duration,
+ color: options?.color,
+ })
+ }
+
+ /**
+ * Clear all notifications for a zone
+ */
+ clearZone(zoneId: string): void {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ if (this.notifications[i].zoneId === zoneId) {
+ this.removeNotification(i)
+ }
+ }
+ }
+
+ /**
+ * Update all notifications (call from render loop)
+ */
+ update(delta: number): void {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ const notif = this.notifications[i]
+ notif.age += delta
+
+ const progress = notif.age / notif.maxAge
+
+ if (progress >= 1) {
+ this.removeNotification(i)
+ } else {
+ // Animate position: ease out float
+ const floatProgress = 1 - Math.pow(1 - progress, 2)
+ const y = notif.startY + (notif.targetY - notif.startY) * floatProgress
+ notif.sprite.position.y = y
+
+ // Animate opacity: stay visible, then fade
+ const fadeStart = 0.6
+ const opacity = progress < fadeStart
+ ? 1
+ : 1 - Math.pow((progress - fadeStart) / (1 - fadeStart), 2)
+ notif.sprite.material.opacity = opacity
+ }
+ }
+ }
+
+ /**
+ * Dispose all resources
+ */
+ dispose(): void {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ this.removeNotification(i)
+ }
+ this.zonePositions.clear()
+ }
+
+ // ============================================================================
+ // Private Methods
+ // ============================================================================
+
+ private createSprite(text: string, color: string): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+
+ canvas.width = 512
+ canvas.height = 96
+
+ // Font setup
+ const fontSize = 32
+ ctx.font = `600 ${fontSize}px ui-monospace, SFMono-Regular, "SF Mono", monospace`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const centerX = canvas.width / 2
+ const centerY = canvas.height / 2
+
+ // Measure text for pill background
+ const textWidth = ctx.measureText(text).width
+ const padding = 24
+ const pillWidth = Math.min(canvas.width - 20, textWidth + padding * 2)
+ const pillHeight = 56
+ const pillX = centerX - pillWidth / 2
+ const pillY = centerY - pillHeight / 2
+
+ // Draw pill background
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
+ ctx.beginPath()
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 10)
+ ctx.fill()
+
+ // Draw border
+ ctx.strokeStyle = color
+ ctx.lineWidth = 2
+ ctx.globalAlpha = 0.5
+ ctx.stroke()
+ ctx.globalAlpha = 1
+
+ // Draw text
+ ctx.fillStyle = color
+
+ // Truncate if needed
+ let displayText = text
+ while (ctx.measureText(displayText).width > pillWidth - padding * 2 && displayText.length > 10) {
+ displayText = displayText.slice(0, -4) + '...'
+ }
+ ctx.fillText(displayText, centerX, centerY)
+
+ // Create texture and sprite
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.needsUpdate = true
+
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ opacity: 1,
+ depthTest: false,
+ })
+
+ const sprite = new THREE.Sprite(material)
+ sprite.scale.set(7, 1.3, 1)
+
+ return sprite
+ }
+
+ private findNextSlot(zoneId: string): number {
+ const usedSlots = new Set()
+ for (const notif of this.notifications) {
+ if (notif.zoneId === zoneId) {
+ usedSlots.add(notif.slot)
+ }
+ }
+
+ // Find first available slot
+ for (let i = 0; i < this.MAX_STACK; i++) {
+ if (!usedSlots.has(i)) return i
+ }
+ return this.MAX_STACK
+ }
+
+ private removeOldestForZone(zoneId: string): void {
+ for (let i = 0; i < this.notifications.length; i++) {
+ if (this.notifications[i].zoneId === zoneId) {
+ this.removeNotification(i)
+ return
+ }
+ }
+ }
+
+ private removeNotification(index: number): void {
+ const notif = this.notifications[index]
+ this.scene.remove(notif.sprite)
+ notif.sprite.material.map?.dispose()
+ notif.sprite.material.dispose()
+ this.notifications.splice(index, 1)
+ }
+}
+
+// ============================================================================
+// Helper Functions for Common Notifications
+// ============================================================================
+
+/**
+ * Format a file change notification
+ */
+export function formatFileChange(
+ fileName: string,
+ options?: { added?: number; removed?: number; lines?: number }
+): string {
+ if (!options) return fileName
+
+ const parts: string[] = []
+ if (options.added && options.added > 0) parts.push(`+${options.added}`)
+ if (options.removed && options.removed > 0) parts.push(`-${options.removed}`)
+ if (options.lines) parts.push(`${options.lines} lines`)
+
+ return parts.length > 0 ? `${fileName} ${parts.join(', ')}` : fileName
+}
+
+/**
+ * Format a command result notification
+ */
+export function formatCommandResult(command: string, maxLength = 30): string {
+ // Extract just the command name, not args
+ const cmdName = command.split(' ')[0].split('/').pop() || command
+ if (cmdName.length <= maxLength) return cmdName
+ return cmdName.slice(0, maxLength - 3) + '...'
+}
+
+/**
+ * Format a search result notification
+ */
+export function formatSearchResult(pattern: string, matchCount?: number): string {
+ const truncatedPattern = pattern.length > 20 ? pattern.slice(0, 17) + '...' : pattern
+ if (matchCount !== undefined) {
+ return `"${truncatedPattern}" → ${matchCount} matches`
+ }
+ return `"${truncatedPattern}"`
+}
diff --git a/src/workshop/scene/stations/AntennaStation.ts b/src/workshop/scene/stations/AntennaStation.ts
new file mode 100644
index 0000000..630b1b9
--- /dev/null
+++ b/src/workshop/scene/stations/AntennaStation.ts
@@ -0,0 +1,70 @@
+/**
+ * Antenna Station - WebFetch/WebSearch station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addAntennaDetails(group: THREE.Group): void {
+ const metalMaterial = new THREE.MeshStandardMaterial({
+ color: 0x666677,
+ metalness: 0.7,
+ roughness: 0.3,
+ })
+
+ // Main tower pole
+ const tower = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.04, 0.06, 1.2),
+ metalMaterial
+ )
+ tower.position.set(0, 1.4, 0)
+ group.add(tower)
+
+ // Cross beams (lattice tower look)
+ for (const y of [1.0, 1.4, 1.8]) {
+ const beam = new THREE.Mesh(
+ new THREE.BoxGeometry(0.4, 0.02, 0.02),
+ metalMaterial
+ )
+ beam.position.set(0, y, 0)
+ group.add(beam)
+ }
+
+ // Satellite dish at top
+ const dishGeometry = new THREE.SphereGeometry(0.25, 12, 6, 0, Math.PI)
+ const dishMaterial = new THREE.MeshStandardMaterial({
+ color: 0xaaaabb,
+ metalness: 0.8,
+ roughness: 0.2,
+ side: THREE.DoubleSide,
+ })
+ const dish = new THREE.Mesh(dishGeometry, dishMaterial)
+ dish.position.set(0.15, 1.85, 0)
+ dish.rotation.x = -Math.PI / 3
+ dish.rotation.z = -0.3
+ group.add(dish)
+
+ // Signal waves (decorative rings)
+ const waveMaterial = new THREE.MeshBasicMaterial({
+ color: 0x66aaff,
+ transparent: true,
+ opacity: 0.3,
+ side: THREE.DoubleSide,
+ })
+ for (let i = 0; i < 2; i++) {
+ const wave = new THREE.Mesh(
+ new THREE.RingGeometry(0.15 + i * 0.12, 0.18 + i * 0.12, 16),
+ waveMaterial
+ )
+ wave.position.set(0.3 + i * 0.15, 1.95, 0.1)
+ wave.rotation.y = Math.PI / 3
+ group.add(wave)
+ }
+
+ // Small blinking light at top
+ const light = new THREE.Mesh(
+ new THREE.SphereGeometry(0.03, 8, 8),
+ new THREE.MeshBasicMaterial({ color: 0xff4444 })
+ )
+ light.position.set(0, 2.0, 0)
+ group.add(light)
+}
diff --git a/src/workshop/scene/stations/BookshelfStation.ts b/src/workshop/scene/stations/BookshelfStation.ts
new file mode 100644
index 0000000..ebebb86
--- /dev/null
+++ b/src/workshop/scene/stations/BookshelfStation.ts
@@ -0,0 +1,45 @@
+/**
+ * Bookshelf Station - Library/reading station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addBookshelfDetails(group: THREE.Group): void {
+ const shelfMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a5a6a, // Blue-gray metallic
+ roughness: 0.6,
+ metalness: 0.3,
+ })
+
+ // Vertical sides
+ for (const xOffset of [-0.7, 0.7]) {
+ const side = new THREE.Mesh(
+ new THREE.BoxGeometry(0.1, 1.5, 0.8),
+ shelfMaterial
+ )
+ side.position.set(xOffset, 1.15, 0)
+ side.castShadow = true
+ group.add(side)
+ }
+
+ // Shelves
+ for (const yOffset of [0.9, 1.4]) {
+ const shelf = new THREE.Mesh(
+ new THREE.BoxGeometry(1.4, 0.05, 0.8),
+ shelfMaterial
+ )
+ shelf.position.set(0, yOffset, 0)
+ group.add(shelf)
+ }
+
+ // Books (simple colored boxes)
+ const bookColors = [0xcc3333, 0x33cc33, 0x3333cc, 0xcccc33, 0xcc33cc]
+ for (let i = 0; i < 5; i++) {
+ const book = new THREE.Mesh(
+ new THREE.BoxGeometry(0.15, 0.35, 0.5),
+ new THREE.MeshStandardMaterial({ color: bookColors[i] })
+ )
+ book.position.set(-0.4 + i * 0.2, 1.1, 0)
+ group.add(book)
+ }
+}
diff --git a/src/workshop/scene/stations/DeskStation.ts b/src/workshop/scene/stations/DeskStation.ts
new file mode 100644
index 0000000..01a3384
--- /dev/null
+++ b/src/workshop/scene/stations/DeskStation.ts
@@ -0,0 +1,38 @@
+/**
+ * Desk Station - Write tool station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addDeskDetails(group: THREE.Group): void {
+ // Notepad/paper
+ const paperGeometry = new THREE.BoxGeometry(0.6, 0.02, 0.8)
+ const paperMaterial = new THREE.MeshStandardMaterial({
+ color: 0xf5f5dc,
+ roughness: 0.9,
+ })
+ const paper = new THREE.Mesh(paperGeometry, paperMaterial)
+ paper.position.set(0, 0.82, 0)
+ group.add(paper)
+
+ // Pencil
+ const pencilGeometry = new THREE.CylinderGeometry(0.02, 0.02, 0.4, 8)
+ const pencilMaterial = new THREE.MeshStandardMaterial({
+ color: 0xffd700,
+ })
+ const pencil = new THREE.Mesh(pencilGeometry, pencilMaterial)
+ pencil.position.set(0.35, 0.85, 0.2)
+ pencil.rotation.z = Math.PI / 2
+ pencil.rotation.y = 0.3
+ group.add(pencil)
+
+ // Ink pot
+ const inkGeometry = new THREE.CylinderGeometry(0.08, 0.1, 0.15, 16)
+ const inkMaterial = new THREE.MeshStandardMaterial({
+ color: 0x1a1a2e,
+ metalness: 0.3,
+ })
+ const ink = new THREE.Mesh(inkGeometry, inkMaterial)
+ ink.position.set(-0.4, 0.88, -0.2)
+ group.add(ink)
+}
diff --git a/src/workshop/scene/stations/PortalStation.ts b/src/workshop/scene/stations/PortalStation.ts
new file mode 100644
index 0000000..0c2bb7f
--- /dev/null
+++ b/src/workshop/scene/stations/PortalStation.ts
@@ -0,0 +1,32 @@
+/**
+ * Portal Station - Task/subagent spawning station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addPortalDetails(group: THREE.Group): void {
+ // Portal ring
+ const ringGeometry = new THREE.TorusGeometry(0.6, 0.1, 8, 32)
+ const ringMaterial = new THREE.MeshStandardMaterial({
+ color: 0x8844ff,
+ emissive: 0x4422aa,
+ emissiveIntensity: 0.5,
+ })
+ const ring = new THREE.Mesh(ringGeometry, ringMaterial)
+ ring.position.set(0, 1.3, 0)
+ ring.rotation.x = Math.PI / 2
+ group.add(ring)
+
+ // Portal center (glowing)
+ const portalGeometry = new THREE.CircleGeometry(0.5, 32)
+ const portalMaterial = new THREE.MeshBasicMaterial({
+ color: 0xaa66ff,
+ transparent: true,
+ opacity: 0.5,
+ side: THREE.DoubleSide,
+ })
+ const portal = new THREE.Mesh(portalGeometry, portalMaterial)
+ portal.position.set(0, 1.3, 0)
+ portal.rotation.x = Math.PI / 2
+ group.add(portal)
+}
diff --git a/src/workshop/scene/stations/ScannerStation.ts b/src/workshop/scene/stations/ScannerStation.ts
new file mode 100644
index 0000000..fbe0380
--- /dev/null
+++ b/src/workshop/scene/stations/ScannerStation.ts
@@ -0,0 +1,65 @@
+/**
+ * Scanner Station - Grep/Glob search station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addScannerDetails(group: THREE.Group): void {
+ // Magnifying glass handle
+ const handleMaterial = new THREE.MeshStandardMaterial({
+ color: 0x4a5a6a, // Blue-gray
+ roughness: 0.5,
+ metalness: 0.4,
+ })
+ const handle = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.04, 0.05, 0.5, 12),
+ handleMaterial
+ )
+ handle.position.set(0.15, 1.0, 0)
+ handle.rotation.z = -Math.PI / 4
+ group.add(handle)
+
+ // Magnifying glass rim
+ const rimMaterial = new THREE.MeshStandardMaterial({
+ color: 0xc9a227,
+ metalness: 0.7,
+ roughness: 0.3,
+ })
+ const rim = new THREE.Mesh(
+ new THREE.TorusGeometry(0.28, 0.04, 12, 24),
+ rimMaterial
+ )
+ rim.position.set(-0.05, 1.35, 0)
+ rim.rotation.x = Math.PI / 2
+ rim.rotation.y = 0.3
+ group.add(rim)
+
+ // Glass lens
+ const lensMaterial = new THREE.MeshStandardMaterial({
+ color: 0xaaddff,
+ transparent: true,
+ opacity: 0.4,
+ metalness: 0.1,
+ roughness: 0.1,
+ })
+ const lens = new THREE.Mesh(
+ new THREE.CircleGeometry(0.26, 24),
+ lensMaterial
+ )
+ lens.position.set(-0.05, 1.35, 0.01)
+ lens.rotation.y = 0.3
+ group.add(lens)
+
+ // Glint/reflection on lens
+ const glint = new THREE.Mesh(
+ new THREE.CircleGeometry(0.06, 12),
+ new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: 0.6,
+ })
+ )
+ glint.position.set(-0.12, 1.42, 0.02)
+ glint.rotation.y = 0.3
+ group.add(glint)
+}
diff --git a/src/workshop/scene/stations/TaskboardStation.ts b/src/workshop/scene/stations/TaskboardStation.ts
new file mode 100644
index 0000000..e2009e1
--- /dev/null
+++ b/src/workshop/scene/stations/TaskboardStation.ts
@@ -0,0 +1,39 @@
+/**
+ * Taskboard Station - TodoWrite station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addTaskboardDetails(group: THREE.Group): void {
+ // Board backing
+ const boardGeometry = new THREE.BoxGeometry(1.2, 0.9, 0.05)
+ const boardMaterial = new THREE.MeshStandardMaterial({
+ color: 0x3a3a4e,
+ roughness: 0.8,
+ })
+ const board = new THREE.Mesh(boardGeometry, boardMaterial)
+ board.position.set(0, 1.25, -0.3)
+ board.rotation.x = -0.1
+ group.add(board)
+
+ // Task cards (sticky notes)
+ const cardColors = [0x4ade80, 0xfbbf24, 0x60a5fa, 0xf472b6]
+ const cardPositions = [
+ [-0.35, 1.4, -0.25],
+ [0.05, 1.4, -0.25],
+ [-0.35, 1.1, -0.25],
+ [0.05, 1.1, -0.25],
+ ]
+
+ cardPositions.forEach((pos, i) => {
+ const cardGeometry = new THREE.BoxGeometry(0.3, 0.2, 0.01)
+ const cardMaterial = new THREE.MeshStandardMaterial({
+ color: cardColors[i % cardColors.length],
+ roughness: 0.9,
+ })
+ const card = new THREE.Mesh(cardGeometry, cardMaterial)
+ card.position.set(pos[0], pos[1], pos[2])
+ card.rotation.x = -0.1
+ group.add(card)
+ })
+}
diff --git a/src/workshop/scene/stations/TerminalStation.ts b/src/workshop/scene/stations/TerminalStation.ts
new file mode 100644
index 0000000..dc091af
--- /dev/null
+++ b/src/workshop/scene/stations/TerminalStation.ts
@@ -0,0 +1,53 @@
+/**
+ * Terminal Station - Computer/CLI station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addTerminalDetails(group: THREE.Group): void {
+ // CRT Monitor frame
+ const frameGeometry = new THREE.BoxGeometry(1.1, 0.8, 0.3)
+ const frameMaterial = new THREE.MeshStandardMaterial({
+ color: 0x2a2a35,
+ roughness: 0.8,
+ })
+ const frame = new THREE.Mesh(frameGeometry, frameMaterial)
+ frame.position.set(0, 1.2, -0.25)
+ frame.rotation.x = -0.15
+ frame.castShadow = true
+ group.add(frame)
+
+ // Screen inset
+ const screenGeometry = new THREE.PlaneGeometry(0.85, 0.55)
+ const screenMaterial = new THREE.MeshStandardMaterial({
+ color: 0x0a0a12,
+ emissive: 0x112244,
+ emissiveIntensity: 0.3,
+ })
+ const screen = new THREE.Mesh(screenGeometry, screenMaterial)
+ screen.position.set(0, 1.22, -0.08)
+ screen.rotation.x = -0.15
+ group.add(screen)
+
+ // Terminal text "$ _" using a small plane with green tint
+ const promptGeometry = new THREE.PlaneGeometry(0.15, 0.08)
+ const promptMaterial = new THREE.MeshBasicMaterial({
+ color: 0x44ff88,
+ transparent: true,
+ opacity: 0.9,
+ })
+ const prompt = new THREE.Mesh(promptGeometry, promptMaterial)
+ prompt.position.set(-0.25, 1.18, -0.06)
+ prompt.rotation.x = -0.15
+ group.add(prompt)
+
+ // Keyboard
+ const keyboardGeometry = new THREE.BoxGeometry(0.7, 0.03, 0.25)
+ const keyboardMaterial = new THREE.MeshStandardMaterial({
+ color: 0x1a1a22,
+ roughness: 0.7,
+ })
+ const keyboard = new THREE.Mesh(keyboardGeometry, keyboardMaterial)
+ keyboard.position.set(0, 0.83, 0.2)
+ group.add(keyboard)
+}
diff --git a/src/workshop/scene/stations/WorkbenchStation.ts b/src/workshop/scene/stations/WorkbenchStation.ts
new file mode 100644
index 0000000..c00e7b7
--- /dev/null
+++ b/src/workshop/scene/stations/WorkbenchStation.ts
@@ -0,0 +1,85 @@
+/**
+ * Workbench Station - Edit tool station decorations
+ */
+
+import * as THREE from 'three'
+
+export function addWorkbenchDetails(group: THREE.Group): void {
+ const metalMaterial = new THREE.MeshStandardMaterial({
+ color: 0x888899,
+ metalness: 0.8,
+ roughness: 0.2,
+ })
+
+ // Vice/clamp on the side
+ const viceBase = new THREE.Mesh(
+ new THREE.BoxGeometry(0.2, 0.15, 0.15),
+ metalMaterial
+ )
+ viceBase.position.set(-0.55, 0.88, 0)
+ group.add(viceBase)
+
+ const viceJaw = new THREE.Mesh(
+ new THREE.BoxGeometry(0.08, 0.2, 0.12),
+ metalMaterial
+ )
+ viceJaw.position.set(-0.55, 1.0, 0.08)
+ group.add(viceJaw)
+
+ // Hammer
+ const hammerHead = new THREE.Mesh(
+ new THREE.BoxGeometry(0.15, 0.08, 0.08),
+ metalMaterial
+ )
+ hammerHead.position.set(0.25, 0.88, -0.15)
+ hammerHead.rotation.y = 0.4
+ group.add(hammerHead)
+
+ const hammerHandle = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.02, 0.025, 0.3, 8),
+ new THREE.MeshStandardMaterial({ color: 0x4a5a6a, metalness: 0.3 }) // Blue-gray
+ )
+ hammerHandle.position.set(0.35, 0.86, -0.08)
+ hammerHandle.rotation.z = Math.PI / 2
+ hammerHandle.rotation.y = 0.4
+ group.add(hammerHandle)
+
+ // Gears (being worked on)
+ const gearMaterial = new THREE.MeshStandardMaterial({
+ color: 0xf97316,
+ metalness: 0.6,
+ roughness: 0.3,
+ })
+ const gear1 = new THREE.Mesh(
+ new THREE.TorusGeometry(0.1, 0.025, 8, 12),
+ gearMaterial
+ )
+ gear1.position.set(0, 0.85, 0.1)
+ gear1.rotation.x = Math.PI / 2
+ group.add(gear1)
+
+ const gear2 = new THREE.Mesh(
+ new THREE.TorusGeometry(0.07, 0.02, 8, 10),
+ gearMaterial
+ )
+ gear2.position.set(-0.15, 0.84, 0.15)
+ gear2.rotation.x = Math.PI / 2
+ group.add(gear2)
+
+ // Screwdriver
+ const screwdriverHandle = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.03, 0.035, 0.12, 8),
+ new THREE.MeshStandardMaterial({ color: 0xcc3333 })
+ )
+ screwdriverHandle.position.set(0.4, 0.87, 0.2)
+ screwdriverHandle.rotation.z = Math.PI / 2 + 0.2
+ group.add(screwdriverHandle)
+
+ const screwdriverShaft = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.012, 0.012, 0.15, 8),
+ metalMaterial
+ )
+ screwdriverShaft.position.set(0.28, 0.85, 0.18)
+ screwdriverShaft.rotation.z = Math.PI / 2 + 0.2
+ group.add(screwdriverShaft)
+}
diff --git a/src/workshop/scene/stations/index.ts b/src/workshop/scene/stations/index.ts
new file mode 100644
index 0000000..319cc7a
--- /dev/null
+++ b/src/workshop/scene/stations/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Station Details - Modular station decoration functions
+ *
+ * Each station function adds decorative meshes to a station group.
+ * These are pure functions with no external dependencies beyond Three.js.
+ *
+ * Usage:
+ * import { addBookshelfDetails } from './stations'
+ * addBookshelfDetails(stationGroup)
+ */
+
+export { addBookshelfDetails } from './BookshelfStation'
+export { addTerminalDetails } from './TerminalStation'
+export { addAntennaDetails } from './AntennaStation'
+export { addPortalDetails } from './PortalStation'
+export { addScannerDetails } from './ScannerStation'
+export { addDeskDetails } from './DeskStation'
+export { addWorkbenchDetails } from './WorkbenchStation'
+export { addTaskboardDetails } from './TaskboardStation'
diff --git a/src/workshop/store/workshop.ts b/src/workshop/store/workshop.ts
new file mode 100644
index 0000000..238c0cc
--- /dev/null
+++ b/src/workshop/store/workshop.ts
@@ -0,0 +1,49 @@
+import { create } from 'zustand'
+
+export interface WorkshopFeedItem {
+ id: string
+ timestamp: number
+ type: 'tool_start' | 'tool_end' | 'text' | 'reasoning' | 'done' | 'error'
+ label: string
+ detail?: string
+ icon?: string
+}
+
+export interface WorkshopSettings {
+ showFeed: boolean
+ feedMaxItems: number
+}
+
+interface WorkshopState {
+ feed: WorkshopFeedItem[]
+ settings: WorkshopSettings
+ isSceneReady: boolean
+
+ addFeedItem: (item: WorkshopFeedItem) => void
+ clearFeed: () => void
+ setSceneReady: (ready: boolean) => void
+ updateSettings: (partial: Partial) => void
+}
+
+export const useWorkshopStore = create((set) => ({
+ feed: [],
+ settings: {
+ showFeed: true,
+ feedMaxItems: 100,
+ },
+ isSceneReady: false,
+
+ addFeedItem: (item) =>
+ set((state) => ({
+ feed: [...state.feed, item].slice(-state.settings.feedMaxItems),
+ })),
+
+ clearFeed: () => set({ feed: [] }),
+
+ setSceneReady: (ready) => set({ isSceneReady: ready }),
+
+ updateSettings: (partial) =>
+ set((state) => ({
+ settings: { ...state.settings, ...partial },
+ })),
+}))
diff --git a/src/workshop/types.ts b/src/workshop/types.ts
new file mode 100644
index 0000000..30997fb
--- /dev/null
+++ b/src/workshop/types.ts
@@ -0,0 +1,263 @@
+/**
+ * Workshop Types — Vendored from Vibecraft2 shared/types.ts + shared/agent-protocol.ts
+ *
+ * Merged and trimmed for ClawBox embedding. Removed WebSocket, session management,
+ * hex art, replay, config, and server-specific types not needed for the embedded viewer.
+ */
+
+// ============================================================================
+// Tool Categories (from agent-protocol.ts)
+// ============================================================================
+
+export type ToolCategory =
+ | 'read'
+ | 'write'
+ | 'edit'
+ | 'execute'
+ | 'search'
+ | 'network'
+ | 'delegate'
+ | 'plan'
+ | 'interact'
+ | 'other'
+
+export const CATEGORY_STATION_MAP: Record = {
+ read: 'bookshelf',
+ write: 'desk',
+ edit: 'workbench',
+ execute: 'terminal',
+ search: 'scanner',
+ network: 'antenna',
+ delegate: 'portal',
+ plan: 'taskboard',
+ interact: 'center',
+ other: 'center',
+}
+
+export function getStationForCategory(category: ToolCategory): string {
+ return CATEGORY_STATION_MAP[category] ?? 'center'
+}
+
+export const CATEGORY_ICON_MAP: Record = {
+ read: '\u{1F4D6}',
+ write: '\u{1F4DD}',
+ edit: '\u270F\uFE0F',
+ execute: '\u{1F4BB}',
+ search: '\u{1F50D}',
+ network: '\u{1F310}',
+ delegate: '\u{1F916}',
+ plan: '\u{1F4CB}',
+ interact: '\u{2753}',
+ other: '\u{1F527}',
+}
+
+// ============================================================================
+// Universal Agent Event Types (from agent-protocol.ts)
+// ============================================================================
+
+export type AgentEventType =
+ | 'tool_start'
+ | 'tool_end'
+ | 'agent_idle'
+ | 'agent_thinking'
+ | 'user_input'
+ | 'agent_start'
+ | 'agent_end'
+ | 'notification'
+ | 'subagent_spawn'
+ | 'subagent_end'
+
+export interface AgentEvent {
+ id: string
+ timestamp: number
+ type: AgentEventType
+ agentId: string
+ source: string
+ cwd?: string
+ metadata?: Record
+}
+
+export interface ToolInfo {
+ name: string
+ category: ToolCategory
+ id: string
+}
+
+export interface ToolStartEvent extends AgentEvent {
+ type: 'tool_start'
+ tool: ToolInfo
+ input?: Record
+ context?: string
+}
+
+export interface ToolEndEvent extends AgentEvent {
+ type: 'tool_end'
+ tool: ToolInfo
+ success: boolean
+ duration?: number
+ output?: Record
+}
+
+export interface AgentIdleEvent extends AgentEvent {
+ type: 'agent_idle'
+ reason?: string
+ response?: string
+}
+
+export interface AgentThinkingEvent extends AgentEvent {
+ type: 'agent_thinking'
+}
+
+export interface AgentStartEvent extends AgentEvent {
+ type: 'agent_start'
+ trigger?: 'startup' | 'resume' | 'user_input' | 'other'
+}
+
+export interface AgentEndEvent extends AgentEvent {
+ type: 'agent_end'
+ reason?: string
+}
+
+export interface UserInputEvent extends AgentEvent {
+ type: 'user_input'
+ text: string
+}
+
+export interface AgentNotificationEvent extends AgentEvent {
+ type: 'notification'
+ message: string
+ level?: 'info' | 'warning' | 'error' | 'success'
+}
+
+export interface SubagentSpawnEvent extends AgentEvent {
+ type: 'subagent_spawn'
+ parentAgentId: string
+ description?: string
+ toolUseId?: string
+}
+
+export interface SubagentEndEvent extends AgentEvent {
+ type: 'subagent_end'
+ toolUseId?: string
+}
+
+export type UniversalEvent =
+ | ToolStartEvent
+ | ToolEndEvent
+ | AgentIdleEvent
+ | AgentThinkingEvent
+ | AgentStartEvent
+ | AgentEndEvent
+ | UserInputEvent
+ | AgentNotificationEvent
+ | SubagentSpawnEvent
+ | SubagentEndEvent
+
+// ============================================================================
+// Legacy Hook Event Types (from types.ts)
+// ============================================================================
+
+export type HookEventType =
+ | 'pre_tool_use'
+ | 'post_tool_use'
+ | 'stop'
+ | 'subagent_stop'
+ | 'session_start'
+ | 'session_end'
+ | 'user_prompt_submit'
+ | 'notification'
+ | 'pre_compact'
+
+export type ToolName =
+ | 'Read'
+ | 'Write'
+ | 'Edit'
+ | 'Bash'
+ | 'Grep'
+ | 'Glob'
+ | 'WebFetch'
+ | 'WebSearch'
+ | 'Task'
+ | 'TodoWrite'
+ | 'AskUserQuestion'
+ | 'NotebookEdit'
+ | string
+
+export interface BaseEvent {
+ id: string
+ timestamp: number
+ type: HookEventType
+ sessionId: string
+ cwd: string
+}
+
+export interface PreToolUseEvent extends BaseEvent {
+ type: 'pre_tool_use'
+ tool: ToolName
+ toolInput: Record
+ toolUseId: string
+ assistantText?: string
+}
+
+export interface PostToolUseEvent extends BaseEvent {
+ type: 'post_tool_use'
+ tool: ToolName
+ toolInput: Record
+ toolResponse: Record
+ toolUseId: string
+ success: boolean
+ duration?: number
+}
+
+export interface StopEvent extends BaseEvent {
+ type: 'stop'
+ stopHookActive: boolean
+ response?: string
+}
+
+export interface UserPromptSubmitEvent extends BaseEvent {
+ type: 'user_prompt_submit'
+ prompt: string
+}
+
+export type ClaudeEvent =
+ | PreToolUseEvent
+ | PostToolUseEvent
+ | StopEvent
+ | BaseEvent
+
+// ============================================================================
+// Visualization State (from types.ts)
+// ============================================================================
+
+export type ClaudeState = 'idle' | 'thinking' | 'working' | 'finished'
+
+export type StationType =
+ | 'center'
+ | 'bookshelf'
+ | 'desk'
+ | 'workbench'
+ | 'terminal'
+ | 'scanner'
+ | 'antenna'
+ | 'portal'
+ | 'taskboard'
+
+export const TOOL_STATION_MAP: Record = {
+ Read: 'bookshelf',
+ Write: 'desk',
+ Edit: 'workbench',
+ Bash: 'terminal',
+ Grep: 'scanner',
+ Glob: 'scanner',
+ WebFetch: 'antenna',
+ WebSearch: 'antenna',
+ Task: 'portal',
+ TodoWrite: 'taskboard',
+ AskUserQuestion: 'center',
+ NotebookEdit: 'desk',
+}
+
+export function getStationForTool(tool: string): StationType {
+ return TOOL_STATION_MAP[tool] ?? 'center'
+}
diff --git a/src/workshop/utils/HexGrid.ts b/src/workshop/utils/HexGrid.ts
new file mode 100644
index 0000000..b7f2aad
--- /dev/null
+++ b/src/workshop/utils/HexGrid.ts
@@ -0,0 +1,219 @@
+/**
+ * HexGrid — Vendored from Vibecraft2. Hex coordinate math for zone placement.
+ */
+
+export interface HexCoord {
+ q: number
+ r: number
+}
+
+interface CubeCoord {
+ x: number
+ y: number
+ z: number
+}
+
+const HEX_DIRECTIONS: HexCoord[] = [
+ { q: 1, r: 0 },
+ { q: 1, r: -1 },
+ { q: 0, r: -1 },
+ { q: -1, r: 0 },
+ { q: -1, r: 1 },
+ { q: 0, r: 1 },
+]
+
+export class HexGrid {
+ readonly hexRadius: number
+ readonly spacing: number
+ readonly hexWidth: number
+ readonly hexHeight: number
+
+ private occupied = new Map()
+ private sessionToHex = new Map()
+ private spiralIndex = 0
+
+ constructor(hexRadius = 10, spacing = 1.1) {
+ this.hexRadius = hexRadius
+ this.spacing = spacing
+ this.hexWidth = Math.sqrt(3) * hexRadius * spacing
+ this.hexHeight = 2 * hexRadius * spacing
+ }
+
+ axialToCartesian(hex: HexCoord): { x: number; z: number } {
+ const x = this.hexWidth * (hex.q + hex.r / 2)
+ const z = this.hexHeight * (3 / 4) * hex.r
+ return { x, z }
+ }
+
+ cartesianToAxial(x: number, z: number): { q: number; r: number } {
+ const r = z / (this.hexHeight * 0.75)
+ const q = x / this.hexWidth - r / 2
+ return { q, r }
+ }
+
+ roundToHex(q: number, r: number): HexCoord {
+ const cube = this.axialToCube({ q, r })
+ let rx = Math.round(cube.x)
+ let ry = Math.round(cube.y)
+ let rz = Math.round(cube.z)
+
+ const dx = Math.abs(rx - cube.x)
+ const dy = Math.abs(ry - cube.y)
+ const dz = Math.abs(rz - cube.z)
+
+ if (dx > dy && dx > dz) rx = -ry - rz
+ else if (dy > dz) ry = -rx - rz
+ else rz = -rx - ry
+
+ return this.cubeToAxial({ x: rx, y: ry, z: rz })
+ }
+
+ cartesianToHex(x: number, z: number): HexCoord {
+ const { q, r } = this.cartesianToAxial(x, z)
+ return this.roundToHex(q, r)
+ }
+
+ private axialToCube(hex: HexCoord): CubeCoord {
+ return { x: hex.q, z: hex.r, y: -hex.q - hex.r }
+ }
+
+ private cubeToAxial(cube: CubeCoord): HexCoord {
+ return { q: cube.x, r: cube.z }
+ }
+
+ hexKey(hex: HexCoord): string {
+ return `${hex.q},${hex.r}`
+ }
+
+ parseHexKey(key: string): HexCoord {
+ const [q, r] = key.split(',').map(Number)
+ return { q, r }
+ }
+
+ getNeighbors(hex: HexCoord): HexCoord[] {
+ return HEX_DIRECTIONS.map(dir => ({
+ q: hex.q + dir.q,
+ r: hex.r + dir.r,
+ }))
+ }
+
+ distance(a: HexCoord, b: HexCoord): number {
+ const cubeA = this.axialToCube(a)
+ const cubeB = this.axialToCube(b)
+ return Math.max(
+ Math.abs(cubeA.x - cubeB.x),
+ Math.abs(cubeA.y - cubeB.y),
+ Math.abs(cubeA.z - cubeB.z)
+ )
+ }
+
+ equals(a: HexCoord, b: HexCoord): boolean {
+ return a.q === b.q && a.r === b.r
+ }
+
+ getHexesInRadius(center: HexCoord, radius: number): HexCoord[] {
+ const results: HexCoord[] = []
+ for (let q = -radius + 1; q < radius; q++) {
+ for (let r = Math.max(-radius + 1, -q - radius + 1); r < Math.min(radius, -q + radius); r++) {
+ results.push({ q: center.q + q, r: center.r + r })
+ }
+ }
+ return results
+ }
+
+ occupy(hex: HexCoord, sessionId: string): void {
+ const key = this.hexKey(hex)
+ this.occupied.set(key, sessionId)
+ this.sessionToHex.set(sessionId, key)
+ }
+
+ release(sessionId: string): void {
+ const key = this.sessionToHex.get(sessionId)
+ if (key) {
+ this.occupied.delete(key)
+ this.sessionToHex.delete(sessionId)
+ }
+ }
+
+ isOccupied(hex: HexCoord): boolean {
+ return this.occupied.has(this.hexKey(hex))
+ }
+
+ getOccupant(hex: HexCoord): string | undefined {
+ return this.occupied.get(this.hexKey(hex))
+ }
+
+ getSessionHex(sessionId: string): HexCoord | undefined {
+ const key = this.sessionToHex.get(sessionId)
+ return key ? this.parseHexKey(key) : undefined
+ }
+
+ get occupiedCount(): number {
+ return this.occupied.size
+ }
+
+ findNearestFree(target: HexCoord): HexCoord {
+ if (!this.isOccupied(target)) return target
+ for (let ring = 1; ring <= 50; ring++) {
+ for (const hex of this.getHexesInRing(target, ring)) {
+ if (!this.isOccupied(hex)) return hex
+ }
+ }
+ return target
+ }
+
+ getNextInSpiral(): HexCoord {
+ for (let i = this.spiralIndex; i < 1000; i++) {
+ const hex = this.indexToHexCoord(i)
+ if (!this.isOccupied(hex)) {
+ this.spiralIndex = i + 1
+ return hex
+ }
+ }
+ return { q: 0, r: 0 }
+ }
+
+ private getHexesInRing(center: HexCoord, ring: number): HexCoord[] {
+ if (ring === 0) return [center]
+ const results: HexCoord[] = []
+ let hex: HexCoord = { q: center.q + ring, r: center.r }
+ for (let side = 0; side < 6; side++) {
+ for (let step = 0; step < ring; step++) {
+ results.push({ ...hex })
+ hex = {
+ q: hex.q + HEX_DIRECTIONS[(side + 2) % 6].q,
+ r: hex.r + HEX_DIRECTIONS[(side + 2) % 6].r,
+ }
+ }
+ }
+ return results
+ }
+
+ private indexToHexCoord(index: number): HexCoord {
+ if (index === 0) return { q: 0, r: 0 }
+ let ring = 1
+ let ringStart = 1
+ while (ringStart + ring * 6 <= index) {
+ ringStart += ring * 6
+ ring++
+ }
+ const posInRing = index - ringStart
+ const side = Math.floor(posInRing / ring)
+ const posOnSide = posInRing % ring
+ let q = ring
+ let r = 0
+ for (let s = 0; s < side; s++) {
+ q += HEX_DIRECTIONS[(s + 2) % 6].q * ring
+ r += HEX_DIRECTIONS[(s + 2) % 6].r * ring
+ }
+ q += HEX_DIRECTIONS[(side + 2) % 6].q * posOnSide
+ r += HEX_DIRECTIONS[(side + 2) % 6].r * posOnSide
+ return { q, r }
+ }
+
+ clear(): void {
+ this.occupied.clear()
+ this.sessionToHex.clear()
+ this.spiralIndex = 0
+ }
+}
diff --git a/src/workshop/utils/ToolUtils.ts b/src/workshop/utils/ToolUtils.ts
new file mode 100644
index 0000000..95400c8
--- /dev/null
+++ b/src/workshop/utils/ToolUtils.ts
@@ -0,0 +1,108 @@
+/**
+ * ToolUtils — Vendored from Vibecraft2. Tool display helpers.
+ */
+
+import { CATEGORY_ICON_MAP } from '../types'
+import type { ToolCategory } from '../types'
+
+export function getToolIcon(tool: string): string {
+ const icons: Record = {
+ Read: '\u{1F4D6}',
+ Edit: '\u270F\uFE0F',
+ Write: '\u{1F4DD}',
+ Bash: '\u{1F4BB}',
+ Grep: '\u{1F50D}',
+ Glob: '\u{1F4C1}',
+ WebFetch: '\u{1F310}',
+ WebSearch: '\u{1F50E}',
+ Task: '\u{1F916}',
+ TodoWrite: '\u{1F4CB}',
+ NotebookEdit: '\u{1F4D3}',
+ AskFollowupQuestion: '\u2753',
+ }
+ return icons[tool] ?? '\u{1F527}'
+}
+
+export function getToolIconByCategory(category: ToolCategory): string {
+ return CATEGORY_ICON_MAP[category] ?? '\u{1F527}'
+}
+
+export function getToolContext(tool: string, input: Record): string | null {
+ switch (tool) {
+ case 'Read':
+ case 'Write':
+ case 'Edit':
+ case 'NotebookEdit': {
+ const path = (input.file_path || input.notebook_path) as string
+ return path ? (path.split('/').pop() || path) : null
+ }
+ case 'Bash': {
+ const cmd = input.command as string
+ if (cmd) {
+ const firstLine = cmd.split('\n')[0]
+ return firstLine.length > 30 ? firstLine.slice(0, 30) + '...' : firstLine
+ }
+ return null
+ }
+ case 'Grep': {
+ const pattern = input.pattern as string
+ return pattern ? `/${pattern}/` : null
+ }
+ case 'Glob':
+ return (input.pattern as string) || null
+ case 'WebFetch': {
+ const url = input.url as string
+ if (url) {
+ try { return new URL(url).hostname } catch { return url.slice(0, 30) }
+ }
+ return null
+ }
+ case 'WebSearch':
+ return input.query ? `"${input.query}"` : null
+ case 'Task':
+ return (input.description as string) || null
+ case 'TodoWrite':
+ return 'Updating tasks'
+ default:
+ return null
+ }
+}
+
+export function getToolContextByCategory(
+ category: ToolCategory,
+ input: Record,
+): string | null {
+ switch (category) {
+ case 'read':
+ case 'write':
+ case 'edit': {
+ const path = (input.file_path || input.path || input.filename) as string
+ return path ? (path.split('/').pop() || path) : null
+ }
+ case 'execute': {
+ const cmd = (input.command || input.cmd) as string
+ if (cmd) {
+ const firstLine = cmd.split('\n')[0]
+ return firstLine.length > 30 ? firstLine.slice(0, 30) + '...' : firstLine
+ }
+ return null
+ }
+ case 'search': {
+ const pattern = (input.pattern || input.query || input.search) as string
+ return pattern ? `/${pattern}/` : null
+ }
+ case 'network': {
+ const url = (input.url || input.endpoint) as string
+ if (url) {
+ try { return new URL(url).hostname } catch { return url.slice(0, 30) }
+ }
+ return null
+ }
+ case 'delegate':
+ return (input.description || input.task) as string || null
+ case 'plan':
+ return (input.description || input.task) as string || null
+ default:
+ return null
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4472402..169bc7b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -22,6 +22,7 @@ export default defineConfig(async () => {
manualChunks(id: string) {
if (!id.includes('node_modules')) return
if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/scheduler/')) return 'vendor-react'
+ if (id.includes('/three/')) return 'vendor-three'
if (id.includes('@radix-ui')) return 'vendor-radix'
if (id.includes('i18next')) return 'vendor-i18n'
if (