diff --git a/studio/index.html b/studio/index.html
new file mode 100644
index 000000000..f88064e6f
--- /dev/null
+++ b/studio/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Hyperview Studio
+
+
+
+
+
+
diff --git a/studio/package-lock.json b/studio/package-lock.json
new file mode 100644
index 000000000..2e1a03e62
--- /dev/null
+++ b/studio/package-lock.json
@@ -0,0 +1,1564 @@
+{
+ "name": "hyperview-studio",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hyperview-studio",
+ "version": "0.1.0",
+ "dependencies": {
+ "@craftjs/core": "^0.2.12",
+ "@monaco-editor/react": "^4.7.0",
+ "lucide-react": "^1.8.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "ws": "^8.20.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.2.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/ws": "^8.18.1",
+ "@vitejs/plugin-react": "^6.0.1",
+ "tailwindcss": "^4.2.2",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.8"
+ }
+ },
+ "node_modules/@craftjs/core": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
+ "integrity": "sha512-M9WZ6SuvGw6Ue2iXouPiqLzzsxOynn1HVugzlm8q98ulMdhn0BDI3XZdD3ErUdUe4kk4y2Ak9E0dtzVjC51JLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@craftjs/utils": "^0.2.5",
+ "debounce": "^1.2.0",
+ "lodash": "^4.17.21",
+ "tiny-invariant": "^1.0.6"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/@craftjs/utils": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/@craftjs/utils/-/utils-0.2.5.tgz",
+ "integrity": "sha512-ANzAkGmQBe6fi7F2x6dhiN2hN9yBDfW+mQ2XJv8bJMIx+0h5Kbv04U4WXaFfMhjcC7DzfPnGkdg7/9gFkd5qVA==",
+ "license": "MIT",
+ "dependencies": {
+ "immer": "^9.0.6",
+ "lodash": "^4.17.21",
+ "nanoid": "^3.1.23",
+ "shallowequal": "^1.1.0",
+ "tiny-invariant": "^1.0.6"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+ "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
+ "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
+ "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
+ "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-x64": "4.2.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
+ "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
+ "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
+ "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
+ "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
+ "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
+ "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
+ "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
+ "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
+ "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
+ "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
+ "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
+ "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
+ "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.2.2",
+ "@tailwindcss/oxide": "4.2.2",
+ "tailwindcss": "4.2.2"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dompurify": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+ "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "peer": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.20.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
+ "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/immer": {
+ "version": "9.0.21",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
+ "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lucide-react": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/monaco-editor": {
+ "version": "0.55.1",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
+ "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "dompurify": "3.2.7",
+ "marked": "14.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+ "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
+ "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.15",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/studio/package.json b/studio/package.json
new file mode 100644
index 000000000..e9237d302
--- /dev/null
+++ b/studio/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "hyperview-studio",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@craftjs/core": "^0.2.12",
+ "@monaco-editor/react": "^4.7.0",
+ "lucide-react": "^1.8.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "ws": "^8.20.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.2.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/ws": "^8.18.1",
+ "@vitejs/plugin-react": "^6.0.1",
+ "tailwindcss": "^4.2.2",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.8"
+ }
+}
diff --git a/studio/src/App.tsx b/studio/src/App.tsx
new file mode 100644
index 000000000..f3180b32e
--- /dev/null
+++ b/studio/src/App.tsx
@@ -0,0 +1,138 @@
+import { Editor, Frame, Element } from '@craftjs/core';
+import {
+ HvDoc, HvScreen, HvBody, HvHeader, HvView, HvText,
+ HvImage, HvSpinner, HvList, HvItem, HvForm, HvTextField,
+ HvSelectSingle, HvSelectMultiple, HvOption, HvSwitch, HvDateField,
+} from './components';
+import {
+ MdsButton, MdsCard, MdsAlert, MdsIcon, MdsSeparator,
+ MdsPill, MdsTitlebar, MdsFooter, MdsCallout, MdsAvatar,
+ MdsListItem, MdsSkeleton, MdsBanner, MdsEmptyStatus,
+ MdsDialog, MdsBottomsheet, MdsCarousel, MdsCarouselItem,
+ MdsList, MdsText,
+} from './components/mds';
+import { Toolbox } from './editor/Toolbox';
+import { LayersTree } from './editor/LayersTree';
+import { PropertyPanel } from './editor/PropertyPanel';
+import { StyleCatalog } from './editor/StyleCatalog';
+import { Toolbar } from './editor/Toolbar';
+import { usePreviewSync } from './editor/usePreviewSync';
+import { useKeyboardShortcuts } from './editor/useKeyboardShortcuts';
+import { CodePanel } from './editor/CodePanel';
+import { useStyleStore } from './stores/styles';
+import { useEffect, useState } from 'react';
+
+const resolver = {
+ HvDoc, HvScreen, HvBody, HvHeader, HvView, HvText,
+ HvImage, HvSpinner, HvList, HvItem, HvForm, HvTextField,
+ HvSelectSingle, HvSelectMultiple, HvOption, HvSwitch, HvDateField,
+ MdsButton, MdsCard, MdsAlert, MdsIcon, MdsSeparator,
+ MdsPill, MdsTitlebar, MdsFooter, MdsCallout, MdsAvatar,
+ MdsListItem, MdsSkeleton, MdsBanner, MdsEmptyStatus,
+ MdsDialog, MdsBottomsheet, MdsCarousel, MdsCarouselItem,
+ MdsList, MdsText,
+};
+
+function EditorContent() {
+ usePreviewSync();
+ useKeyboardShortcuts();
+ const { setStyles } = useStyleStore();
+ const [showCode, setShowCode] = useState(false);
+
+ useEffect(() => {
+ function handleImport(e: Event) {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.styles) setStyles(detail.styles);
+ }
+ window.addEventListener('hv-import', handleImport);
+ return () => window.removeEventListener('hv-import', handleImport);
+ }, [setStyles]);
+
+ return (
+
+ {/* Header */}
+
+
+
+ H
+
+
Hyperview Studio
+
+
+
+
+ v0.3
+
+
+
+ {/* Main content */}
+
+ {/* Left sidebar */}
+
+
+ {/* Canvas */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right sidebar */}
+
+
+
+ {/* Code Panel (bottom split) */}
+ {showCode && (
+
+
+
+ )}
+
+ {/* Toolbar */}
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/studio/src/components/HvBody.tsx b/studio/src/components/HvBody.tsx
new file mode 100644
index 000000000..3f8723e21
--- /dev/null
+++ b/studio/src/components/HvBody.tsx
@@ -0,0 +1,28 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+}
+
+export function HvBody({ children }: Props) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="min-h-[300px] p-1"
+ >
+ {children}
+
+ );
+}
+
+HvBody.craft = {
+ displayName: 'Body',
+ rules: {
+ canDrag: () => false,
+ canMoveIn: (incoming: { data: { type: string } }[]) => {
+ const blocked = ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader'];
+ return incoming.every((n) => !blocked.includes(n.data.type));
+ },
+ },
+};
diff --git a/studio/src/components/HvDateField.tsx b/studio/src/components/HvDateField.tsx
new file mode 100644
index 000000000..f44854f2a
--- /dev/null
+++ b/studio/src/components/HvDateField.tsx
@@ -0,0 +1,38 @@
+import { useNode } from '@craftjs/core';
+
+interface HvDateFieldProps {
+ name?: string;
+ label?: string;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvDateField({ name, label, hvId, hvStyle }: HvDateFieldProps) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="border border-dashed border-gray-500 rounded px-3 py-2 my-1 bg-gray-800/30 hover:border-blue-400 transition-colors cursor-move"
+ >
+
+ date-field
+ {label && {label}}
+ {name && name={name}}
+
+
+
+ );
+}
+
+HvDateField.craft = {
+ displayName: 'DateField',
+ rules: {
+ canDrop: () => true,
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvDoc.tsx b/studio/src/components/HvDoc.tsx
new file mode 100644
index 000000000..0e1aef6b7
--- /dev/null
+++ b/studio/src/components/HvDoc.tsx
@@ -0,0 +1,26 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+}
+
+export function HvDoc({ children }: Props) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="min-h-full"
+ >
+ {children}
+
+ );
+}
+
+HvDoc.craft = {
+ displayName: 'Doc',
+ rules: {
+ canDrag: () => false,
+ canMoveIn: (incoming: { data: { type: string } }[]) =>
+ incoming.every((n) => n.data.type === 'HvScreen'),
+ },
+};
diff --git a/studio/src/components/HvForm.tsx b/studio/src/components/HvForm.tsx
new file mode 100644
index 000000000..2c0630a5e
--- /dev/null
+++ b/studio/src/components/HvForm.tsx
@@ -0,0 +1,35 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvForm({ children }: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`min-h-[60px] p-2 border border-dashed border-gray-300 rounded ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+ {children || (
+ Form (drop fields here)
+ )}
+
+ );
+}
+
+HvForm.craft = {
+ displayName: 'Form',
+ props: { hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) => {
+ const blocked = ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader', 'HvForm'];
+ return incoming.every((n) => !blocked.includes(n.data.type));
+ },
+ },
+};
diff --git a/studio/src/components/HvHeader.tsx b/studio/src/components/HvHeader.tsx
new file mode 100644
index 000000000..37c7fdb3c
--- /dev/null
+++ b/studio/src/components/HvHeader.tsx
@@ -0,0 +1,27 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+}
+
+export function HvHeader({ children }: Props) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="min-h-[44px] bg-gray-100 border-b border-gray-200 flex items-center px-4"
+ >
+ {children || Header}
+
+ );
+}
+
+HvHeader.craft = {
+ displayName: 'Header',
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) => {
+ const blocked = ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader'];
+ return incoming.every((n) => !blocked.includes(n.data.type));
+ },
+ },
+};
diff --git a/studio/src/components/HvImage.tsx b/studio/src/components/HvImage.tsx
new file mode 100644
index 000000000..ebd5a1cca
--- /dev/null
+++ b/studio/src/components/HvImage.tsx
@@ -0,0 +1,51 @@
+import { useNode } from '@craftjs/core';
+import { ImageIcon } from 'lucide-react';
+
+interface Props {
+ source?: string;
+ width?: number;
+ height?: number;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvImage({
+ source,
+ width = 200,
+ height = 150,
+}: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`flex items-center justify-center bg-gray-100 overflow-hidden ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ style={{ width, height }}
+ >
+ {source ? (
+

+ ) : (
+
+
+ Image
+
+ )}
+
+ );
+}
+
+HvImage.craft = {
+ displayName: 'Image',
+ props: {
+ source: '',
+ width: 200,
+ height: 150,
+ hvId: '',
+ hvStyle: '',
+ },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvItem.tsx b/studio/src/components/HvItem.tsx
new file mode 100644
index 000000000..305b643e0
--- /dev/null
+++ b/studio/src/components/HvItem.tsx
@@ -0,0 +1,36 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+ hvKey?: string;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvItem({ children }: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`min-h-[40px] border-b border-gray-100 p-2 ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+ {children || (
+ Item
+ )}
+
+ );
+}
+
+HvItem.craft = {
+ displayName: 'Item',
+ props: { hvKey: '', hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) => {
+ const blocked = ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader', 'HvList', 'HvItem'];
+ return incoming.every((n) => !blocked.includes(n.data.type));
+ },
+ },
+};
diff --git a/studio/src/components/HvList.tsx b/studio/src/components/HvList.tsx
new file mode 100644
index 000000000..589b2591e
--- /dev/null
+++ b/studio/src/components/HvList.tsx
@@ -0,0 +1,33 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvList({ children }: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`min-h-[60px] ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+ {children || (
+ List (drop items here)
+ )}
+
+ );
+}
+
+HvList.craft = {
+ displayName: 'List',
+ props: { hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) =>
+ incoming.every((n) => n.data.type === 'HvItem'),
+ },
+};
diff --git a/studio/src/components/HvOption.tsx b/studio/src/components/HvOption.tsx
new file mode 100644
index 000000000..bfdf67fad
--- /dev/null
+++ b/studio/src/components/HvOption.tsx
@@ -0,0 +1,34 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ value?: string;
+ label?: string;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvOption({
+ label = 'Option',
+ value = '',
+}: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+ {label || value || 'Option'}
+
+ );
+}
+
+HvOption.craft = {
+ displayName: 'Option',
+ props: { value: '', label: 'Option', hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvScreen.tsx b/studio/src/components/HvScreen.tsx
new file mode 100644
index 000000000..35085cfd4
--- /dev/null
+++ b/studio/src/components/HvScreen.tsx
@@ -0,0 +1,27 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ children?: React.ReactNode;
+}
+
+export function HvScreen({ children }: Props) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="min-h-[400px] border border-[var(--border-color)] rounded-lg overflow-hidden bg-white"
+ style={{ maxWidth: 390, margin: '0 auto' }}
+ >
+ {children}
+
+ );
+}
+
+HvScreen.craft = {
+ displayName: 'Screen',
+ rules: {
+ canDrag: () => false,
+ canMoveIn: (incoming: { data: { type: string } }[]) =>
+ incoming.every((n) => ['HvBody', 'HvHeader'].includes(n.data.type)),
+ },
+};
diff --git a/studio/src/components/HvSelectMultiple.tsx b/studio/src/components/HvSelectMultiple.tsx
new file mode 100644
index 000000000..ea44f613f
--- /dev/null
+++ b/studio/src/components/HvSelectMultiple.tsx
@@ -0,0 +1,38 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface HvSelectMultipleProps {
+ name?: string;
+ hvId?: string;
+ hvStyle?: string;
+ children?: ReactNode;
+}
+
+export function HvSelectMultiple({ name, children }: HvSelectMultipleProps) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="border border-dashed border-yellow-600 rounded p-2 my-1 bg-yellow-900/10 hover:border-yellow-400 transition-colors cursor-move"
+ >
+
+ select-multiple
+ {name && name={name}}
+
+
+ {children}
+
+
+ );
+}
+
+HvSelectMultiple.craft = {
+ displayName: 'SelectMultiple',
+ rules: {
+ canDrop: () => true,
+ canMoveIn: (nodes: any[]) => nodes.every(
+ (n: any) => n.data.type === 'HvOption' || n.data.displayName === 'Option'
+ ),
+ },
+};
diff --git a/studio/src/components/HvSelectSingle.tsx b/studio/src/components/HvSelectSingle.tsx
new file mode 100644
index 000000000..52f6af358
--- /dev/null
+++ b/studio/src/components/HvSelectSingle.tsx
@@ -0,0 +1,34 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ name?: string;
+ children?: React.ReactNode;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvSelectSingle({ children }: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`min-h-[40px] border border-gray-300 rounded p-2 ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+ {children || (
+ Select (drop options here)
+ )}
+
+ );
+}
+
+HvSelectSingle.craft = {
+ displayName: 'Select Single',
+ props: { name: '', hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) =>
+ incoming.every((n) => n.data.type === 'HvOption'),
+ },
+};
diff --git a/studio/src/components/HvSpinner.tsx b/studio/src/components/HvSpinner.tsx
new file mode 100644
index 000000000..dd44311a3
--- /dev/null
+++ b/studio/src/components/HvSpinner.tsx
@@ -0,0 +1,30 @@
+import { useNode } from '@craftjs/core';
+import { Loader2 } from 'lucide-react';
+
+interface Props {
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvSpinner(_props: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`flex items-center justify-center p-4 ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+
+
+ );
+}
+
+HvSpinner.craft = {
+ displayName: 'Spinner',
+ props: { hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvSwitch.tsx b/studio/src/components/HvSwitch.tsx
new file mode 100644
index 000000000..d5f20158c
--- /dev/null
+++ b/studio/src/components/HvSwitch.tsx
@@ -0,0 +1,37 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ name?: string;
+ value?: boolean;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvSwitch({ value = false }: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`inline-flex items-center ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+
+
+ );
+}
+
+HvSwitch.craft = {
+ displayName: 'Switch',
+ props: { name: '', value: false, hvId: '', hvStyle: '' },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvText.tsx b/studio/src/components/HvText.tsx
new file mode 100644
index 000000000..42d22253e
--- /dev/null
+++ b/studio/src/components/HvText.tsx
@@ -0,0 +1,66 @@
+import { useNode } from '@craftjs/core';
+import { useCallback } from 'react';
+
+interface Props {
+ text?: string;
+ fontSize?: number;
+ fontWeight?: string;
+ color?: string;
+ textAlign?: string;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvText({
+ text = 'Text',
+ fontSize = 16,
+ fontWeight = 'normal',
+ color = '#000000',
+ textAlign = 'left',
+}: Props) {
+ const { connectors: { connect, drag }, selected, actions: { setProp } } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
+ const el = e.currentTarget;
+ el.contentEditable = 'true';
+ el.focus();
+ }, []);
+
+ const handleBlur = useCallback((e: React.FocusEvent) => {
+ e.currentTarget.contentEditable = 'false';
+ setProp((props: { text: string }) => {
+ props.text = e.currentTarget.textContent || '';
+ });
+ }, [setProp]);
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`inline-block min-w-[20px] cursor-text ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ style={{ fontSize, fontWeight, color, textAlign: textAlign as React.CSSProperties['textAlign'] }}
+ onDoubleClick={handleDoubleClick}
+ onBlur={handleBlur}
+ suppressContentEditableWarning
+ >
+ {text}
+
+ );
+}
+
+HvText.craft = {
+ displayName: 'Text',
+ props: {
+ text: 'Text',
+ fontSize: 16,
+ fontWeight: 'normal',
+ color: '#000000',
+ textAlign: 'left',
+ hvId: '',
+ hvStyle: '',
+ },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvTextField.tsx b/studio/src/components/HvTextField.tsx
new file mode 100644
index 000000000..ca654d06d
--- /dev/null
+++ b/studio/src/components/HvTextField.tsx
@@ -0,0 +1,45 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ name?: string;
+ placeholder?: string;
+ keyboardType?: string;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+export function HvTextField({
+ placeholder = 'Enter text...',
+}: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ >
+
+
+ );
+}
+
+HvTextField.craft = {
+ displayName: 'Text Field',
+ props: {
+ name: '',
+ placeholder: 'Enter text...',
+ keyboardType: 'default',
+ hvId: '',
+ hvStyle: '',
+ },
+ rules: {
+ canMoveIn: () => false,
+ },
+};
diff --git a/studio/src/components/HvView.tsx b/studio/src/components/HvView.tsx
new file mode 100644
index 000000000..51491bc4d
--- /dev/null
+++ b/studio/src/components/HvView.tsx
@@ -0,0 +1,68 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ flexDirection?: string;
+ alignItems?: string;
+ justifyContent?: string;
+ padding?: number;
+ margin?: number;
+ backgroundColor?: string;
+ borderRadius?: number;
+ hvId?: string;
+ hvStyle?: string;
+ children?: React.ReactNode;
+}
+
+export function HvView({
+ flexDirection = 'column',
+ alignItems,
+ justifyContent,
+ padding = 8,
+ margin = 0,
+ backgroundColor,
+ borderRadius = 0,
+ children,
+}: Props) {
+ const { connectors: { connect, drag }, selected } = useNode((state) => ({
+ selected: state.events.selected,
+ }));
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`min-h-[40px] transition-all ${selected ? 'ring-2 ring-[var(--accent)]' : ''}`}
+ style={{
+ display: 'flex',
+ flexDirection: flexDirection as React.CSSProperties['flexDirection'],
+ alignItems,
+ justifyContent,
+ padding,
+ margin,
+ backgroundColor: backgroundColor || undefined,
+ borderRadius,
+ }}
+ >
+ {children || (
+ View
+ )}
+
+ );
+}
+
+HvView.craft = {
+ displayName: 'View',
+ props: {
+ flexDirection: 'column',
+ padding: 8,
+ margin: 0,
+ borderRadius: 0,
+ hvId: '',
+ hvStyle: '',
+ },
+ rules: {
+ canMoveIn: (incoming: { data: { type: string } }[]) => {
+ const blocked = ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader'];
+ return incoming.every((n) => !blocked.includes(n.data.type));
+ },
+ },
+};
diff --git a/studio/src/components/index.ts b/studio/src/components/index.ts
new file mode 100644
index 000000000..b52b7c1b1
--- /dev/null
+++ b/studio/src/components/index.ts
@@ -0,0 +1,37 @@
+export { HvDoc } from './HvDoc';
+export { HvScreen } from './HvScreen';
+export { HvBody } from './HvBody';
+export { HvHeader } from './HvHeader';
+export { HvView } from './HvView';
+export { HvText } from './HvText';
+export { HvImage } from './HvImage';
+export { HvSpinner } from './HvSpinner';
+export { HvList } from './HvList';
+export { HvItem } from './HvItem';
+export { HvForm } from './HvForm';
+export { HvTextField } from './HvTextField';
+export { HvSelectSingle } from './HvSelectSingle';
+export { HvSelectMultiple } from './HvSelectMultiple';
+export { HvOption } from './HvOption';
+export { HvSwitch } from './HvSwitch';
+export { HvDateField } from './HvDateField';
+
+export const componentMap = {
+ HvDoc: 'doc',
+ HvScreen: 'screen',
+ HvBody: 'body',
+ HvHeader: 'header',
+ HvView: 'view',
+ HvText: 'text',
+ HvImage: 'image',
+ HvSpinner: 'spinner',
+ HvList: 'list',
+ HvItem: 'item',
+ HvForm: 'form',
+ HvTextField: 'text-field',
+ HvSelectSingle: 'select-single',
+ HvSelectMultiple: 'select-multiple',
+ HvOption: 'option',
+ HvSwitch: 'switch',
+ HvDateField: 'date-field',
+} as const;
diff --git a/studio/src/components/mds/MdsAlert.tsx b/studio/src/components/mds/MdsAlert.tsx
new file mode 100644
index 000000000..9cbfc8dda
--- /dev/null
+++ b/studio/src/components/mds/MdsAlert.tsx
@@ -0,0 +1,80 @@
+import { useNode, Element } from '@craftjs/core';
+import { HvView } from '../HvView';
+
+interface Props {
+ kind?: string;
+ title?: string;
+ showIcon?: boolean;
+ primaryLinkLabel?: string;
+ secondaryLinkLabel?: string;
+ hvId?: string;
+}
+
+const KIND_COLORS: Record = {
+ success: { bg: 'bg-green-900/30', border: 'border-green-600', text: 'text-green-300', iconLabel: 'check' },
+ danger: { bg: 'bg-red-900/30', border: 'border-red-600', text: 'text-red-300', iconLabel: 'alert' },
+ info: { bg: 'bg-blue-900/30', border: 'border-blue-600', text: 'text-blue-300', iconLabel: 'info' },
+ warning: { bg: 'bg-yellow-900/30', border: 'border-yellow-600', text: 'text-yellow-300', iconLabel: 'warn' },
+ neutral: { bg: 'bg-gray-800/50', border: 'border-gray-600', text: 'text-gray-300', iconLabel: 'info' },
+};
+
+export function MdsAlert({
+ kind = 'neutral', title, showIcon = false,
+ primaryLinkLabel, secondaryLinkLabel,
+}: Props) {
+ const { connectors: { connect, drag } } = useNode();
+ const colors = KIND_COLORS[kind] || KIND_COLORS.neutral;
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`rounded-lg border ${colors.bg} ${colors.border} p-3 my-1 cursor-move`}
+ >
+
+ {showIcon && (
+
+ {colors.iconLabel.slice(0, 1).toUpperCase()}
+
+ )}
+
+ {title &&
{title}
}
+
+
+
+
+
+ {(primaryLinkLabel || secondaryLinkLabel) && (
+
+ {primaryLinkLabel && (
+
+ )}
+ {secondaryLinkLabel && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function ContentHint() {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="text-[9px] text-gray-600 italic border border-dashed border-gray-700/40 rounded px-2 py-1 min-h-[20px]">
+ Alert content
+
+ );
+}
+ContentHint.craft = { displayName: 'Hint', rules: { canDrag: () => false } };
+
+MdsAlert.craft = {
+ displayName: 'MDS Alert',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsAvatar.tsx b/studio/src/components/mds/MdsAvatar.tsx
new file mode 100644
index 000000000..d65024d3d
--- /dev/null
+++ b/studio/src/components/mds/MdsAvatar.tsx
@@ -0,0 +1,52 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ source?: string;
+ size?: string;
+ type?: string;
+ hasBorder?: boolean;
+ counter?: number;
+ right?: number;
+ hvId?: string;
+}
+
+export function MdsAvatar({ source, size = '48', type = 'person', hasBorder = false, counter, right = 0 }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+ const px = Number(size) || 48;
+
+ if (counter !== undefined && counter !== null) {
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`rounded-full bg-indigo-600 flex items-center justify-center cursor-move shrink-0 text-white font-semibold ${hasBorder ? 'ring-2 ring-white/30' : ''}`}
+ style={{ width: px, height: px, minWidth: px, marginRight: right, fontSize: px * 0.35 }}
+ >
+ {counter}
+
+ );
+ }
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`rounded-full bg-gray-700 flex items-center justify-center cursor-move shrink-0 overflow-hidden ${hasBorder ? 'ring-2 ring-white/30' : ''}`}
+ style={{ width: px, height: px, marginRight: right }}
+ >
+ {source ? (
+
{source.split('/').pop()}
+ ) : (
+
+ )}
+
+ );
+}
+
+MdsAvatar.craft = {
+ displayName: 'MDS Avatar',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsBanner.tsx b/studio/src/components/mds/MdsBanner.tsx
new file mode 100644
index 000000000..2cfee4aac
--- /dev/null
+++ b/studio/src/components/mds/MdsBanner.tsx
@@ -0,0 +1,49 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ bannerType?: string;
+ title?: string;
+ subtitle?: string;
+ iconName?: string;
+ iconSize?: string;
+ iconColor?: string;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+const TYPE_COLORS: Record = {
+ PRIMARY: { bg: 'bg-indigo-900/40', text: 'text-indigo-300' },
+ SUCCESS: { bg: 'bg-green-900/40', text: 'text-green-300' },
+ WARNING: { bg: 'bg-yellow-900/40', text: 'text-yellow-300' },
+ DANGER: { bg: 'bg-red-900/40', text: 'text-red-300' },
+};
+
+export function MdsBanner({ bannerType = 'PRIMARY', title = 'Banner Title', subtitle, iconName, children }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+ const colors = TYPE_COLORS[bannerType] || TYPE_COLORS.PRIMARY;
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`flex items-center gap-3 px-4 py-3 ${colors.bg} rounded-lg my-1 cursor-move`}
+ >
+ {iconName && (
+
+ {iconName.slice(0, 3)}
+
+ )}
+
+
{title}
+ {subtitle &&
{subtitle}
}
+ {children}
+
+
+
+ );
+}
+
+MdsBanner.craft = {
+ displayName: 'MDS Banner',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsBottomsheet.tsx b/studio/src/components/mds/MdsBottomsheet.tsx
new file mode 100644
index 000000000..d96e5d145
--- /dev/null
+++ b/studio/src/components/mds/MdsBottomsheet.tsx
@@ -0,0 +1,69 @@
+import { useNode, Element } from '@craftjs/core';
+import { HvView } from '../HvView';
+
+interface Props {
+ visible?: boolean;
+ ctaLabel?: string;
+ ctaLayout?: string;
+ secondaryCtaLabel?: string;
+ toggleDuration?: number;
+ hvId?: string;
+}
+
+export function MdsBottomsheet({
+ visible = false, ctaLabel = 'Got it', ctaLayout = 'vertical',
+ secondaryCtaLabel,
+}: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`border border-dashed border-purple-500/50 rounded-xl my-1 cursor-move bg-gray-900 ${visible ? '' : 'opacity-60'}`}
+ >
+
+ worker-app:bottom-sheet
+ {!visible && (hidden)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ctaLabel}
+
+
+ {secondaryCtaLabel && (
+
+
+ {secondaryCtaLabel}
+
+
+ )}
+
+
+ );
+}
+
+function BodyHint() {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="text-[9px] text-gray-600 italic border border-dashed border-gray-700/40 rounded px-2 py-3 text-center min-h-[40px]">
+ Sheet content
+
+ );
+}
+BodyHint.craft = { displayName: 'Hint', rules: { canDrag: () => false } };
+
+MdsBottomsheet.craft = {
+ displayName: 'MDS Bottomsheet',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsButton.tsx b/studio/src/components/mds/MdsButton.tsx
new file mode 100644
index 000000000..491d89c93
--- /dev/null
+++ b/studio/src/components/mds/MdsButton.tsx
@@ -0,0 +1,65 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ label?: string;
+ variant?: string;
+ size?: string;
+ state?: string;
+ status?: string;
+ icon?: string;
+ iconTrailing?: boolean;
+ action?: string;
+ href?: string;
+ target?: string;
+ verb?: string;
+ labelLoading?: string;
+ hide?: boolean;
+ hvId?: string;
+}
+
+export function MdsButton({
+ label = 'Button', variant = 'primary', size = 'large',
+ state = 'enabled', status = 'default', icon, iconTrailing = false,
+ hide = false,
+}: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ const isDestructive = status === 'destructive';
+ const bgMap: Record = {
+ primary: isDestructive ? 'bg-red-600' : 'bg-indigo-600',
+ secondary: 'bg-gray-200',
+ tertiary: 'bg-transparent border border-gray-500',
+ };
+ const textMap: Record = {
+ primary: 'text-white',
+ secondary: isDestructive ? 'text-red-600' : 'text-gray-800',
+ tertiary: isDestructive ? 'text-red-400' : 'text-indigo-400',
+ };
+ const sizeMap: Record = {
+ small: 'px-3 py-1 text-xs',
+ medium: 'px-4 py-2 text-sm',
+ large: 'px-6 py-3 text-base',
+ };
+
+ const iconEl = icon ? (
+ {icon}
+ ) : null;
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`rounded-lg font-semibold text-center cursor-move flex items-center justify-center gap-1.5 ${bgMap[variant] || bgMap.primary} ${textMap[variant] || textMap.primary} ${sizeMap[size] || sizeMap.large} ${state === 'disabled' ? 'opacity-40' : ''} ${state === 'loading' ? 'animate-pulse' : ''} ${hide ? 'opacity-20' : ''}`}
+ >
+ {!iconTrailing && iconEl}
+ {state === 'loading' ? (
+ Loading...
+ ) : label}
+ {iconTrailing && iconEl}
+
+ );
+}
+
+MdsButton.craft = {
+ displayName: 'MDS Button',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsCallout.tsx b/studio/src/components/mds/MdsCallout.tsx
new file mode 100644
index 000000000..a49b60164
--- /dev/null
+++ b/studio/src/components/mds/MdsCallout.tsx
@@ -0,0 +1,47 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ iconName?: string;
+ iconColor?: string;
+ iconEmphasizeColor?: string;
+ backgroundColor?: string;
+ textAlign?: string;
+ size?: string;
+ numberOfLines?: number;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsCallout({
+ iconName, iconColor, backgroundColor, textAlign = 'left',
+ size = 'small', children,
+}: Props) {
+ const { connectors: { connect, drag } } = useNode();
+ const isLarge = size === 'large';
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`flex items-start gap-2 rounded-lg my-1 cursor-move ${isLarge ? 'p-4' : 'p-3'}`}
+ style={{ backgroundColor: backgroundColor || 'rgba(99,102,241,0.1)', textAlign: textAlign as any }}
+ >
+ {iconName && iconColor && (
+
+ {iconName.slice(0, 2)}
+
+ )}
+
+ {children || 'Callout text'}
+
+
+ );
+}
+
+MdsCallout.craft = {
+ displayName: 'MDS Callout',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsCard.tsx b/studio/src/components/mds/MdsCard.tsx
new file mode 100644
index 000000000..7dcd0a827
--- /dev/null
+++ b/studio/src/components/mds/MdsCard.tsx
@@ -0,0 +1,39 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ action?: string;
+ href?: string;
+ color?: string;
+ shadow?: boolean;
+ dismissible?: string;
+ loadEvent?: string;
+ pressEvent?: string;
+ pressedEvent?: string;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsCard({ children, shadow = false, color, dismissible, hvId }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`relative rounded-xl border border-gray-600 p-4 my-1 cursor-move ${shadow ? 'shadow-lg shadow-black/30' : ''}`}
+ style={{ backgroundColor: color || 'rgba(255,255,255,0.05)' }}
+ >
+ {dismissible && (
+
+
+
+ )}
+ {children ||
Drop content here
}
+
+ );
+}
+
+MdsCard.craft = {
+ displayName: 'MDS Card',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsCarousel.tsx b/studio/src/components/mds/MdsCarousel.tsx
new file mode 100644
index 000000000..736244c66
--- /dev/null
+++ b/studio/src/components/mds/MdsCarousel.tsx
@@ -0,0 +1,51 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ autoHeight?: boolean;
+ autoplay?: boolean;
+ height?: number;
+ pagination?: string;
+ peekItems?: boolean;
+ nextEventName?: string;
+ prevEventName?: string;
+ onActiveEventPrefix?: string;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsCarousel({ height, pagination, autoplay = false, children }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="border border-dashed border-cyan-500/50 rounded-lg my-1 cursor-move bg-cyan-900/10 overflow-hidden"
+ style={height ? { height } : undefined}
+ >
+
+ ui:carousel
+ {autoplay && (autoplay)}
+
+
+ {children || (
+
+ Drop carousel items here
+
+ )}
+
+ {pagination !== 'hide' && (
+
+ )}
+
+ );
+}
+
+MdsCarousel.craft = {
+ displayName: 'MDS Carousel',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsCarouselItem.tsx b/studio/src/components/mds/MdsCarouselItem.tsx
new file mode 100644
index 000000000..ab4a3fb0d
--- /dev/null
+++ b/studio/src/components/mds/MdsCarouselItem.tsx
@@ -0,0 +1,25 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsCarouselItem({ children }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="flex-shrink-0 w-[280px] border border-gray-700/50 rounded-lg bg-gray-800/30 cursor-move min-h-[40px] p-2"
+ >
+ {children ||
Slide content
}
+
+ );
+}
+
+MdsCarouselItem.craft = {
+ displayName: 'MDS CarouselItem',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsDialog.tsx b/studio/src/components/mds/MdsDialog.tsx
new file mode 100644
index 000000000..45399bbb8
--- /dev/null
+++ b/studio/src/components/mds/MdsDialog.tsx
@@ -0,0 +1,38 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ title?: string;
+ message?: string;
+ trigger?: string;
+ dialogOptions?: string;
+ hvId?: string;
+}
+
+export function MdsDialog({ title = 'Dialog Title', message = 'Are you sure?', trigger = 'press' }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="border border-dashed border-amber-600/50 rounded-lg p-3 my-1 cursor-move bg-amber-900/10"
+ >
+
+
+ behavior: alert ({trigger})
+
+
+
{title}
+
{message}
+
+ Cancel
+ OK
+
+
+
+ );
+}
+
+MdsDialog.craft = {
+ displayName: 'MDS Dialog',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsEmptyStatus.tsx b/studio/src/components/mds/MdsEmptyStatus.tsx
new file mode 100644
index 000000000..216b33dbe
--- /dev/null
+++ b/studio/src/components/mds/MdsEmptyStatus.tsx
@@ -0,0 +1,37 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ illustrationName?: string;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsEmptyStatus({ illustrationName, children }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="flex flex-col items-center justify-center py-12 px-6 cursor-move"
+ >
+
+ {illustrationName ? (
+ {illustrationName}
+ ) : (
+
+ )}
+
+
+ {children || 'Nothing to show'}
+
+
+ );
+}
+
+MdsEmptyStatus.craft = {
+ displayName: 'MDS EmptyStatus',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsFooter.tsx b/studio/src/components/mds/MdsFooter.tsx
new file mode 100644
index 000000000..c7d632db6
--- /dev/null
+++ b/studio/src/components/mds/MdsFooter.tsx
@@ -0,0 +1,27 @@
+import { useNode } from '@craftjs/core';
+import type { ReactNode } from 'react';
+
+interface Props {
+ sticky?: boolean;
+ hvId?: string;
+ children?: ReactNode;
+}
+
+export function MdsFooter({ children, sticky = true }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`px-4 py-3 border-t border-gray-700 bg-gray-800/80 cursor-move ${sticky ? 'mt-auto' : ''}`}
+ >
+
mds:footer
+ {children ||
Footer content
}
+
+ );
+}
+
+MdsFooter.craft = {
+ displayName: 'MDS Footer',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsIcon.tsx b/studio/src/components/mds/MdsIcon.tsx
new file mode 100644
index 000000000..272e7e60a
--- /dev/null
+++ b/studio/src/components/mds/MdsIcon.tsx
@@ -0,0 +1,31 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ name?: string;
+ size?: string;
+ color?: string;
+ hvId?: string;
+}
+
+export function MdsIcon({ name = 'star', size = '24', color }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="inline-flex items-center justify-center cursor-move hover:ring-1 hover:ring-indigo-400 rounded"
+ style={{ width: `${size}px`, height: `${size}px`, color: color || '#9ca3af' }}
+ title={`icon: ${name}`}
+ >
+
+
+ );
+}
+
+MdsIcon.craft = {
+ displayName: 'MDS Icon',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsList.tsx b/studio/src/components/mds/MdsList.tsx
new file mode 100644
index 000000000..38895d447
--- /dev/null
+++ b/studio/src/components/mds/MdsList.tsx
@@ -0,0 +1,74 @@
+import { useNode, Element } from '@craftjs/core';
+import { HvView } from '../HvView';
+
+interface Props {
+ href?: string;
+ target?: string;
+ hvId?: string;
+}
+
+export function MdsList({ hvId }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="border border-dashed border-emerald-500/50 rounded-lg my-1 cursor-move bg-emerald-900/5"
+ >
+
list
+
+
+
Title
+
+ {/* title slot */}
+
+
+
+
+
+
+
Item Template
+
+
+
+
+
+
+
+
+
+
+
Empty State
+
+ {/* empty slot */}
+
+
+
+ );
+}
+
+function ItemHint() {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="text-[9px] text-emerald-600/60 italic border border-dashed border-emerald-700/30 rounded px-2 py-2 text-center">
+ Design one list item here
+
+ );
+}
+ItemHint.craft = { displayName: 'Hint', rules: { canDrag: () => false } };
+
+MdsList.craft = {
+ displayName: 'MDS List',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsListItem.tsx b/studio/src/components/mds/MdsListItem.tsx
new file mode 100644
index 000000000..bc7138f6b
--- /dev/null
+++ b/studio/src/components/mds/MdsListItem.tsx
@@ -0,0 +1,59 @@
+import { useNode, Element } from '@craftjs/core';
+import { HvView } from '../HvView';
+
+interface Props {
+ hvId?: string;
+}
+
+export function MdsListItem(_props: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="flex flex-col border-b border-gray-700/50 cursor-move hover:bg-gray-800/20"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* bottom slot -- optional */}
+
+
+
+ );
+}
+
+function DropHint({ label }: { label: string }) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="flex items-center justify-center text-[9px] text-gray-600 italic border border-dashed border-gray-700/40 rounded px-2 py-1 min-w-[40px] min-h-[24px]"
+ >
+ {label}
+
+ );
+}
+
+DropHint.craft = { displayName: 'Hint', rules: { canDrag: () => false } };
+
+MdsListItem.craft = {
+ displayName: 'MDS ListItem',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/MdsPill.tsx b/studio/src/components/mds/MdsPill.tsx
new file mode 100644
index 000000000..425c858b8
--- /dev/null
+++ b/studio/src/components/mds/MdsPill.tsx
@@ -0,0 +1,26 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ text?: string;
+ iconName?: string;
+ hvId?: string;
+}
+
+export function MdsPill({ text = 'Label', iconName }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 text-xs font-medium cursor-move"
+ >
+ {iconName && {iconName.slice(0, 2)}}
+ {text}
+
+ );
+}
+
+MdsPill.craft = {
+ displayName: 'MDS Pill',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsSeparator.tsx b/studio/src/components/mds/MdsSeparator.tsx
new file mode 100644
index 000000000..e37043f14
--- /dev/null
+++ b/studio/src/components/mds/MdsSeparator.tsx
@@ -0,0 +1,27 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ size?: string;
+ variant?: string;
+ marginHorizontal?: number;
+ hvId?: string;
+}
+
+export function MdsSeparator({ size = 'small', variant = 'secondary', marginHorizontal = 24 }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className="cursor-move py-1"
+ style={{ paddingLeft: marginHorizontal, paddingRight: marginHorizontal }}
+ >
+
+
+ );
+}
+
+MdsSeparator.craft = {
+ displayName: 'MDS Separator',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsSkeleton.tsx b/studio/src/components/mds/MdsSkeleton.tsx
new file mode 100644
index 000000000..bf694773f
--- /dev/null
+++ b/studio/src/components/mds/MdsSkeleton.tsx
@@ -0,0 +1,32 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ variant?: string;
+ size?: string;
+ hvId?: string;
+}
+
+export function MdsSkeleton({ variant = 'text', size }: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ const variantClasses: Record = {
+ text: 'h-3 w-full rounded',
+ icon: 'h-6 w-6 rounded',
+ avatar: 'h-10 w-10 rounded-full',
+ filter: 'h-8 w-20 rounded-full',
+ };
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`bg-gray-700 animate-pulse cursor-move ${variantClasses[variant] || variantClasses.text}`}
+ style={size ? { width: size, height: size } : undefined}
+ title={`skeleton: ${variant}`}
+ />
+ );
+}
+
+MdsSkeleton.craft = {
+ displayName: 'MDS Skeleton',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsText.tsx b/studio/src/components/mds/MdsText.tsx
new file mode 100644
index 000000000..22d2cfe37
--- /dev/null
+++ b/studio/src/components/mds/MdsText.tsx
@@ -0,0 +1,75 @@
+import { useNode } from '@craftjs/core';
+
+interface Props {
+ content?: string;
+ textType?: string;
+ color?: string;
+ fontStyle?: string;
+ textAlign?: string;
+ numberOfLines?: number;
+ listType?: string;
+ listIndex?: number;
+ selectable?: boolean;
+ preformatted?: boolean;
+ hide?: boolean;
+ hvId?: string;
+ hvStyle?: string;
+}
+
+const TYPE_STYLES: Record
= {
+ h1: { size: 'text-3xl', weight: 'font-bold' },
+ h2: { size: 'text-2xl', weight: 'font-bold' },
+ h3: { size: 'text-xl', weight: 'font-bold' },
+ h4: { size: 'text-lg', weight: 'font-semibold' },
+ h5: { size: 'text-base', weight: 'font-semibold' },
+ h6: { size: 'text-sm', weight: 'font-semibold' },
+ h7: { size: 'text-xs', weight: 'font-semibold' },
+ b2: { size: 'text-base', weight: 'font-normal' },
+ b3: { size: 'text-sm', weight: 'font-normal' },
+ b4: { size: 'text-xs', weight: 'font-normal' },
+ b5: { size: 'text-[11px]', weight: 'font-normal' },
+ b6: { size: 'text-[10px]', weight: 'font-normal' },
+};
+
+export function MdsText({
+ content = 'Text', textType = 'b3', color, fontStyle = 'normal',
+ textAlign, numberOfLines, listType, listIndex, hide = false,
+}: Props) {
+ const { connectors: { connect, drag }, actions: { setProp } } = useNode();
+ const styles = TYPE_STYLES[textType] || TYPE_STYLES.b3;
+
+ const bullet = listType === 'unordered' ? '• ' :
+ listType === 'ordered' || listType === 'ordered_simple' ? `${listIndex || 1}. ` : '';
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`cursor-move ${styles.size} ${styles.weight} ${hide ? 'opacity-20' : ''}`}
+ style={{
+ color: color || '#e5e7eb',
+ fontStyle,
+ textAlign: textAlign as any,
+ ...(numberOfLines ? {
+ overflow: 'hidden',
+ display: '-webkit-box',
+ WebkitLineClamp: numberOfLines,
+ WebkitBoxOrient: 'vertical' as const,
+ } : {}),
+ }}
+ >
+ {bullet}
+ setProp((p: Record) => { p.content = e.currentTarget.textContent || ''; })}
+ >
+ {content}
+
+
+ );
+}
+
+MdsText.craft = {
+ displayName: 'MDS Text',
+ rules: { canMoveIn: () => false },
+};
diff --git a/studio/src/components/mds/MdsTitlebar.tsx b/studio/src/components/mds/MdsTitlebar.tsx
new file mode 100644
index 000000000..7ee281731
--- /dev/null
+++ b/studio/src/components/mds/MdsTitlebar.tsx
@@ -0,0 +1,61 @@
+import { useNode, Element } from '@craftjs/core';
+import { HvView } from '../HvView';
+
+interface Props {
+ title?: string;
+ showBack?: boolean;
+ main?: boolean;
+ progress?: string;
+ titleAlwaysOn?: boolean;
+ hvId?: string;
+}
+
+export function MdsTitlebar({
+ title = 'Screen Title', showBack = true, main = false, progress,
+}: Props) {
+ const { connectors: { connect, drag } } = useNode();
+
+ return (
+ { if (ref) connect(drag(ref)); }}
+ className={`flex flex-col bg-gray-800/60 border-b border-gray-700 cursor-move ${main ? 'py-4' : 'py-2.5'}`}
+ >
+
+
+ {showBack ? (
+
+ ) : (
+
+ {/* left slot */}
+
+ )}
+
+
+
+ {title}
+
+
+
+
+ {/* right slot */}
+
+
+
+ {progress !== undefined && progress !== '' && (
+
+ )}
+
+ );
+}
+
+MdsTitlebar.craft = {
+ displayName: 'MDS Titlebar',
+ rules: { canDrop: () => true },
+};
diff --git a/studio/src/components/mds/index.ts b/studio/src/components/mds/index.ts
new file mode 100644
index 000000000..e291d0e93
--- /dev/null
+++ b/studio/src/components/mds/index.ts
@@ -0,0 +1,43 @@
+export { MdsButton } from './MdsButton';
+export { MdsCard } from './MdsCard';
+export { MdsAlert } from './MdsAlert';
+export { MdsIcon } from './MdsIcon';
+export { MdsSeparator } from './MdsSeparator';
+export { MdsPill } from './MdsPill';
+export { MdsTitlebar } from './MdsTitlebar';
+export { MdsFooter } from './MdsFooter';
+export { MdsCallout } from './MdsCallout';
+export { MdsAvatar } from './MdsAvatar';
+export { MdsListItem } from './MdsListItem';
+export { MdsSkeleton } from './MdsSkeleton';
+export { MdsBanner } from './MdsBanner';
+export { MdsEmptyStatus } from './MdsEmptyStatus';
+export { MdsDialog } from './MdsDialog';
+export { MdsBottomsheet } from './MdsBottomsheet';
+export { MdsCarousel } from './MdsCarousel';
+export { MdsCarouselItem } from './MdsCarouselItem';
+export { MdsList } from './MdsList';
+export { MdsText } from './MdsText';
+
+export const mdsComponentMap: Record = {
+ MdsButton: 'mds:button',
+ MdsCard: 'mds:card',
+ MdsAlert: 'mds:alert',
+ MdsIcon: 'mds:icon',
+ MdsSeparator: 'mds:separator',
+ MdsPill: 'mds:pill',
+ MdsTitlebar: 'mds:titlebar',
+ MdsFooter: 'mds:footer',
+ MdsCallout: 'mds:callout',
+ MdsAvatar: 'mds:avatar',
+ MdsListItem: 'mds:list-item',
+ MdsSkeleton: 'mds:skeleton',
+ MdsBanner: 'mds:banner',
+ MdsEmptyStatus: 'mds:empty-status',
+ MdsDialog: 'mds:dialog',
+ MdsBottomsheet: 'worker-app:bottom-sheet',
+ MdsCarousel: 'ui:carousel',
+ MdsCarouselItem: 'ui:carousel-item',
+ MdsList: 'mds:list',
+ MdsText: 'mds:text',
+};
diff --git a/studio/src/components/mds/types.ts b/studio/src/components/mds/types.ts
new file mode 100644
index 000000000..10f9b1c50
--- /dev/null
+++ b/studio/src/components/mds/types.ts
@@ -0,0 +1,68 @@
+export const MDS_NS = 'https://instawork.com/hyperview-mds';
+export const WORKER_APP_NS = 'https://instawork.com/hyperview-worker-app';
+export const UI_NS = 'https://instawork.com/hyperview-ui';
+export const ICON_NS = 'https://instawork.com/hyperview-icon';
+export const NAV_NS = 'https://instawork.com/hyperview-nav';
+
+export const MDS_VARIANTS = ['primary', 'secondary', 'tertiary'] as const;
+export const MDS_BUTTON_STATUSES = ['default', 'destructive'] as const;
+export const MDS_STATES = ['enabled', 'disabled', 'loading'] as const;
+export const MDS_SIZES = ['small', 'medium', 'large'] as const;
+export const MDS_VERBS = ['get', 'post'] as const;
+
+export const MDS_ALERT_KINDS = ['success', 'danger', 'info', 'neutral', 'warning'] as const;
+export const MDS_BANNER_TYPES = ['WARNING', 'SUCCESS', 'PRIMARY', 'DANGER'] as const;
+export const MDS_DIALOG_STYLES = ['default', 'cancel', 'destructive'] as const;
+export const MDS_LAYOUTS = ['none', 'horizontal', 'vertical'] as const;
+
+export const MDS_AVATAR_SIZES = ['24', '32', '40', '48', '56', '64', '72', '96', '128'] as const;
+export const MDS_AVATAR_TYPES = ['person', 'business'] as const;
+
+export const MDS_ICON_SIZES = ['8', '12', '16', '20', '24', '32', '36', '40', '48', '64', '80', '96', '128', '196'] as const;
+
+export const MDS_TEXT_TYPES = ['b2', 'b3', 'b4', 'b5', 'b6', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7'] as const;
+export const MDS_FONT_STYLES = ['normal', 'italic'] as const;
+export const MDS_TEXT_ALIGNS = ['auto', 'left', 'right', 'center', 'justify'] as const;
+export const MDS_LIST_TYPES = ['unordered', 'ordered', 'ordered_simple'] as const;
+
+export const MDS_SKELETON_VARIANTS = ['text', 'icon', 'avatar', 'filter'] as const;
+export const MDS_TOAST_TYPES = ['positive', 'negative', 'neutral'] as const;
+
+export const MDS_CAROUSEL_PAGINATIONS = ['hide', 'overlay'] as const;
+
+export interface DialogOptionDef {
+ label: string;
+ style: string;
+}
+
+export interface AlertLinkDef {
+ label: string;
+}
+
+export const SLOT_DEFS: Record = {
+ MdsListItem: [
+ { id: 'left', label: 'Left' },
+ { id: 'content', label: 'Content' },
+ { id: 'right', label: 'Right' },
+ { id: 'bottom', label: 'Bottom' },
+ ],
+ MdsAlert: [
+ { id: 'content', label: 'Content' },
+ ],
+ MdsTitlebar: [
+ { id: 'leftContent', label: 'Left' },
+ { id: 'rightContent', label: 'Right' },
+ ],
+ MdsBottomsheet: [
+ { id: 'body', label: 'Body' },
+ { id: 'ctaChildren', label: 'Primary CTA' },
+ { id: 'secondaryCtaChildren', label: 'Secondary CTA' },
+ ],
+ MdsList: [
+ { id: 'header', label: 'Header' },
+ { id: 'itemTemplate', label: 'Item Template' },
+ { id: 'footer', label: 'Footer' },
+ { id: 'empty', label: 'Empty State' },
+ { id: 'title', label: 'Title' },
+ ],
+};
diff --git a/studio/src/components/types.ts b/studio/src/components/types.ts
new file mode 100644
index 000000000..599217c3a
--- /dev/null
+++ b/studio/src/components/types.ts
@@ -0,0 +1,34 @@
+export interface HvElementProps {
+ id?: string;
+ style?: string;
+ children?: React.ReactNode;
+ [key: string]: unknown;
+}
+
+export const HYPERVIEW_NS = 'https://hyperview.org/hyperview';
+
+export const ELEMENT_CATEGORIES = {
+ Structure: ['HvDoc', 'HvScreen', 'HvBody', 'HvHeader'],
+ Layout: ['HvView'],
+ Content: ['HvText', 'HvImage', 'HvSpinner'],
+ Lists: ['HvList', 'HvItem'],
+ Forms: ['HvForm', 'HvTextField', 'HvSelectSingle', 'HvOption', 'HvSwitch'],
+} as const;
+
+export interface ElementDefinition {
+ tagName: string;
+ displayName: string;
+ category: string;
+ canContain: string[] | '*';
+ canBeContainedIn: string[] | '*';
+ defaultProps: Record;
+ editableProps: PropDefinition[];
+}
+
+export interface PropDefinition {
+ name: string;
+ label: string;
+ type: 'text' | 'number' | 'select' | 'color' | 'boolean';
+ options?: string[];
+ group?: string;
+}
diff --git a/studio/src/editor/BehaviorEditor.tsx b/studio/src/editor/BehaviorEditor.tsx
new file mode 100644
index 000000000..bce5ea725
--- /dev/null
+++ b/studio/src/editor/BehaviorEditor.tsx
@@ -0,0 +1,294 @@
+import { Plus, Trash2, GripVertical, Zap, ChevronDown, ChevronRight } from 'lucide-react';
+import { useState } from 'react';
+import { useBehaviorStore, type HvBehaviorDef } from '../stores/behaviors';
+
+const TRIGGERS = [
+ 'press', 'longPress', 'load', 'visible', 'refresh', 'on-event',
+] as const;
+
+const ACTIONS = [
+ 'push', 'new', 'back', 'close', 'navigate', 'reload',
+ 'replace', 'replace-inner', 'append', 'prepend',
+ 'show', 'hide', 'toggle',
+ 'set-value', 'dispatch-event',
+ 'deep-link', 'open-settings', 'copy-to-clipboard',
+ 'select-all', 'unselect-all',
+ 'scroll', 'alert',
+] as const;
+
+const NAV_ACTIONS = new Set(['push', 'new', 'navigate', 'replace', 'replace-inner', 'append', 'prepend', 'reload']);
+const VISIBILITY_ACTIONS = new Set(['show', 'hide', 'toggle']);
+
+interface BehaviorCardProps {
+ nodeId: string;
+ behavior: HvBehaviorDef;
+ index: number;
+ allNodeIds: string[];
+}
+
+function BehaviorCard({ nodeId, behavior, index, allNodeIds }: BehaviorCardProps) {
+ const { updateBehavior, removeBehavior } = useBehaviorStore();
+ const [expanded, setExpanded] = useState(true);
+
+ const update = (updates: Partial) => {
+ updateBehavior(nodeId, behavior.id, updates);
+ };
+
+ const showHref = NAV_ACTIONS.has(behavior.action);
+ const showTarget = VISIBILITY_ACTIONS.has(behavior.action) || behavior.action === 'set-value';
+ const showEventName = behavior.trigger === 'on-event' || behavior.action === 'dispatch-event';
+ const showNewValue = behavior.action === 'set-value';
+ const showAlert = behavior.action === 'alert';
+ const showScroll = behavior.action === 'scroll';
+ const showLoadIndicators = showHref;
+
+ return (
+
+
setExpanded(!expanded)}
+ >
+
+ {expanded ? : }
+
+ {behavior.trigger}
+ {' → '}
+ {behavior.action}
+ {behavior.href && {behavior.href}}
+
+
+
+
+ {expanded && (
+
+ )}
+
+ );
+}
+
+interface BehaviorEditorProps {
+ nodeId: string;
+}
+
+export function BehaviorEditor({ nodeId }: BehaviorEditorProps) {
+ const { getBehaviors, addBehavior, getAllBehaviors } = useBehaviorStore();
+ const behaviors = getBehaviors(nodeId);
+
+ const allBehaviors = getAllBehaviors();
+ const allNodeIds = Object.keys(allBehaviors).reduce((acc, _nid) => acc, []);
+
+ return (
+
+
+
+
+ Behaviors
+ {behaviors.length > 0 && (
+
+ {behaviors.length}
+
+ )}
+
+
+
+
+ {behaviors.length === 0 && (
+
+ No behaviors. Click Add to configure interactions.
+
+ )}
+
+ {behaviors.map((bh, i) => (
+
+ ))}
+
+ );
+}
diff --git a/studio/src/editor/CodePanel.tsx b/studio/src/editor/CodePanel.tsx
new file mode 100644
index 000000000..c7c9b8aef
--- /dev/null
+++ b/studio/src/editor/CodePanel.tsx
@@ -0,0 +1,104 @@
+import { useEditor } from '@craftjs/core';
+import Editor from '@monaco-editor/react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { serializeToHxml } from '../serialization/serialize';
+import { deserializeHxml } from '../serialization/deserialize';
+import { useStyleStore } from '../stores/styles';
+import { useBehaviorStore } from '../stores/behaviors';
+import { Code2, RefreshCw, AlertCircle } from 'lucide-react';
+
+export function CodePanel() {
+ const { query, actions } = useEditor();
+ const { styles, setStyles } = useStyleStore();
+ const { getAllBehaviors } = useBehaviorStore();
+ const [code, setCode] = useState('');
+ const [error, setError] = useState(null);
+ const [syncing, setSyncing] = useState<'idle' | 'from-canvas' | 'from-code'>('idle');
+ const skipNextSync = useRef(false);
+
+ const syncFromCanvas = useCallback(() => {
+ if (skipNextSync.current) {
+ skipNextSync.current = false;
+ return;
+ }
+ try {
+ const json = query.serialize();
+ const nodes = JSON.parse(json);
+ const nodeMap: Record = {};
+ for (const [id, data] of Object.entries(nodes)) {
+ nodeMap[id] = { data };
+ }
+ const hxml = serializeToHxml(nodeMap, styles, getAllBehaviors());
+ setCode(hxml);
+ setError(null);
+ } catch (e) {
+ console.warn('[CodePanel] sync error:', e);
+ }
+ }, [query, styles, getAllBehaviors]);
+
+ useEffect(() => {
+ syncFromCanvas();
+ const timer = setInterval(syncFromCanvas, 2000);
+ return () => clearInterval(timer);
+ }, [syncFromCanvas]);
+
+ const applyCode = useCallback(() => {
+ setSyncing('from-code');
+ try {
+ const result = deserializeHxml(code);
+ skipNextSync.current = true;
+ actions.deserialize(result.nodes);
+ if (result.styles.length > 0) setStyles(result.styles);
+ setError(null);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Parse error');
+ } finally {
+ setSyncing('idle');
+ }
+ }, [code, actions, setStyles]);
+
+ return (
+
+
+
+
+ HXML Source
+
+
+
+
+ {error && (
+
+ )}
+
+
+ setCode(val || '')}
+ options={{
+ minimap: { enabled: false },
+ fontSize: 12,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ tabSize: 2,
+ automaticLayout: true,
+ padding: { top: 8 },
+ }}
+ />
+
+
+ );
+}
diff --git a/studio/src/editor/IconPicker.tsx b/studio/src/editor/IconPicker.tsx
new file mode 100644
index 000000000..9be2fa994
--- /dev/null
+++ b/studio/src/editor/IconPicker.tsx
@@ -0,0 +1,78 @@
+import { useState, useRef, useEffect } from 'react';
+
+const ICONS = ["activity","airplay","anchor","aperture","archive","arrow-down","arrow-down-left","arrow-down-right","arrow-left","arrow-right","arrow-up","arrow-up-left","arrow-up-right","at-sign","award","badge-dollar-sign","ban","banknote","banknote-arrow-down","bell","bell-off","bluetooth","bold","book","book-check","book-open","book-open-text","bookmark","box","briefcase","bus","cake","calendar","calendar-check","calendar-clock","calendar-days","calendar-sync","camera","camera-off","car","chart-bar","chart-bar-increasing","chart-pie","check","chevron-down","chevron-left","chevron-right","chevron-up","chevrons-down","chevrons-left","chevrons-right","chevrons-up","circle","circle-alert","circle-arrow-down","circle-arrow-left","circle-arrow-right","circle-arrow-up","circle-check","circle-check-big","circle-dollar-sign","circle-minus","circle-pause","circle-play","circle-plus","circle-question-mark","circle-slash","circle-small","circle-star","circle-stop","circle-user-round","circle-x","clipboard","clipboard-list","clipboard-pen","clock","clock-fading","cloud","cloud-download","cloud-drizzle","cloud-lightning","cloud-off","cloud-rain","cloud-snow","cloud-upload","code","coffee","columns-2","compass","contact-round","copy","corner-down-left","corner-down-right","corner-left-down","corner-left-up","corner-right-down","corner-right-up","corner-up-left","corner-up-right","credit-card","crop","crosshair","disc","dollar-sign","download","ellipsis","ellipsis-vertical","external-link","eye","eye-off","fast-forward","feather","file","file-badge","file-minus","file-plus","file-text","film","flag","flag-triangle-right","folder","folder-minus","folder-plus","frown","fuel","funnel","gift","globe","graduation-cap","hand-helping","handshake","hash","headphones","heart","hourglass","house","image","images","inbox","info","italic","key","landmark","layers","layout-grid","life-buoy","lightbulb","link","link-2","loader","lock","lock-keyhole","lock-open","log-in","log-out","mail","map","map-pin","map-pin-house","maximize","maximize-2","megaphone","meh","menu","message-circle","message-circle-question-mark","message-square","messages-square","mic","mic-off","minus","monitor","moon","mouse-pointer","mouse-pointer-click","move","music","navigation","navigation-2","notebook-pen","notebook-text","octagon","octagon-alert","octagon-pause","package","panel-left","panels-top-left","paperclip","party-popper","pause","pen-tool","pencil","pencil-line","percent","person-standing","phone","phone-call","phone-forwarded","phone-incoming","phone-missed","phone-off","phone-outgoing","plus","power","printer","qr-code","radio","refresh-ccw","refresh-cw","repeat","repeat-2","rewind","rotate-ccw","rotate-cw","rss","save","scissors","scroll-text","search","send","server","settings","share","share-2","shield","shield-check","shield-off","shield-user","shirt","shopping-bag","shopping-cart","signpost","skip-back","skip-forward","slash","sliders-horizontal","sliders-vertical","smartphone","smile","square","square-arrow-up-right","square-check","square-check-big","square-minus","square-pen","square-plus","square-x","star","store","sun","sunrise","sunset","table","tag","target","text-align-center","text-align-end","text-align-justify","text-align-start","thermometer","thumbs-down","thumbs-up","timer","toggle-left","toggle-right","trash","trash-2","tree-palm","trending-down","trending-up","triangle","triangle-alert","trophy","truck","tv","type","umbrella","underline","upload","user","user-check","user-lock","user-minus","user-plus","user-round","user-round-plus","user-round-x","user-star","user-x","users","users-round","video","wallet-minimal","watch","wifi","wifi-off","wind","wrench","x","zap","zap-off","zoom-in","zoom-out"];
+
+interface IconPickerProps {
+ value: string;
+ onChange: (val: string) => void;
+ label?: string;
+}
+
+export function IconPicker({ value, onChange, label = 'Icon' }: IconPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const inputRef = useRef(null);
+ const dropdownRef = useRef(null);
+
+ const filtered = search
+ ? ICONS.filter((i) => i.includes(search.toLowerCase()))
+ : ICONS;
+
+ useEffect(() => {
+ if (!open) return;
+ function handleClick(e: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, [open]);
+
+ return (
+
+
{label}
+
+ { onChange(e.target.value); setSearch(e.target.value); }}
+ onFocus={() => setOpen(true)}
+ placeholder="search icons..."
+ className="flex-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded px-2 py-1 text-sm text-[var(--text-primary)]"
+ />
+ {value && (
+
+ )}
+
+ {open && filtered.length > 0 && (
+
+ {filtered.slice(0, 60).map((icon) => (
+
+ ))}
+ {filtered.length > 60 && (
+
+ {filtered.length - 60} more — type to narrow
+
+ )}
+
+ )}
+
+ );
+}
+
+export { ICONS };
diff --git a/studio/src/editor/LayersTree.tsx b/studio/src/editor/LayersTree.tsx
new file mode 100644
index 000000000..bddf8fd45
--- /dev/null
+++ b/studio/src/editor/LayersTree.tsx
@@ -0,0 +1,78 @@
+import { useEditor } from '@craftjs/core';
+import { ChevronRight, ChevronDown, Layers } from 'lucide-react';
+import { useState, useCallback } from 'react';
+
+interface TreeNodeProps {
+ nodeId: string;
+ depth: number;
+}
+
+function TreeNode({ nodeId, depth }: TreeNodeProps) {
+ const { node, selectedId, actions } = useEditor((state, query) => {
+ const n = state.nodes[nodeId];
+ return {
+ node: n
+ ? {
+ displayName: n.data.displayName || (typeof n.data.type === 'string' ? n.data.type : 'Unknown'),
+ childNodes: n.data.nodes || [],
+ linkedNodes: n.data.linkedNodes ? Object.values(n.data.linkedNodes) : [],
+ }
+ : null,
+ selectedId: state.events.selected,
+ };
+ });
+
+ const [expanded, setExpanded] = useState(true);
+
+ const handleSelect = useCallback(() => {
+ actions.selectNode(nodeId);
+ }, [actions, nodeId]);
+
+ if (!node) return null;
+
+ const allChildren = [...node.childNodes, ...node.linkedNodes];
+ const hasChildren = allChildren.length > 0;
+ const isSelected = selectedId?.has(nodeId);
+
+ return (
+
+
+ {hasChildren ? (
+
+ ) : (
+
+ )}
+ {node.displayName}
+
+ {expanded && hasChildren && (
+
+ {allChildren.map((childId) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function LayersTree() {
+ return (
+
+ );
+}
diff --git a/studio/src/editor/PropertyPanel.tsx b/studio/src/editor/PropertyPanel.tsx
new file mode 100644
index 000000000..057086ee3
--- /dev/null
+++ b/studio/src/editor/PropertyPanel.tsx
@@ -0,0 +1,504 @@
+import { useEditor } from '@craftjs/core';
+import { Settings, Trash2 } from 'lucide-react';
+import { useCallback } from 'react';
+import { BehaviorEditor } from './BehaviorEditor';
+import { IconPicker } from './IconPicker';
+import { SlotIndicator } from './SlotIndicator';
+import { SLOT_DEFS } from '../components/mds/types';
+
+type PropType = 'text' | 'number' | 'select' | 'color' | 'boolean' | 'icon';
+
+function PropInput({
+ label, value, type = 'text', options, onChange,
+}: {
+ label: string;
+ value: string | number | boolean;
+ type?: PropType;
+ options?: string[];
+ onChange: (val: string | number | boolean) => void;
+}) {
+ if (type === 'icon') {
+ return onChange(v)} />;
+ }
+
+ if (type === 'boolean') {
+ return (
+
+ );
+ }
+
+ if (type === 'select' && options) {
+ return (
+
+ );
+ }
+
+ if (type === 'color') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+interface PropCfg {
+ label: string;
+ type: PropType;
+ options?: string[];
+ group?: string;
+}
+
+const PROP_CONFIGS: Record = {
+ HvView: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Flex Direction', type: 'select', options: ['row', 'column', 'row-reverse', 'column-reverse'], group: 'Layout' },
+ { label: 'Align Items', type: 'select', options: ['', 'flex-start', 'center', 'flex-end', 'stretch', 'baseline'], group: 'Layout' },
+ { label: 'Justify Content', type: 'select', options: ['', 'flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'], group: 'Layout' },
+ { label: 'Padding', type: 'number', group: 'Layout' },
+ { label: 'Margin', type: 'number', group: 'Layout' },
+ { label: 'Background Color', type: 'color', group: 'Appearance' },
+ { label: 'Border Radius', type: 'number', group: 'Appearance' },
+ ],
+ HvText: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Text', type: 'text', group: 'Content' },
+ { label: 'Font Size', type: 'number', group: 'Typography' },
+ { label: 'Font Weight', type: 'select', options: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], group: 'Typography' },
+ { label: 'Color', type: 'color', group: 'Typography' },
+ { label: 'Text Align', type: 'select', options: ['left', 'center', 'right'], group: 'Typography' },
+ ],
+ HvImage: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Source', type: 'text', group: 'Content' },
+ { label: 'Width', type: 'number', group: 'Size' },
+ { label: 'Height', type: 'number', group: 'Size' },
+ ],
+ HvTextField: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Name', type: 'text', group: 'Form' },
+ { label: 'Placeholder', type: 'text', group: 'Form' },
+ { label: 'Keyboard Type', type: 'select', options: ['default', 'number-pad', 'decimal-pad', 'numeric', 'email-address', 'phone-pad', 'url'], group: 'Form' },
+ ],
+ HvSwitch: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Name', type: 'text', group: 'Form' },
+ { label: 'Value', type: 'boolean', group: 'Form' },
+ ],
+ HvOption: [
+ { label: 'Value', type: 'text', group: 'Content' },
+ { label: 'Label', type: 'text', group: 'Content' },
+ ],
+ HvDateField: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Name', type: 'text', group: 'Form' },
+ { label: 'Label', type: 'text', group: 'Form' },
+ ],
+ HvSelectMultiple: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Name', type: 'text', group: 'Form' },
+ ],
+ HvSelectSingle: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Name', type: 'text', group: 'Form' },
+ ],
+
+ // ── MDS Components ─────────────────────────────────
+
+ MdsButton: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Label', type: 'text', group: 'Content' },
+ { label: 'Variant', type: 'select', options: ['primary', 'secondary', 'tertiary'], group: 'Appearance' },
+ { label: 'Size', type: 'select', options: ['small', 'medium', 'large'], group: 'Appearance' },
+ { label: 'State', type: 'select', options: ['enabled', 'disabled', 'loading'], group: 'Appearance' },
+ { label: 'Status', type: 'select', options: ['default', 'destructive'], group: 'Appearance' },
+ { label: 'Icon', type: 'icon', group: 'Content' },
+ { label: 'Icon Trailing', type: 'boolean', group: 'Content' },
+ { label: 'Label Loading', type: 'text', group: 'Content' },
+ { label: 'Hide', type: 'boolean', group: 'Appearance' },
+ { label: 'Action', type: 'select', options: ['', 'push', 'new', 'back', 'close', 'navigate', 'reload', 'replace', 'replace-inner'], group: 'Behavior' },
+ { label: 'Href', type: 'text', group: 'Behavior' },
+ { label: 'Target', type: 'text', group: 'Behavior' },
+ { label: 'Verb', type: 'select', options: ['', 'get', 'post'], group: 'Behavior' },
+ ],
+
+ MdsCard: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Shadow', type: 'boolean', group: 'Appearance' },
+ { label: 'Color', type: 'color', group: 'Appearance' },
+ { label: 'Dismissible', type: 'text', group: 'Behavior' },
+ { label: 'Action', type: 'select', options: ['', 'push', 'new', 'navigate', 'replace', 'replace-inner'], group: 'Behavior' },
+ { label: 'Href', type: 'text', group: 'Behavior' },
+ { label: 'Load Event', type: 'text', group: 'Events' },
+ { label: 'Press Event', type: 'text', group: 'Events' },
+ { label: 'Pressed Event', type: 'text', group: 'Events' },
+ ],
+
+ MdsAlert: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Kind', type: 'select', options: ['success', 'danger', 'info', 'neutral', 'warning'], group: 'Appearance' },
+ { label: 'Title', type: 'text', group: 'Content' },
+ { label: 'Show Icon', type: 'boolean', group: 'Appearance' },
+ { label: 'Primary Link Label', type: 'text', group: 'Links' },
+ { label: 'Secondary Link Label', type: 'text', group: 'Links' },
+ ],
+
+ MdsIcon: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Name', type: 'icon', group: 'Content' },
+ { label: 'Size', type: 'select', options: ['8', '12', '16', '20', '24', '32', '36', '40', '48', '64', '80', '96', '128'], group: 'Appearance' },
+ { label: 'Color', type: 'color', group: 'Appearance' },
+ ],
+
+ MdsSeparator: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Size', type: 'select', options: ['small', 'large'], group: 'Appearance' },
+ { label: 'Variant', type: 'select', options: ['primary', 'secondary'], group: 'Appearance' },
+ { label: 'Margin Horizontal', type: 'number', group: 'Layout' },
+ ],
+
+ MdsPill: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Text', type: 'text', group: 'Content' },
+ { label: 'Icon Name', type: 'icon', group: 'Content' },
+ ],
+
+ MdsTitlebar: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Title', type: 'text', group: 'Content' },
+ { label: 'Show Back', type: 'boolean', group: 'Navigation' },
+ { label: 'Main', type: 'boolean', group: 'Appearance' },
+ { label: 'Progress', type: 'text', group: 'Appearance' },
+ { label: 'Title Always On', type: 'boolean', group: 'Appearance' },
+ ],
+
+ MdsFooter: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Sticky', type: 'boolean', group: 'Layout' },
+ { label: 'Hide', type: 'boolean', group: 'Appearance' },
+ ],
+
+ MdsCallout: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Icon Name', type: 'icon', group: 'Content' },
+ { label: 'Icon Color', type: 'color', group: 'Appearance' },
+ { label: 'Icon Emphasize Color', type: 'color', group: 'Appearance' },
+ { label: 'Background Color', type: 'color', group: 'Appearance' },
+ { label: 'Text Align', type: 'select', options: ['left', 'center', 'right', 'justify'], group: 'Appearance' },
+ { label: 'Size', type: 'select', options: ['small', 'large'], group: 'Appearance' },
+ { label: 'Number Of Lines', type: 'number', group: 'Appearance' },
+ ],
+
+ MdsAvatar: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Source', type: 'text', group: 'Content' },
+ { label: 'Size', type: 'select', options: ['24', '32', '40', '48', '56', '64', '72', '96', '128'], group: 'Appearance' },
+ { label: 'Type', type: 'select', options: ['person', 'business'], group: 'Appearance' },
+ { label: 'Has Border', type: 'boolean', group: 'Appearance' },
+ { label: 'Counter', type: 'number', group: 'Content' },
+ { label: 'Right', type: 'number', group: 'Layout' },
+ ],
+
+ MdsListItem: [
+ { label: 'ID', type: 'text', group: 'General' },
+ ],
+
+ MdsSkeleton: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Variant', type: 'select', options: ['text', 'icon', 'avatar', 'filter'], group: 'Appearance' },
+ { label: 'Size', type: 'text', group: 'Appearance' },
+ ],
+
+ MdsBanner: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Banner Type', type: 'select', options: ['PRIMARY', 'SUCCESS', 'WARNING', 'DANGER'], group: 'Appearance' },
+ { label: 'Title', type: 'text', group: 'Content' },
+ { label: 'Subtitle', type: 'text', group: 'Content' },
+ { label: 'Icon Name', type: 'icon', group: 'Content' },
+ { label: 'Icon Size', type: 'text', group: 'Content' },
+ { label: 'Icon Color', type: 'color', group: 'Content' },
+ ],
+
+ MdsEmptyStatus: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Illustration Name', type: 'text', group: 'Content' },
+ ],
+
+ MdsDialog: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Title', type: 'text', group: 'Content' },
+ { label: 'Message', type: 'text', group: 'Content' },
+ { label: 'Trigger', type: 'select', options: ['press', 'longPress', 'load', 'visible', 'on-event'], group: 'Behavior' },
+ ],
+
+ MdsBottomsheet: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Visible', type: 'boolean', group: 'Appearance' },
+ { label: 'CTA Label', type: 'text', group: 'CTA' },
+ { label: 'CTA Layout', type: 'select', options: ['vertical', 'horizontal', 'none'], group: 'CTA' },
+ { label: 'Secondary CTA Label', type: 'text', group: 'CTA' },
+ { label: 'Toggle Duration', type: 'number', group: 'Animation' },
+ ],
+
+ MdsCarousel: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Auto Height', type: 'boolean', group: 'Layout' },
+ { label: 'Autoplay', type: 'boolean', group: 'Behavior' },
+ { label: 'Height', type: 'number', group: 'Layout' },
+ { label: 'Pagination', type: 'select', options: ['', 'hide', 'overlay'], group: 'Appearance' },
+ { label: 'Peek Items', type: 'boolean', group: 'Appearance' },
+ { label: 'Next Event Name', type: 'text', group: 'Events' },
+ { label: 'Prev Event Name', type: 'text', group: 'Events' },
+ { label: 'On Active Event Prefix', type: 'text', group: 'Events' },
+ ],
+
+ MdsCarouselItem: [
+ { label: 'ID', type: 'text', group: 'General' },
+ ],
+
+ MdsList: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Href', type: 'text', group: 'Data' },
+ { label: 'Target', type: 'text', group: 'Data' },
+ ],
+
+ MdsText: [
+ { label: 'ID', type: 'text', group: 'General' },
+ { label: 'Style', type: 'text', group: 'General' },
+ { label: 'Content', type: 'text', group: 'Content' },
+ { label: 'Text Type', type: 'select', options: ['b2', 'b3', 'b4', 'b5', 'b6', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7'], group: 'Typography' },
+ { label: 'Color', type: 'color', group: 'Typography' },
+ { label: 'Font Style', type: 'select', options: ['normal', 'italic'], group: 'Typography' },
+ { label: 'Text Align', type: 'select', options: ['', 'auto', 'left', 'right', 'center', 'justify'], group: 'Typography' },
+ { label: 'Number Of Lines', type: 'number', group: 'Typography' },
+ { label: 'List Type', type: 'select', options: ['', 'unordered', 'ordered', 'ordered_simple'], group: 'List' },
+ { label: 'List Index', type: 'number', group: 'List' },
+ { label: 'Selectable', type: 'boolean', group: 'Behavior' },
+ { label: 'Preformatted', type: 'boolean', group: 'Behavior' },
+ { label: 'Hide', type: 'boolean', group: 'Appearance' },
+ ],
+};
+
+const LABEL_TO_PROP: Record = {
+ 'ID': 'hvId',
+ 'Style': 'hvStyle',
+ 'Flex Direction': 'flexDirection',
+ 'Align Items': 'alignItems',
+ 'Justify Content': 'justifyContent',
+ 'Padding': 'padding',
+ 'Margin': 'margin',
+ 'Background Color': 'backgroundColor',
+ 'Border Radius': 'borderRadius',
+ 'Text': 'text',
+ 'Font Size': 'fontSize',
+ 'Font Weight': 'fontWeight',
+ 'Color': 'color',
+ 'Text Align': 'textAlign',
+ 'Source': 'source',
+ 'Width': 'width',
+ 'Height': 'height',
+ 'Name': 'name',
+ 'Placeholder': 'placeholder',
+ 'Keyboard Type': 'keyboardType',
+ 'Value': 'value',
+ 'Label': 'label',
+ 'Key': 'hvKey',
+ 'Variant': 'variant',
+ 'State': 'state',
+ 'Status': 'status',
+ 'Icon': 'icon',
+ 'Icon Trailing': 'iconTrailing',
+ 'Label Loading': 'labelLoading',
+ 'Hide': 'hide',
+ 'Action': 'action',
+ 'Href': 'href',
+ 'Target': 'target',
+ 'Verb': 'verb',
+ 'Shadow': 'shadow',
+ 'Dismissible': 'dismissible',
+ 'Load Event': 'loadEvent',
+ 'Press Event': 'pressEvent',
+ 'Pressed Event': 'pressedEvent',
+ 'Kind': 'kind',
+ 'Title': 'title',
+ 'Show Icon': 'showIcon',
+ 'Primary Link Label': 'primaryLinkLabel',
+ 'Secondary Link Label': 'secondaryLinkLabel',
+ 'Icon Name': 'iconName',
+ 'Icon Color': 'iconColor',
+ 'Icon Emphasize Color': 'iconEmphasizeColor',
+ 'Icon Size': 'iconSize',
+ 'Margin Horizontal': 'marginHorizontal',
+ 'Show Back': 'showBack',
+ 'Main': 'main',
+ 'Progress': 'progress',
+ 'Title Always On': 'titleAlwaysOn',
+ 'Sticky': 'sticky',
+ 'Type': 'type',
+ 'Has Border': 'hasBorder',
+ 'Counter': 'counter',
+ 'Right': 'right',
+ 'Banner Type': 'bannerType',
+ 'Subtitle': 'subtitle',
+ 'Illustration Name': 'illustrationName',
+ 'Size': 'size',
+ 'Content': 'content',
+ 'Text Type': 'textType',
+ 'Font Style': 'fontStyle',
+ 'Number Of Lines': 'numberOfLines',
+ 'List Type': 'listType',
+ 'List Index': 'listIndex',
+ 'Selectable': 'selectable',
+ 'Preformatted': 'preformatted',
+ 'Message': 'message',
+ 'Trigger': 'trigger',
+ 'Visible': 'visible',
+ 'CTA Label': 'ctaLabel',
+ 'CTA Layout': 'ctaLayout',
+ 'Secondary CTA Label': 'secondaryCtaLabel',
+ 'Toggle Duration': 'toggleDuration',
+ 'Auto Height': 'autoHeight',
+ 'Autoplay': 'autoplay',
+ 'Pagination': 'pagination',
+ 'Peek Items': 'peekItems',
+ 'Next Event Name': 'nextEventName',
+ 'Prev Event Name': 'prevEventName',
+ 'On Active Event Prefix': 'onActiveEventPrefix',
+};
+
+export function PropertyPanel() {
+ const { selectedNodeId, selectedNode, actions } = useEditor((state) => {
+ const [id] = state.events.selected || [];
+ return {
+ selectedNodeId: id,
+ selectedNode: id ? state.nodes[id] : null,
+ };
+ });
+
+ const handleDelete = useCallback(() => {
+ if (selectedNodeId) actions.delete(selectedNodeId);
+ }, [selectedNodeId, actions]);
+
+ if (!selectedNode) {
+ return (
+
+
+ Select an element to edit its properties
+
+ );
+ }
+
+ const typeName = typeof selectedNode.data.type === 'string'
+ ? selectedNode.data.type
+ : (selectedNode.data.type as any).resolvedName || selectedNode.data.displayName || 'Unknown';
+
+ const propConfigs = PROP_CONFIGS[typeName] || [];
+ const props = selectedNode.data.props || {};
+ const isDeletable = !['HvDoc', 'HvScreen', 'HvBody'].includes(typeName);
+ const slots = SLOT_DEFS[typeName] || [];
+
+ const groups = propConfigs.reduce>((acc, cfg) => {
+ const group = cfg.group || 'General';
+ if (!acc[group]) acc[group] = [];
+ acc[group].push(cfg);
+ return acc;
+ }, {});
+
+ return (
+
+
+
+ {selectedNode.data.displayName || typeName}
+
+ {isDeletable && (
+
+ )}
+
+
+ {slots.length > 0 && (
+
+
+
+ )}
+
+ {Object.entries(groups).map(([groupName, configs]) => (
+
+
+ {groupName}
+
+ {configs.map((cfg) => {
+ const propKey = LABEL_TO_PROP[cfg.label] || cfg.label.toLowerCase().replace(/\s/g, '');
+ return (
+
{
+ actions.setProp(selectedNodeId!, (p: Record) => {
+ p[propKey] = val;
+ });
+ }}
+ />
+ );
+ })}
+
+ ))}
+
+ {propConfigs.length === 0 && (
+
+ No editable properties for this element.
+
+ )}
+
+ {isDeletable && (
+
+
+
+ )}
+
+ );
+}
diff --git a/studio/src/editor/RepeatableForm.tsx b/studio/src/editor/RepeatableForm.tsx
new file mode 100644
index 000000000..aa7da2cb3
--- /dev/null
+++ b/studio/src/editor/RepeatableForm.tsx
@@ -0,0 +1,95 @@
+import { Plus, Trash2, GripVertical } from 'lucide-react';
+
+export interface FieldDef {
+ key: string;
+ label: string;
+ type: 'text' | 'select';
+ options?: string[];
+ placeholder?: string;
+}
+
+interface RepeatableFormProps> {
+ label: string;
+ items: T[];
+ fields: FieldDef[];
+ onAdd: () => void;
+ onUpdate: (index: number, updates: Partial) => void;
+ onRemove: (index: number) => void;
+ createEmpty?: () => T;
+}
+
+export function RepeatableForm>({
+ label,
+ items,
+ fields,
+ onAdd,
+ onUpdate,
+ onRemove,
+}: RepeatableFormProps) {
+ return (
+
+
+
+ {label}
+ {items.length > 0 && (
+
+ {items.length}
+
+ )}
+
+
+
+
+ {items.length === 0 && (
+
+ None configured. Click Add.
+
+ )}
+
+ {items.map((item, i) => (
+
+
+
+
+ #{i + 1}
+
+
+
+ {fields.map((field) => (
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/studio/src/editor/SlotIndicator.tsx b/studio/src/editor/SlotIndicator.tsx
new file mode 100644
index 000000000..c24b8d4d9
--- /dev/null
+++ b/studio/src/editor/SlotIndicator.tsx
@@ -0,0 +1,46 @@
+import { useEditor } from '@craftjs/core';
+import { LayoutGrid } from 'lucide-react';
+
+interface SlotIndicatorProps {
+ nodeId: string;
+ slots: { id: string; label: string }[];
+}
+
+export function SlotIndicator({ nodeId, slots }: SlotIndicatorProps) {
+ const { actions, linkedNodes } = useEditor((state) => {
+ const node = state.nodes[nodeId];
+ return { linkedNodes: node?.data.linkedNodes || {} };
+ });
+
+ if (slots.length === 0) return null;
+
+ return (
+
+
+
+ Slots
+
+ {slots.map((slot) => {
+ const linkedId = linkedNodes[slot.id];
+ const hasContent = !!linkedId;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/studio/src/editor/SlotRegion.tsx b/studio/src/editor/SlotRegion.tsx
new file mode 100644
index 000000000..f376c3a2c
--- /dev/null
+++ b/studio/src/editor/SlotRegion.tsx
@@ -0,0 +1,36 @@
+import { Element, useNode } from '@craftjs/core';
+import { HvView } from '../components/HvView';
+
+interface SlotRegionProps {
+ slotId: string;
+ label: string;
+ direction?: 'row' | 'column';
+ minHeight?: number;
+ optional?: boolean;
+}
+
+export function SlotRegion({ slotId, label, direction = 'column', minHeight = 32 }: SlotRegionProps) {
+ return (
+
+
+
+ );
+}
+
+function SlotPlaceholder({ label, minHeight }: { label: string; minHeight: number }) {
+ const { connectors: { connect } } = useNode();
+ return (
+ { if (ref) connect(ref); }}
+ className="flex items-center justify-center text-[10px] text-gray-500 italic border border-dashed border-gray-600/40 rounded"
+ style={{ minHeight }}
+ >
+ {label}
+
+ );
+}
+
+SlotPlaceholder.craft = {
+ displayName: 'Placeholder',
+ rules: { canDrag: () => false },
+};
diff --git a/studio/src/editor/StyleCatalog.tsx b/studio/src/editor/StyleCatalog.tsx
new file mode 100644
index 000000000..9019afd8e
--- /dev/null
+++ b/studio/src/editor/StyleCatalog.tsx
@@ -0,0 +1,215 @@
+import { Palette, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
+import { useState } from 'react';
+import { useStyleStore, type HvModifierDef } from '../stores/styles';
+
+const STYLE_PROPERTIES = [
+ { key: 'flex', label: 'Flex', type: 'text' },
+ { key: 'flexDirection', label: 'Flex Direction', type: 'select', options: ['row', 'column', 'row-reverse', 'column-reverse'] },
+ { key: 'alignItems', label: 'Align Items', type: 'select', options: ['flex-start', 'center', 'flex-end', 'stretch', 'baseline'] },
+ { key: 'justifyContent', label: 'Justify', type: 'select', options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around'] },
+ { key: 'padding', label: 'Padding', type: 'text' },
+ { key: 'margin', label: 'Margin', type: 'text' },
+ { key: 'width', label: 'Width', type: 'text' },
+ { key: 'height', label: 'Height', type: 'text' },
+ { key: 'backgroundColor', label: 'Background', type: 'color' },
+ { key: 'borderWidth', label: 'Border Width', type: 'text' },
+ { key: 'borderColor', label: 'Border Color', type: 'color' },
+ { key: 'borderRadius', label: 'Border Radius', type: 'text' },
+ { key: 'opacity', label: 'Opacity', type: 'text' },
+ { key: 'fontSize', label: 'Font Size', type: 'text' },
+ { key: 'fontWeight', label: 'Font Weight', type: 'select', options: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'] },
+ { key: 'color', label: 'Color', type: 'color' },
+ { key: 'textAlign', label: 'Text Align', type: 'select', options: ['left', 'center', 'right'] },
+] as const;
+
+const MODIFIER_STATES: HvModifierDef['state'][] = ['pressed', 'selected', 'focused'];
+
+function PropertyGrid({
+ properties,
+ onChange,
+}: {
+ properties: Record;
+ onChange: (props: Record) => void;
+}) {
+ return (
+
+ );
+}
+
+export function StyleCatalog() {
+ const { styles, addStyle, updateStyle, removeStyle, addModifier, updateModifier, removeModifier } = useStyleStore();
+ const [newName, setNewName] = useState('');
+ const [expandedId, setExpandedId] = useState(null);
+ const [expandedModifier, setExpandedModifier] = useState(null);
+
+ const handleAdd = () => {
+ const name = newName.trim();
+ if (!name) return;
+ addStyle(name);
+ setNewName('');
+ setExpandedId(name);
+ };
+
+ return (
+
+
+
+
+
setNewName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ placeholder="Style name..."
+ className="flex-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded px-2 py-1 text-xs text-[var(--text-primary)]"
+ />
+
+
+
+ {styles.map((style) => (
+
+
setExpandedId(expandedId === style.id ? null : style.id)}
+ >
+
+ {expandedId === style.id ? : }
+ {style.id}
+ {style.modifiers.length > 0 && (
+
+ {style.modifiers.length} state{style.modifiers.length > 1 ? 's' : ''}
+
+ )}
+
+
+
+
+ {expandedId === style.id && (
+
+ {/* Base properties */}
+
updateStyle(style.id, props)}
+ />
+
+ {/* Modifiers */}
+
+
+
State Modifiers
+
+ {MODIFIER_STATES.filter(
+ (s) => !style.modifiers.find((m) => m.state === s)
+ ).map((state) => (
+
+ ))}
+
+
+
+ {style.modifiers.map((mod) => {
+ const modKey = `${style.id}:${mod.state}`;
+ return (
+
+
setExpandedModifier(expandedModifier === modKey ? null : modKey)}
+ >
+
+ {expandedModifier === modKey ? : }
+ {mod.state}
+
+
+
+ {expandedModifier === modKey && (
+
+
updateModifier(style.id, mod.state, props)}
+ />
+
+ )}
+
+ );
+ })}
+
+ {style.modifiers.length === 0 && (
+
+ Add pressed, selected, or focused states above.
+
+ )}
+
+
+ )}
+
+ ))}
+
+ {styles.length === 0 && (
+
+ No styles defined. Add one above.
+
+ )}
+
+ );
+}
diff --git a/studio/src/editor/Toolbar.tsx b/studio/src/editor/Toolbar.tsx
new file mode 100644
index 000000000..a3cd19737
--- /dev/null
+++ b/studio/src/editor/Toolbar.tsx
@@ -0,0 +1,109 @@
+import { useEditor } from '@craftjs/core';
+import { Undo2, Redo2, Download, Upload, Monitor } from 'lucide-react';
+import { useCallback, useRef } from 'react';
+import { serializeToHxml } from '../serialization/serialize';
+import { useStyleStore } from '../stores/styles';
+import { useBehaviorStore } from '../stores/behaviors';
+
+export function Toolbar() {
+ const { actions, query, canUndo, canRedo } = useEditor((state, query) => ({
+ canUndo: query.history.canUndo(),
+ canRedo: query.history.canRedo(),
+ }));
+
+ const { styles } = useStyleStore();
+ const { getAllBehaviors } = useBehaviorStore();
+ const fileInputRef = useRef(null);
+
+ const handleExport = useCallback(() => {
+ const json = query.serialize();
+ const nodes = JSON.parse(json);
+ const nodeMap: Record = {};
+ for (const [id, data] of Object.entries(nodes)) {
+ nodeMap[id] = { data };
+ }
+ const hxml = serializeToHxml(nodeMap, styles, getAllBehaviors());
+
+ const blob = new Blob([hxml], { type: 'application/xml' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'screen.xml';
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [query, styles, getAllBehaviors]);
+
+ const handleImport = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleFileChange = useCallback(async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ try {
+ const text = await file.text();
+ const { deserializeHxml } = await import('../serialization/deserialize');
+ const result = deserializeHxml(text);
+ actions.deserialize(result.nodes);
+ // Dispatch custom event to load styles and behaviors
+ window.dispatchEvent(new CustomEvent('hv-import', { detail: result }));
+ } catch (err) {
+ console.error('[HV Studio] Import error:', err);
+ alert(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
+ }
+ e.target.value = '';
+ }, [actions]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Preview: /preview/screen.xml
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/studio/src/editor/Toolbox.tsx b/studio/src/editor/Toolbox.tsx
new file mode 100644
index 000000000..eb35639b2
--- /dev/null
+++ b/studio/src/editor/Toolbox.tsx
@@ -0,0 +1,167 @@
+import { useEditor, Element } from '@craftjs/core';
+import {
+ FileText, Layout, Type, Image, Loader2, List,
+ FormInput, TextCursorInput, ToggleLeft, CheckSquare, CircleDot,
+ PanelTop, Rows3, Calendar, ListChecks,
+ Package, MousePointerClick, CreditCard, AlertTriangle, Star,
+ Minus, Tag, PanelTopClose, ArrowDownFromLine, MessageSquare,
+ CircleUser, ListTodo, Bone, Flag, SearchX,
+ Layers, PanelBottomOpen, GalleryHorizontal, ListOrdered, TypeIcon,
+} from 'lucide-react';
+import { useState } from 'react';
+import { HvView } from '../components/HvView';
+import { HvText } from '../components/HvText';
+import { HvImage } from '../components/HvImage';
+import { HvSpinner } from '../components/HvSpinner';
+import { HvHeader } from '../components/HvHeader';
+import { HvList } from '../components/HvList';
+import { HvItem } from '../components/HvItem';
+import { HvForm } from '../components/HvForm';
+import { HvTextField } from '../components/HvTextField';
+import { HvSelectSingle } from '../components/HvSelectSingle';
+import { HvSelectMultiple } from '../components/HvSelectMultiple';
+import { HvOption } from '../components/HvOption';
+import { HvSwitch } from '../components/HvSwitch';
+import { HvDateField } from '../components/HvDateField';
+import {
+ MdsButton, MdsCard, MdsAlert, MdsIcon, MdsSeparator,
+ MdsPill, MdsTitlebar, MdsFooter, MdsCallout, MdsAvatar,
+ MdsListItem, MdsSkeleton, MdsBanner, MdsEmptyStatus,
+ MdsDialog, MdsBottomsheet, MdsCarousel, MdsCarouselItem,
+ MdsList, MdsText,
+} from '../components/mds';
+
+interface ToolboxCategory {
+ name: string;
+ items: { name: string; icon: React.ReactNode; element: React.ReactElement }[];
+}
+
+const categories: ToolboxCategory[] = [
+ {
+ name: 'Structure',
+ items: [
+ { name: 'Header', icon: , element: },
+ ],
+ },
+ {
+ name: 'Layout',
+ items: [
+ { name: 'View', icon: , element: },
+ ],
+ },
+ {
+ name: 'Content',
+ items: [
+ { name: 'Text', icon: , element: },
+ { name: 'Image', icon: , element: },
+ { name: 'Spinner', icon: , element: },
+ ],
+ },
+ {
+ name: 'Lists',
+ items: [
+ { name: 'List', icon:
, element: },
+ { name: 'Item', icon: , element: },
+ ],
+ },
+ {
+ name: 'Forms',
+ items: [
+ { name: 'Form', icon: , element: },
+ { name: 'Text Field', icon: , element: },
+ { name: 'Date Field', icon: , element: },
+ { name: 'Select', icon: , element: },
+ { name: 'Multi Select', icon: , element: },
+ { name: 'Option', icon: , element: },
+ { name: 'Switch', icon: , element: },
+ ],
+ },
+ {
+ name: 'MDS Actions',
+ items: [
+ { name: 'Button', icon: , element: },
+ { name: 'Dialog', icon: , element: },
+ ],
+ },
+ {
+ name: 'MDS Layout',
+ items: [
+ { name: 'Card', icon: , element: },
+ { name: 'Titlebar', icon: , element: },
+ { name: 'Footer', icon: , element: },
+ { name: 'Separator', icon: , element: },
+ { name: 'List Item', icon: , element: },
+ { name: 'Bottomsheet', icon: , element: },
+ { name: 'List', icon: , element: },
+ ],
+ },
+ {
+ name: 'MDS Feedback',
+ items: [
+ { name: 'Alert', icon: , element: },
+ { name: 'Banner', icon: , element: },
+ { name: 'Callout', icon: , element: },
+ { name: 'Empty Status', icon: , element: },
+ { name: 'Skeleton', icon: , element: },
+ ],
+ },
+ {
+ name: 'MDS Data Display',
+ items: [
+ { name: 'Avatar', icon: , element: },
+ { name: 'Icon', icon: , element: },
+ { name: 'Pill', icon: , element: },
+ { name: 'MDS Text', icon: , element: },
+ { name: 'Carousel', icon: , element: },
+ { name: 'Carousel Item', icon: , element: },
+ ],
+ },
+];
+
+function ToolboxItem({ name, icon, element }: { name: string; icon: React.ReactNode; element: React.ReactElement }) {
+ const { connectors } = useEditor();
+
+ return (
+ { if (ref) connectors.create(ref, element); }}
+ className="flex items-center gap-2 px-3 py-2 rounded cursor-grab hover:bg-[var(--bg-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors text-sm"
+ >
+ {icon}
+ {name}
+
+ );
+}
+
+export function Toolbox() {
+ const [collapsed, setCollapsed] = useState>({});
+
+ const toggle = (name: string) => {
+ setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
+ };
+
+ return (
+
+
+
+ Components
+
+ {categories.map((cat) => (
+
+
+ {!collapsed[cat.name] && (
+
+ {cat.items.map((item) => (
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/studio/src/editor/useKeyboardShortcuts.ts b/studio/src/editor/useKeyboardShortcuts.ts
new file mode 100644
index 000000000..245a1cd17
--- /dev/null
+++ b/studio/src/editor/useKeyboardShortcuts.ts
@@ -0,0 +1,68 @@
+import { useEditor } from '@craftjs/core';
+import { useEffect } from 'react';
+
+export function useKeyboardShortcuts() {
+ const { actions, query } = useEditor();
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) {
+ return;
+ }
+
+ if ((e.key === 'Delete' || e.key === 'Backspace') && !e.metaKey && !e.ctrlKey) {
+ const selected = query.getEvent('selected').first();
+ if (selected) {
+ const node = query.node(selected).get();
+ if (node && node.data.type !== 'HvDoc' && node.data.type !== 'HvScreen' && node.data.type !== 'HvBody') {
+ e.preventDefault();
+ actions.delete(selected);
+ }
+ }
+ }
+
+ if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
+ e.preventDefault();
+ if (query.history.canUndo()) actions.history.undo();
+ }
+
+ if ((e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
+ (e.key === 'y' && (e.metaKey || e.ctrlKey))) {
+ e.preventDefault();
+ if (query.history.canRedo()) actions.history.redo();
+ }
+
+ if (e.key === 'Escape') {
+ actions.clearEvents();
+ }
+
+ if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
+ const selected = query.getEvent('selected').first();
+ if (selected) {
+ e.preventDefault();
+ const { data } = query.node(selected).get();
+ if (data.type !== 'HvDoc' && data.type !== 'HvScreen' && data.type !== 'HvBody') {
+ const parent = data.parent;
+ if (parent) {
+ const freshNode = query.parseFreshNode({
+ data: {
+ type: data.type,
+ props: { ...data.props },
+ isCanvas: data.isCanvas,
+ },
+ }).toNode();
+ actions.addNodeTree(
+ { rootNodeId: freshNode.id, nodes: { [freshNode.id]: freshNode } },
+ parent
+ );
+ }
+ }
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [actions, query]);
+}
diff --git a/studio/src/editor/usePreviewSync.ts b/studio/src/editor/usePreviewSync.ts
new file mode 100644
index 000000000..7fc7be22c
--- /dev/null
+++ b/studio/src/editor/usePreviewSync.ts
@@ -0,0 +1,42 @@
+import { useEditor } from '@craftjs/core';
+import { useEffect, useRef } from 'react';
+import { serializeToHxml } from '../serialization/serialize';
+import { useStyleStore } from '../stores/styles';
+import { useBehaviorStore } from '../stores/behaviors';
+
+export function usePreviewSync() {
+ const { query } = useEditor();
+ const { styles } = useStyleStore();
+ const { getAllBehaviors } = useBehaviorStore();
+ const lastHxmlRef = useRef('');
+
+ useEffect(() => {
+ function sync() {
+ try {
+ const json = query.serialize();
+ const nodes = JSON.parse(json);
+
+ const nodeMap: Record = {};
+ for (const [id, data] of Object.entries(nodes)) {
+ nodeMap[id] = { data: data as any };
+ }
+
+ const hxml = serializeToHxml(nodeMap, styles, getAllBehaviors());
+
+ if (hxml === lastHxmlRef.current) return;
+ lastHxmlRef.current = hxml;
+
+ fetch('/preview/update', {
+ method: 'POST',
+ body: hxml,
+ headers: { 'Content-Type': 'application/xml' },
+ }).catch(() => {});
+ } catch (e) {
+ console.warn('[HV Studio] Sync error:', e);
+ }
+ }
+
+ const timer = setInterval(sync, 1000);
+ return () => clearInterval(timer);
+ }, [query, styles, getAllBehaviors]);
+}
diff --git a/studio/src/index.css b/studio/src/index.css
new file mode 100644
index 000000000..d52c87fb0
--- /dev/null
+++ b/studio/src/index.css
@@ -0,0 +1,66 @@
+@import "tailwindcss";
+
+:root {
+ --sidebar-width: 260px;
+ --toolbar-height: 44px;
+ --border-color: #2a2a3a;
+ --bg-primary: #0f0f1a;
+ --bg-secondary: #161625;
+ --bg-canvas: #1c1c2e;
+ --bg-hover: #22223a;
+ --text-primary: #e0e0f0;
+ --text-secondary: #8888a8;
+ --accent: #6366f1;
+ --accent-hover: #818cf8;
+}
+
+body {
+ margin: 0;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+/* Craft.js canvas overrides */
+.craftjs-renderer {
+ min-height: 100%;
+}
+
+/* Element hover labels */
+[class*="hv-"][class*="-preview"]:hover::before,
+.hv-element-wrapper:hover::after {
+ content: attr(data-label);
+ position: absolute;
+ top: -18px;
+ left: 0;
+ font-size: 10px;
+ padding: 1px 4px;
+ background: var(--accent);
+ color: white;
+ border-radius: 2px;
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+}
+
+/* Selection outline animation */
+@keyframes selection-pulse {
+ 0%, 100% { box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.6); }
+ 50% { box-shadow: 0 0 0 2px rgba(99, 102, 241, 1); }
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #333355;
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #444477;
+}
diff --git a/studio/src/main.tsx b/studio/src/main.tsx
new file mode 100644
index 000000000..cc14d8376
--- /dev/null
+++ b/studio/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/studio/src/serialization/deserialize.ts b/studio/src/serialization/deserialize.ts
new file mode 100644
index 000000000..8e3c112d1
--- /dev/null
+++ b/studio/src/serialization/deserialize.ts
@@ -0,0 +1,363 @@
+import type { HvStyleDef, HvModifierDef } from '../stores/styles';
+import type { HvBehaviorDef } from '../stores/behaviors';
+
+const TAG_TO_COMPONENT: Record = {
+ 'doc': 'HvDoc',
+ 'screen': 'HvScreen',
+ 'body': 'HvBody',
+ 'header': 'HvHeader',
+ 'view': 'HvView',
+ 'text': 'HvText',
+ 'image': 'HvImage',
+ 'spinner': 'HvSpinner',
+ 'list': 'HvList',
+ 'item': 'HvItem',
+ 'form': 'HvForm',
+ 'text-field': 'HvTextField',
+ 'date-field': 'HvDateField',
+ 'select-single': 'HvSelectSingle',
+ 'select-multiple': 'HvSelectMultiple',
+ 'option': 'HvOption',
+ 'switch': 'HvSwitch',
+};
+
+const PREFIXED_TAG_TO_COMPONENT: Record = {
+ 'mds:button': 'MdsButton',
+ 'mds:card': 'MdsCard',
+ 'mds:alert': 'MdsAlert',
+ 'mds:icon': 'MdsIcon',
+ 'mds:separator': 'MdsSeparator',
+ 'mds:pill': 'MdsPill',
+ 'mds:titlebar': 'MdsTitlebar',
+ 'mds:footer': 'MdsFooter',
+ 'mds:callout': 'MdsCallout',
+ 'mds:avatar': 'MdsAvatar',
+ 'mds:list-item': 'MdsListItem',
+ 'mds:skeleton': 'MdsSkeleton',
+ 'mds:banner': 'MdsBanner',
+ 'mds:empty-status': 'MdsEmptyStatus',
+ 'mds:dialog': 'MdsDialog',
+ 'mds:text': 'MdsText',
+ 'mds:list': 'MdsList',
+ 'worker-app:bottom-sheet': 'MdsBottomsheet',
+ 'ui:carousel': 'MdsCarousel',
+ 'ui:carousel-item': 'MdsCarouselItem',
+ 'icon:icon': 'MdsIcon',
+};
+
+const CUSTOM_ATTR_TO_PROP: Record = {
+ 'label': 'label',
+ 'variant': 'variant',
+ 'size': 'size',
+ 'icon': 'icon',
+ 'icon-trailing': 'iconTrailing',
+ 'action': 'action',
+ 'href': 'href',
+ 'target': 'target',
+ 'verb': 'verb',
+ 'state': 'state',
+ 'status': 'status',
+ 'label-loading': 'labelLoading',
+ 'hide': 'hide',
+ 'kind': 'kind',
+ 'title': 'title',
+ 'show-icon': 'showIcon',
+ 'primary-link-label': 'primaryLinkLabel',
+ 'secondary-link-label': 'secondaryLinkLabel',
+ 'shadow': 'shadow',
+ 'color': 'color',
+ 'dismissible': 'dismissible',
+ 'load-event': 'loadEvent',
+ 'press-event': 'pressEvent',
+ 'pressed-event': 'pressedEvent',
+ 'name': 'name',
+ 'source': 'source',
+ 'type': 'type',
+ 'has-border': 'hasBorder',
+ 'counter': 'counter',
+ 'right': 'right',
+ 'text': 'text',
+ 'content': 'content',
+ 'icon-name': 'iconName',
+ 'icon-color': 'iconColor',
+ 'icon-emphasize-color': 'iconEmphasizeColor',
+ 'icon-size': 'iconSize',
+ 'margin-horizontal': 'marginHorizontal',
+ 'show-back': 'showBack',
+ 'main': 'main',
+ 'progress': 'progress',
+ 'title-always-on': 'titleAlwaysOn',
+ 'sticky': 'sticky',
+ 'background-color': 'backgroundColor',
+ 'text-align': 'textAlign',
+ 'number-of-lines': 'numberOfLines',
+ 'illustration-name': 'illustrationName',
+ 'subtitle': 'subtitle',
+ 'visible': 'visible',
+ 'cta-label': 'ctaLabel',
+ 'cta-layout': 'ctaLayout',
+ 'secondary-cta-label': 'secondaryCtaLabel',
+ 'toggle-duration': 'toggleDuration',
+ 'auto-height': 'autoHeight',
+ 'autoplay': 'autoplay',
+ 'height': 'height',
+ 'pagination': 'pagination',
+ 'peek-items': 'peekItems',
+ 'next-event-name': 'nextEventName',
+ 'prev-event-name': 'prevEventName',
+ 'on-active-event-prefix': 'onActiveEventPrefix',
+ 'font-style': 'fontStyle',
+ 'list-type': 'listType',
+ 'list-index': 'listIndex',
+ 'selectable': 'selectable',
+ 'preformatted': 'preformatted',
+ 'message': 'message',
+ 'trigger': 'trigger',
+};
+
+const CANVAS_COMPONENTS = new Set([
+ 'HvDoc', 'HvScreen', 'HvBody', 'HvHeader', 'HvView',
+ 'HvList', 'HvItem', 'HvForm', 'HvSelectSingle', 'HvSelectMultiple',
+ 'MdsCard', 'MdsAlert', 'MdsFooter', 'MdsCallout', 'MdsListItem',
+ 'MdsBanner', 'MdsEmptyStatus', 'MdsBottomsheet', 'MdsCarousel',
+ 'MdsCarouselItem', 'MdsList',
+]);
+
+export interface DeserializeResult {
+ nodes: Record;
+ styles: HvStyleDef[];
+ behaviors: Record;
+}
+
+let nodeCounter = 0;
+function nextNodeId(): string {
+ return `node-${++nodeCounter}`;
+}
+
+function parseBehavior(el: Element): HvBehaviorDef {
+ const bh: HvBehaviorDef = {
+ id: `bh-imp-${++nodeCounter}`,
+ trigger: el.getAttribute('trigger') || 'press',
+ action: el.getAttribute('action') || 'push',
+ };
+ if (el.getAttribute('href')) bh.href = el.getAttribute('href')!;
+ if (el.getAttribute('target')) bh.target = el.getAttribute('target')!;
+ if (el.getAttribute('show-during-load')) bh.showDuringLoad = el.getAttribute('show-during-load')!;
+ if (el.getAttribute('hide-during-load')) bh.hideDuringLoad = el.getAttribute('hide-during-load')!;
+ if (el.getAttribute('event-name')) bh.eventName = el.getAttribute('event-name')!;
+ if (el.getAttribute('new-value')) bh.newValue = el.getAttribute('new-value')!;
+
+ const alertTitle = el.getAttributeNS('https://hyperview.org/hyperview-alert', 'title');
+ if (alertTitle) bh.alertTitle = alertTitle;
+ const alertMessage = el.getAttributeNS('https://hyperview.org/hyperview-alert', 'message');
+ if (alertMessage) bh.alertMessage = alertMessage;
+
+ const scrollAnimated = el.getAttributeNS('https://hyperview.org/hyperview-scroll', 'animated');
+ if (scrollAnimated) bh.scrollAnimated = scrollAnimated === 'true';
+ const scrollOffset = el.getAttributeNS('https://hyperview.org/hyperview-scroll', 'offset');
+ if (scrollOffset) bh.scrollOffset = scrollOffset;
+ const scrollPosition = el.getAttributeNS('https://hyperview.org/hyperview-scroll', 'position');
+ if (scrollPosition) bh.scrollPosition = scrollPosition;
+
+ return bh;
+}
+
+function parseStyles(stylesEl: Element): HvStyleDef[] {
+ const result: HvStyleDef[] = [];
+ for (let i = 0; i < stylesEl.children.length; i++) {
+ const child = stylesEl.children[i];
+ if (child.localName !== 'style') continue;
+ const id = child.getAttribute('id');
+ if (!id) continue;
+ const properties: Record = {};
+ for (let j = 0; j < child.attributes.length; j++) {
+ const attr = child.attributes[j];
+ if (attr.name === 'id') continue;
+ properties[attr.name] = attr.value;
+ }
+ const modifiers: HvModifierDef[] = [];
+ for (let j = 0; j < child.children.length; j++) {
+ const modEl = child.children[j];
+ if (modEl.localName !== 'modifier') continue;
+ const state = modEl.getAttribute('state') as HvModifierDef['state'] | null;
+ if (!state || !['pressed', 'selected', 'focused'].includes(state)) continue;
+ const modProps: Record = {};
+ for (let k = 0; k < modEl.attributes.length; k++) {
+ const attr = modEl.attributes[k];
+ if (attr.name === 'state') continue;
+ modProps[attr.name] = attr.value;
+ }
+ modifiers.push({ state, properties: modProps });
+ }
+ result.push({ id, properties, modifiers });
+ }
+ return result;
+}
+
+function resolveComponent(el: Element): string | null {
+ const localName = el.localName;
+
+ if (TAG_TO_COMPONENT[localName]) return TAG_TO_COMPONENT[localName];
+
+ const prefix = el.prefix;
+ if (prefix) {
+ const fullTag = `${prefix}:${localName}`;
+ if (PREFIXED_TAG_TO_COMPONENT[fullTag]) return PREFIXED_TAG_TO_COMPONENT[fullTag];
+ }
+
+ if (localName.includes(':')) {
+ if (PREFIXED_TAG_TO_COMPONENT[localName]) return PREFIXED_TAG_TO_COMPONENT[localName];
+ }
+
+ return null;
+}
+
+function extractProps(el: Element, tagName: string, componentName: string): Record {
+ const props: Record = {};
+ const isCustom = componentName.startsWith('Mds');
+
+ const id = el.getAttribute('id');
+ if (id) props.hvId = id;
+ const style = el.getAttribute('style');
+ if (style) props.hvStyle = style;
+ const key = el.getAttribute('key');
+ if (key) props.hvKey = key;
+
+ if (!isCustom) {
+ if (tagName === 'text') {
+ let textContent = '';
+ for (let i = 0; i < el.childNodes.length; i++) {
+ const child = el.childNodes[i];
+ if (child.nodeType === 3) textContent += child.textContent;
+ }
+ props.text = textContent.trim() || 'Text';
+ }
+ if (tagName === 'image') {
+ if (el.getAttribute('source')) props.source = el.getAttribute('source');
+ if (el.getAttribute('width')) props.width = Number(el.getAttribute('width'));
+ if (el.getAttribute('height')) props.height = Number(el.getAttribute('height'));
+ }
+ if (tagName === 'text-field') {
+ if (el.getAttribute('name')) props.name = el.getAttribute('name');
+ if (el.getAttribute('placeholder')) props.placeholder = el.getAttribute('placeholder');
+ if (el.getAttribute('keyboard-type')) props.keyboardType = el.getAttribute('keyboard-type');
+ }
+ if (tagName === 'date-field') {
+ if (el.getAttribute('name')) props.name = el.getAttribute('name');
+ if (el.getAttribute('label')) props.label = el.getAttribute('label');
+ }
+ if (tagName === 'select-single' || tagName === 'select-multiple') {
+ if (el.getAttribute('name')) props.name = el.getAttribute('name');
+ }
+ if (tagName === 'option') {
+ if (el.getAttribute('value')) props.value = el.getAttribute('value');
+ if (el.getAttribute('label')) props.label = el.getAttribute('label');
+ }
+ if (tagName === 'switch') {
+ if (el.getAttribute('name')) props.name = el.getAttribute('name');
+ props.value = el.getAttribute('value') === 'true';
+ }
+ if (tagName === 'view') {
+ props.flexDirection = 'column';
+ props.padding = 8;
+ props.margin = 0;
+ props.borderRadius = 0;
+ }
+ } else {
+ for (let i = 0; i < el.attributes.length; i++) {
+ const attr = el.attributes[i];
+ if (attr.name === 'id' || attr.name === 'style' || attr.name === 'key') continue;
+ if (attr.name.startsWith('xmlns')) continue;
+ const propKey = CUSTOM_ATTR_TO_PROP[attr.name] || attr.name;
+ if (attr.value === 'true') props[propKey] = true;
+ else if (attr.value === 'false') props[propKey] = false;
+ else if (/^\d+$/.test(attr.value)) props[propKey] = Number(attr.value);
+ else props[propKey] = attr.value;
+ }
+ // MDS text content
+ if (componentName === 'MdsText') {
+ let textContent = '';
+ for (let i = 0; i < el.childNodes.length; i++) {
+ const child = el.childNodes[i];
+ if (child.nodeType === 3) textContent += child.textContent;
+ }
+ if (textContent.trim()) props.content = textContent.trim();
+ }
+ }
+
+ return props;
+}
+
+function processElement(
+ el: Element,
+ parentId: string,
+ nodes: Record,
+ behaviors: Record,
+ styles: HvStyleDef[],
+): string | null {
+ const tagName = el.localName;
+
+ if (tagName === 'styles') {
+ styles.push(...parseStyles(el));
+ return null;
+ }
+ if (tagName === 'behavior') return null;
+ if (tagName === 'modifier') return null;
+
+ const componentName = resolveComponent(el);
+ if (!componentName) return null;
+
+ const nodeId = tagName === 'doc' ? 'ROOT' : nextNodeId();
+ const props = extractProps(el, tagName, componentName);
+ const isCanvas = CANVAS_COMPONENTS.has(componentName);
+
+ const childNodeIds: string[] = [];
+ const nodeBehaviors: HvBehaviorDef[] = [];
+
+ for (let i = 0; i < el.children.length; i++) {
+ const child = el.children[i];
+ if (child.localName === 'behavior') {
+ nodeBehaviors.push(parseBehavior(child));
+ } else {
+ const childId = processElement(child, nodeId, nodes, behaviors, styles);
+ if (childId) childNodeIds.push(childId);
+ }
+ }
+
+ if (nodeBehaviors.length > 0) {
+ behaviors[nodeId] = nodeBehaviors;
+ }
+
+ nodes[nodeId] = {
+ type: { resolvedName: componentName },
+ isCanvas,
+ props,
+ displayName: componentName.replace(/^(Hv|Mds)/, ''),
+ custom: {},
+ parent: parentId,
+ nodes: childNodeIds,
+ linkedNodes: {},
+ };
+
+ return nodeId;
+}
+
+export function deserializeHxml(hxml: string): DeserializeResult {
+ nodeCounter = 0;
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(hxml, 'application/xml');
+
+ const parserError = doc.querySelector('parsererror');
+ if (parserError) {
+ throw new Error(`XML parse error: ${parserError.textContent}`);
+ }
+
+ const root = doc.documentElement;
+ const nodes: Record = {};
+ const behaviors: Record = {};
+ const styles: HvStyleDef[] = [];
+
+ processElement(root, '', nodes, behaviors, styles);
+
+ return { nodes, styles, behaviors };
+}
diff --git a/studio/src/serialization/serialize.ts b/studio/src/serialization/serialize.ts
new file mode 100644
index 000000000..7740ecde5
--- /dev/null
+++ b/studio/src/serialization/serialize.ts
@@ -0,0 +1,401 @@
+import { componentMap } from '../components';
+import { mdsComponentMap } from '../components/mds';
+import type { HvStyleDef } from '../stores/styles';
+import type { HvBehaviorDef } from '../stores/behaviors';
+
+const MDS_NS_URI = 'https://instawork.com/hyperview-mds';
+const WORKER_APP_NS_URI = 'https://instawork.com/hyperview-worker-app';
+const UI_NS_URI = 'https://instawork.com/hyperview-ui';
+const ICON_NS_URI = 'https://instawork.com/hyperview-icon';
+
+interface SerializedNode {
+ type: { resolvedName: string } | string;
+ displayName?: string;
+ props: Record;
+ nodes: string[];
+ linkedNodes?: Record;
+ parent?: string;
+ isCanvas?: boolean;
+}
+
+type NodeMap = Record;
+
+const INDENT = ' ';
+
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function getTagName(typeName: string): string | null {
+ return (componentMap as Record)[typeName]
+ || (mdsComponentMap as Record)[typeName]
+ || null;
+}
+
+function isCustomElement(tagName: string): boolean {
+ return tagName.includes(':');
+}
+
+function serializeStyles(styles: HvStyleDef[], indent: string): string {
+ if (styles.length === 0) return '';
+ let xml = `${indent}\n`;
+ for (const style of styles) {
+ const attrs = Object.entries(style.properties)
+ .filter(([, v]) => v !== '' && v !== undefined)
+ .map(([k, v]) => `${k}="${escapeXml(v)}"`)
+ .join(' ');
+ const hasModifiers = style.modifiers && style.modifiers.length > 0;
+ if (!hasModifiers) {
+ xml += `${indent}${INDENT}\n`;
+ } else {
+ xml += `${indent}${INDENT}\n`;
+ }
+ }
+ xml += `${indent}\n`;
+ return xml;
+}
+
+type BehaviorMap = Record;
+
+const SCROLL_NS = 'https://hyperview.org/hyperview-scroll';
+const ALERT_NS = 'https://hyperview.org/hyperview-alert';
+
+function serializeBehaviors(behaviors: HvBehaviorDef[], indent: string): string {
+ let xml = '';
+ for (const bh of behaviors) {
+ const attrs: string[] = [];
+ attrs.push(`trigger="${escapeXml(bh.trigger)}"`);
+ attrs.push(`action="${escapeXml(bh.action)}"`);
+ if (bh.href) attrs.push(`href="${escapeXml(bh.href)}"`);
+ if (bh.target) attrs.push(`target="${escapeXml(bh.target)}"`);
+ if (bh.showDuringLoad) attrs.push(`show-during-load="${escapeXml(bh.showDuringLoad)}"`);
+ if (bh.hideDuringLoad) attrs.push(`hide-during-load="${escapeXml(bh.hideDuringLoad)}"`);
+ if (bh.eventName) {
+ if (bh.action === 'dispatch-event') attrs.push(`event-name="${escapeXml(bh.eventName)}"`);
+ if (bh.trigger === 'on-event') attrs.push(`event-name="${escapeXml(bh.eventName)}"`);
+ }
+ if (bh.action === 'set-value' && bh.newValue !== undefined) attrs.push(`new-value="${escapeXml(bh.newValue)}"`);
+ if (bh.action === 'alert') {
+ if (bh.alertTitle) attrs.push(`alert:title="${escapeXml(bh.alertTitle)}"`);
+ if (bh.alertMessage) attrs.push(`alert:message="${escapeXml(bh.alertMessage)}"`);
+ }
+ if (bh.action === 'scroll') {
+ if (bh.scrollAnimated !== undefined) attrs.push(`scroll:animated="${bh.scrollAnimated}"`);
+ if (bh.scrollOffset) attrs.push(`scroll:offset="${escapeXml(bh.scrollOffset)}"`);
+ if (bh.scrollPosition) attrs.push(`scroll:position="${escapeXml(bh.scrollPosition)}"`);
+ }
+ xml += `${indent}\n`;
+ }
+ return xml;
+}
+
+function collectNamespaces(behaviorMap: BehaviorMap, nodes: NodeMap): { prefix: string; uri: string }[] {
+ const ns: { prefix: string; uri: string }[] = [];
+ const seen = new Set();
+
+ for (const behaviors of Object.values(behaviorMap)) {
+ for (const bh of behaviors) {
+ if (bh.action === 'scroll' && !seen.has('scroll')) {
+ ns.push({ prefix: 'scroll', uri: SCROLL_NS });
+ seen.add('scroll');
+ }
+ if (bh.action === 'alert' && !seen.has('alert')) {
+ ns.push({ prefix: 'alert', uri: ALERT_NS });
+ seen.add('alert');
+ }
+ }
+ }
+
+ for (const entry of Object.values(nodes)) {
+ const typeName = typeof entry.data.type === 'string' ? entry.data.type : entry.data.type.resolvedName;
+ const tag = (mdsComponentMap as Record)[typeName];
+ if (!tag) continue;
+
+ if (tag.startsWith('mds:') && !seen.has('mds')) {
+ ns.push({ prefix: 'mds', uri: MDS_NS_URI });
+ seen.add('mds');
+ } else if (tag.startsWith('worker-app:') && !seen.has('worker-app')) {
+ ns.push({ prefix: 'worker-app', uri: WORKER_APP_NS_URI });
+ seen.add('worker-app');
+ } else if (tag.startsWith('ui:') && !seen.has('ui')) {
+ ns.push({ prefix: 'ui', uri: UI_NS_URI });
+ seen.add('ui');
+ } else if (tag.startsWith('icon:') && !seen.has('icon')) {
+ ns.push({ prefix: 'icon', uri: ICON_NS_URI });
+ seen.add('icon');
+ }
+ }
+
+ return ns;
+}
+
+const CUSTOM_PROP_MAP: Record = {
+ label: 'label',
+ variant: 'variant',
+ size: 'size',
+ icon: 'icon',
+ iconTrailing: 'icon-trailing',
+ action: 'action',
+ href: 'href',
+ target: 'target',
+ verb: 'verb',
+ state: 'state',
+ status: 'status',
+ labelLoading: 'label-loading',
+ hide: 'hide',
+ kind: 'kind',
+ title: 'title',
+ showIcon: 'show-icon',
+ primaryLinkLabel: 'primary-link-label',
+ secondaryLinkLabel: 'secondary-link-label',
+ shadow: 'shadow',
+ color: 'color',
+ dismissible: 'dismissible',
+ loadEvent: 'load-event',
+ pressEvent: 'press-event',
+ pressedEvent: 'pressed-event',
+ name: 'name',
+ source: 'source',
+ type: 'type',
+ hasBorder: 'has-border',
+ counter: 'counter',
+ right: 'right',
+ text: 'text',
+ iconName: 'icon-name',
+ iconColor: 'icon-color',
+ iconEmphasizeColor: 'icon-emphasize-color',
+ iconSize: 'icon-size',
+ marginHorizontal: 'margin-horizontal',
+ showBack: 'show-back',
+ main: 'main',
+ progress: 'progress',
+ titleAlwaysOn: 'title-always-on',
+ sticky: 'sticky',
+ backgroundColor: 'background-color',
+ textAlign: 'text-align',
+ numberOfLines: 'number-of-lines',
+ illustrationName: 'illustration-name',
+ bannerType: 'type',
+ subtitle: 'subtitle',
+ visible: 'visible',
+ ctaLabel: 'cta-label',
+ ctaLayout: 'cta-layout',
+ secondaryCtaLabel: 'secondary-cta-label',
+ toggleDuration: 'toggle-duration',
+ autoHeight: 'auto-height',
+ autoplay: 'autoplay',
+ height: 'height',
+ pagination: 'pagination',
+ peekItems: 'peek-items',
+ nextEventName: 'next-event-name',
+ prevEventName: 'prev-event-name',
+ onActiveEventPrefix: 'on-active-event-prefix',
+ content: 'content',
+ textType: 'type',
+ fontStyle: 'font-style',
+ listType: 'list-type',
+ listIndex: 'list-index',
+ selectable: 'selectable',
+ preformatted: 'preformatted',
+ message: 'message',
+ trigger: 'trigger',
+ dialogOptions: 'dialog-options',
+};
+
+const CUSTOM_SKIP_PROPS = new Set(['children', 'hvId', 'hvStyle', 'hvKey']);
+
+// Slot names that should NOT be serialized as child elements
+// (they are internal Craft.js structure nodes, e.g. HvView wrappers)
+const SLOT_NAMES = new Set([
+ 'left', 'content', 'right', 'bottom',
+ 'leftContent', 'rightContent',
+ 'body', 'ctaChildren', 'secondaryCtaChildren',
+ 'header', 'footer', 'title', 'empty', 'itemTemplate',
+]);
+
+function serializeNode(
+ nodeId: string,
+ nodes: NodeMap,
+ depth: number,
+ styles: HvStyleDef[],
+ behaviorMap: BehaviorMap,
+ extraNsAttrs: string,
+ slotName?: string,
+): string {
+ const entry = nodes[nodeId];
+ if (!entry) return '';
+
+ const node = entry.data;
+ const typeName = typeof node.type === 'string'
+ ? node.type
+ : node.type.resolvedName;
+
+ // Skip Placeholder/Hint nodes (they're empty visual hints)
+ if (typeName === 'Placeholder' || node.displayName === 'Hint' || node.displayName === 'Placeholder') {
+ return '';
+ }
+
+ const tagName = getTagName(typeName);
+ if (!tagName) return '';
+
+ const indent = INDENT.repeat(depth);
+ const props = node.props || {};
+
+ const attrParts: string[] = [];
+ if (tagName === 'doc') {
+ attrParts.push('xmlns="https://hyperview.org/hyperview"');
+ if (extraNsAttrs) attrParts.push(extraNsAttrs);
+ }
+
+ // For linked-node slot wrappers (HvView used as slot container), skip the view tag
+ // and just serialize children directly
+ if (slotName && tagName === 'view') {
+ const allChildIds = [...(node.nodes || []), ...Object.values(node.linkedNodes || {})];
+ let xml = '';
+ for (const childId of allChildIds) {
+ xml += serializeNode(childId, nodes, depth, styles, behaviorMap, '');
+ }
+ return xml;
+ }
+
+ if (!isCustomElement(tagName)) {
+ // Core HV element attribute handling
+ if (props.hvId) attrParts.push(`id="${escapeXml(String(props.hvId))}"`);
+ if (props.hvStyle) attrParts.push(`style="${escapeXml(String(props.hvStyle))}"`);
+ if (props.hvKey) attrParts.push(`key="${escapeXml(String(props.hvKey))}"`);
+
+ if (tagName === 'image' && props.source) {
+ attrParts.push(`source="${escapeXml(String(props.source))}"`);
+ if (props.width) attrParts.push(`width="${props.width}"`);
+ if (props.height) attrParts.push(`height="${props.height}"`);
+ }
+ if (tagName === 'text-field') {
+ if (props.name) attrParts.push(`name="${escapeXml(String(props.name))}"`);
+ if (props.placeholder) attrParts.push(`placeholder="${escapeXml(String(props.placeholder))}"`);
+ if (props.keyboardType && props.keyboardType !== 'default') {
+ attrParts.push(`keyboard-type="${escapeXml(String(props.keyboardType))}"`);
+ }
+ }
+ if ((tagName === 'select-single' || tagName === 'select-multiple') && props.name) {
+ attrParts.push(`name="${escapeXml(String(props.name))}"`);
+ }
+ if (tagName === 'date-field') {
+ if (props.name) attrParts.push(`name="${escapeXml(String(props.name))}"`);
+ if (props.label) attrParts.push(`label="${escapeXml(String(props.label))}"`);
+ }
+ if (tagName === 'option') {
+ if (props.value) attrParts.push(`value="${escapeXml(String(props.value))}"`);
+ if (props.label) attrParts.push(`label="${escapeXml(String(props.label))}"`);
+ }
+ if (tagName === 'switch') {
+ if (props.name) attrParts.push(`name="${escapeXml(String(props.name))}"`);
+ if (props.value) attrParts.push(`value="true"`);
+ }
+ if (tagName === 'item' && props.hvKey) {
+ attrParts.push(`key="${escapeXml(String(props.hvKey))}"`);
+ }
+ } else {
+ // Custom/MDS element attribute handling
+ if (props.hvId) attrParts.push(`id="${escapeXml(String(props.hvId))}"`);
+ if (props.hvStyle) attrParts.push(`style="${escapeXml(String(props.hvStyle))}"`);
+
+ for (const [propKey, propVal] of Object.entries(props)) {
+ if (CUSTOM_SKIP_PROPS.has(propKey)) continue;
+ if (propVal === undefined || propVal === null || propVal === '') continue;
+ if (propVal === false) continue;
+ const xmlAttr = CUSTOM_PROP_MAP[propKey] || propKey;
+ if (typeof propVal === 'boolean') {
+ attrParts.push(`${xmlAttr}="true"`);
+ } else if (typeof propVal === 'number') {
+ attrParts.push(`${xmlAttr}="${propVal}"`);
+ } else {
+ attrParts.push(`${xmlAttr}="${escapeXml(String(propVal))}"`);
+ }
+ }
+ }
+
+ const attrStr = attrParts.length > 0 ? ' ' + attrParts.join(' ') : '';
+
+ // Separate regular children from linked (slot) children
+ const regularChildIds = node.nodes || [];
+ const linkedEntries = Object.entries(node.linkedNodes || {});
+
+ const nodeBehaviors = behaviorMap[nodeId] || [];
+
+ // Text is a special case
+ if (tagName === 'text') {
+ if (nodeBehaviors.length === 0) {
+ const textContent = escapeXml(String(props.text || ''));
+ return `${indent}${textContent}\n`;
+ }
+ let xml = `${indent}\n`;
+ xml += `${indent}${INDENT}${escapeXml(String(props.text || ''))}\n`;
+ xml += serializeBehaviors(nodeBehaviors, indent + INDENT);
+ xml += `${indent}\n`;
+ return xml;
+ }
+
+ // MDS text uses content prop
+ if (tagName === 'mds:text') {
+ const textContent = escapeXml(String(props.content || ''));
+ if (nodeBehaviors.length === 0 && regularChildIds.length === 0 && linkedEntries.length === 0) {
+ return `${indent}<${tagName}${attrStr}>${textContent}${tagName}>\n`;
+ }
+ }
+
+ const hasLinkedSlots = linkedEntries.some(([name]) => SLOT_NAMES.has(name));
+ const hasChildren = regularChildIds.length > 0 || hasLinkedSlots || nodeBehaviors.length > 0;
+
+ if (!hasChildren && linkedEntries.length === 0) {
+ return `${indent}<${tagName}${attrStr} />\n`;
+ }
+
+ let xml = `${indent}<${tagName}${attrStr}>\n`;
+
+ if (tagName === 'screen') {
+ xml += serializeStyles(styles, indent + INDENT);
+ }
+
+ xml += serializeBehaviors(nodeBehaviors, indent + INDENT);
+
+ // Serialize linked-node slots as named wrapper elements
+ for (const [sName, linkedId] of linkedEntries) {
+ if (SLOT_NAMES.has(sName)) {
+ const slotContent = serializeNode(linkedId, nodes, depth + 1, styles, behaviorMap, '', sName);
+ if (slotContent.trim()) {
+ xml += `${indent}${INDENT}<${sName}>\n`;
+ // Re-serialize at depth+2 since we added a wrapper
+ const innerContent = serializeNode(linkedId, nodes, depth + 2, styles, behaviorMap, '', sName);
+ xml += innerContent;
+ xml += `${indent}${INDENT}${sName}>\n`;
+ }
+ } else {
+ xml += serializeNode(linkedId, nodes, depth + 1, styles, behaviorMap, '');
+ }
+ }
+
+ for (const childId of regularChildIds) {
+ xml += serializeNode(childId, nodes, depth + 1, styles, behaviorMap, '');
+ }
+
+ xml += `${indent}${tagName}>\n`;
+ return xml;
+}
+
+export function serializeToHxml(nodes: NodeMap, styles: HvStyleDef[], behaviorMap: BehaviorMap = {}): string {
+ const nsDecls = collectNamespaces(behaviorMap, nodes);
+ const extraNsAttrs = nsDecls.map((ns) => `xmlns:${ns.prefix}="${ns.uri}"`).join(' ');
+ return serializeNode('ROOT', nodes, 0, styles, behaviorMap, extraNsAttrs).trim();
+}
diff --git a/studio/src/server/vite-plugin.ts b/studio/src/server/vite-plugin.ts
new file mode 100644
index 000000000..5082a613b
--- /dev/null
+++ b/studio/src/server/vite-plugin.ts
@@ -0,0 +1,84 @@
+import type { Plugin, ViteDevServer } from 'vite';
+import { WebSocketServer, WebSocket } from 'ws';
+
+let currentHxml = '';
+let wss: WebSocketServer | null = null;
+
+export function hxmlPreviewPlugin(): Plugin {
+ return {
+ name: 'hyperview-studio-preview',
+
+ configureServer(server: ViteDevServer) {
+ wss = new WebSocketServer({ noServer: true });
+
+ server.httpServer?.on('upgrade', (request, socket, head) => {
+ if (request.url === '/preview/ws') {
+ wss!.handleUpgrade(request, socket, head, (ws) => {
+ wss!.emit('connection', ws, request);
+ });
+ }
+ });
+
+ wss.on('connection', (ws) => {
+ console.log('[HV Studio] Preview client connected');
+ ws.on('close', () => {
+ console.log('[HV Studio] Preview client disconnected');
+ });
+ });
+
+ server.middlewares.use('/preview/update', (req, res) => {
+ if (req.method !== 'POST') {
+ res.statusCode = 405;
+ res.end('Method not allowed');
+ return;
+ }
+
+ let body = '';
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
+ req.on('end', () => {
+ currentHxml = body;
+
+ if (wss) {
+ wss.clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(JSON.stringify({ type: 'reload' }));
+ }
+ });
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.end(JSON.stringify({ ok: true }));
+ });
+ });
+
+ server.middlewares.use('/preview/screen.xml', (_req, res) => {
+ res.setHeader('Content-Type', 'application/xml');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.end(currentHxml || defaultHxml());
+ return;
+ });
+
+ server.middlewares.use('/preview/clients', (_req, res) => {
+ const count = wss ? [...wss.clients].filter((c) => c.readyState === WebSocket.OPEN).length : 0;
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.end(JSON.stringify({ count }));
+ });
+
+ console.log('[HV Studio] Preview server ready at /preview/*');
+ },
+ };
+}
+
+function defaultHxml(): string {
+ return `
+
+
+
+ No content yet. Build something in the editor!
+
+
+
+`;
+}
diff --git a/studio/src/stores/behaviors.ts b/studio/src/stores/behaviors.ts
new file mode 100644
index 000000000..b807a41fc
--- /dev/null
+++ b/studio/src/stores/behaviors.ts
@@ -0,0 +1,106 @@
+import { useSyncExternalStore, useCallback } from 'react';
+
+export interface HvBehaviorDef {
+ id: string;
+ trigger: string;
+ action: string;
+ href?: string;
+ target?: string;
+ showDuringLoad?: string;
+ hideDuringLoad?: string;
+ eventName?: string;
+ newValue?: string;
+ // Alert namespace attrs
+ alertTitle?: string;
+ alertMessage?: string;
+ // Scroll namespace attrs
+ scrollAnimated?: boolean;
+ scrollOffset?: string;
+ scrollPosition?: string;
+}
+
+type BehaviorMap = Record;
+
+let store: BehaviorMap = {};
+let listeners: Set<() => void> = new Set();
+let snapshotRef = store;
+
+function notify() {
+ snapshotRef = { ...store };
+ listeners.forEach((fn) => fn());
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
+
+function getSnapshot(): BehaviorMap {
+ return snapshotRef;
+}
+
+let nextId = 1;
+
+export function useBehaviorStore() {
+ const behaviors = useSyncExternalStore(subscribe, getSnapshot);
+
+ const getBehaviors = useCallback((nodeId: string): HvBehaviorDef[] => {
+ return behaviors[nodeId] || [];
+ }, [behaviors]);
+
+ const addBehavior = useCallback((nodeId: string): string => {
+ const id = `bh-${nextId++}`;
+ const newBehavior: HvBehaviorDef = {
+ id,
+ trigger: 'press',
+ action: 'push',
+ };
+ store = {
+ ...store,
+ [nodeId]: [...(store[nodeId] || []), newBehavior],
+ };
+ notify();
+ return id;
+ }, []);
+
+ const updateBehavior = useCallback((nodeId: string, behaviorId: string, updates: Partial) => {
+ const list = store[nodeId];
+ if (!list) return;
+ store = {
+ ...store,
+ [nodeId]: list.map((b) =>
+ b.id === behaviorId ? { ...b, ...updates } : b
+ ),
+ };
+ notify();
+ }, []);
+
+ const removeBehavior = useCallback((nodeId: string, behaviorId: string) => {
+ const list = store[nodeId];
+ if (!list) return;
+ store = {
+ ...store,
+ [nodeId]: list.filter((b) => b.id !== behaviorId),
+ };
+ if (store[nodeId]!.length === 0) {
+ const { [nodeId]: _, ...rest } = store;
+ store = rest;
+ }
+ notify();
+ }, []);
+
+ const moveBehavior = useCallback((nodeId: string, fromIndex: number, toIndex: number) => {
+ const list = [...(store[nodeId] || [])];
+ if (fromIndex < 0 || fromIndex >= list.length || toIndex < 0 || toIndex >= list.length) return;
+ const [item] = list.splice(fromIndex, 1);
+ list.splice(toIndex, 0, item);
+ store = { ...store, [nodeId]: list };
+ notify();
+ }, []);
+
+ const getAllBehaviors = useCallback((): BehaviorMap => {
+ return behaviors;
+ }, [behaviors]);
+
+ return { getBehaviors, addBehavior, updateBehavior, removeBehavior, moveBehavior, getAllBehaviors };
+}
diff --git a/studio/src/stores/styles.ts b/studio/src/stores/styles.ts
new file mode 100644
index 000000000..2f78b55d3
--- /dev/null
+++ b/studio/src/stores/styles.ts
@@ -0,0 +1,88 @@
+import { useSyncExternalStore, useCallback } from 'react';
+
+export interface HvModifierDef {
+ state: 'pressed' | 'selected' | 'focused';
+ properties: Record;
+}
+
+export interface HvStyleDef {
+ id: string;
+ properties: Record;
+ modifiers: HvModifierDef[];
+}
+
+let globalStyles: HvStyleDef[] = [];
+let listeners: Set<() => void> = new Set();
+
+function notify() {
+ globalStyles = [...globalStyles];
+ listeners.forEach((fn) => fn());
+}
+
+function getSnapshot(): HvStyleDef[] {
+ return globalStyles;
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
+
+export function useStyleStore() {
+ const styles = useSyncExternalStore(subscribe, getSnapshot);
+
+ const addStyle = useCallback((id: string) => {
+ if (globalStyles.find((s) => s.id === id)) return;
+ globalStyles = [...globalStyles, { id, properties: {}, modifiers: [] }];
+ notify();
+ }, []);
+
+ const updateStyle = useCallback((id: string, properties: Record) => {
+ globalStyles = globalStyles.map((s) =>
+ s.id === id ? { ...s, properties } : s
+ );
+ notify();
+ }, []);
+
+ const removeStyle = useCallback((id: string) => {
+ globalStyles = globalStyles.filter((s) => s.id !== id);
+ notify();
+ }, []);
+
+ const addModifier = useCallback((styleId: string, state: HvModifierDef['state']) => {
+ globalStyles = globalStyles.map((s) => {
+ if (s.id !== styleId) return s;
+ if (s.modifiers.find((m) => m.state === state)) return s;
+ return { ...s, modifiers: [...s.modifiers, { state, properties: {} }] };
+ });
+ notify();
+ }, []);
+
+ const updateModifier = useCallback((styleId: string, state: string, properties: Record) => {
+ globalStyles = globalStyles.map((s) => {
+ if (s.id !== styleId) return s;
+ return {
+ ...s,
+ modifiers: s.modifiers.map((m) =>
+ m.state === state ? { ...m, properties } : m
+ ),
+ };
+ });
+ notify();
+ }, []);
+
+ const removeModifier = useCallback((styleId: string, state: string) => {
+ globalStyles = globalStyles.map((s) => {
+ if (s.id !== styleId) return s;
+ return { ...s, modifiers: s.modifiers.filter((m) => m.state !== state) };
+ });
+ notify();
+ }, []);
+
+ const setStyles = useCallback((newStyles: HvStyleDef[]) => {
+ globalStyles = newStyles;
+ notify();
+ }, []);
+
+ return { styles, addStyle, updateStyle, removeStyle, addModifier, updateModifier, removeModifier, setStyles };
+}
diff --git a/studio/src/vite-env.d.ts b/studio/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/studio/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/studio/tsconfig.json b/studio/tsconfig.json
new file mode 100644
index 000000000..058188564
--- /dev/null
+++ b/studio/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/studio/vite.config.ts b/studio/vite.config.ts
new file mode 100644
index 000000000..e654ce061
--- /dev/null
+++ b/studio/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import { hxmlPreviewPlugin } from './src/server/vite-plugin';
+
+export default defineConfig({
+ plugins: [react(), tailwindcss(), hxmlPreviewPlugin()],
+ server: {
+ port: 5200,
+ },
+});