From aa033fef77539de64bf2a4e41457925fada518ac Mon Sep 17 00:00:00 2001 From: Smit Date: Thu, 7 May 2026 17:19:43 +0530 Subject: [PATCH] feat(studio): add Hyperview Studio visual HXML builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a drag-and-drop UI builder (studio/) for designing Hyperview screens visually. Built on Craft.js + Vite + React + TypeScript + Tailwind. Key capabilities: - Full HXML element palette (view, text, image, form fields, lists) - 20 MDS components with full prop APIs and linked-node slot system - Searchable icon picker (280+ icons from Instawork tokens) - Behavior editor (triggers, actions, hrefs, events) - Style catalog with modifier states (pressed, selected, focused) - Bidirectional code panel (Monaco editor ↔ canvas sync) - Built-in HXML preview server with WebSocket hot-reload - HXML export/import with full serialization roundtrip - Keyboard shortcuts (undo/redo, delete, duplicate) Co-authored-by: Cursor --- studio/index.html | 12 + studio/package-lock.json | 1564 +++++++++++++++++ studio/package.json | 29 + studio/src/App.tsx | 138 ++ studio/src/components/HvBody.tsx | 28 + studio/src/components/HvDateField.tsx | 38 + studio/src/components/HvDoc.tsx | 26 + studio/src/components/HvForm.tsx | 35 + studio/src/components/HvHeader.tsx | 27 + studio/src/components/HvImage.tsx | 51 + studio/src/components/HvItem.tsx | 36 + studio/src/components/HvList.tsx | 33 + studio/src/components/HvOption.tsx | 34 + studio/src/components/HvScreen.tsx | 27 + studio/src/components/HvSelectMultiple.tsx | 38 + studio/src/components/HvSelectSingle.tsx | 34 + studio/src/components/HvSpinner.tsx | 30 + studio/src/components/HvSwitch.tsx | 37 + studio/src/components/HvText.tsx | 66 + studio/src/components/HvTextField.tsx | 45 + studio/src/components/HvView.tsx | 68 + studio/src/components/index.ts | 37 + studio/src/components/mds/MdsAlert.tsx | 80 + studio/src/components/mds/MdsAvatar.tsx | 52 + studio/src/components/mds/MdsBanner.tsx | 49 + studio/src/components/mds/MdsBottomsheet.tsx | 69 + studio/src/components/mds/MdsButton.tsx | 65 + studio/src/components/mds/MdsCallout.tsx | 47 + studio/src/components/mds/MdsCard.tsx | 39 + studio/src/components/mds/MdsCarousel.tsx | 51 + studio/src/components/mds/MdsCarouselItem.tsx | 25 + studio/src/components/mds/MdsDialog.tsx | 38 + studio/src/components/mds/MdsEmptyStatus.tsx | 37 + studio/src/components/mds/MdsFooter.tsx | 27 + studio/src/components/mds/MdsIcon.tsx | 31 + studio/src/components/mds/MdsList.tsx | 74 + studio/src/components/mds/MdsListItem.tsx | 59 + studio/src/components/mds/MdsPill.tsx | 26 + studio/src/components/mds/MdsSeparator.tsx | 27 + studio/src/components/mds/MdsSkeleton.tsx | 32 + studio/src/components/mds/MdsText.tsx | 75 + studio/src/components/mds/MdsTitlebar.tsx | 61 + studio/src/components/mds/index.ts | 43 + studio/src/components/mds/types.ts | 68 + studio/src/components/types.ts | 34 + studio/src/editor/BehaviorEditor.tsx | 294 ++++ studio/src/editor/CodePanel.tsx | 104 ++ studio/src/editor/IconPicker.tsx | 78 + studio/src/editor/LayersTree.tsx | 78 + studio/src/editor/PropertyPanel.tsx | 504 ++++++ studio/src/editor/RepeatableForm.tsx | 95 + studio/src/editor/SlotIndicator.tsx | 46 + studio/src/editor/SlotRegion.tsx | 36 + studio/src/editor/StyleCatalog.tsx | 215 +++ studio/src/editor/Toolbar.tsx | 109 ++ studio/src/editor/Toolbox.tsx | 167 ++ studio/src/editor/useKeyboardShortcuts.ts | 68 + studio/src/editor/usePreviewSync.ts | 42 + studio/src/index.css | 66 + studio/src/main.tsx | 10 + studio/src/serialization/deserialize.ts | 363 ++++ studio/src/serialization/serialize.ts | 401 +++++ studio/src/server/vite-plugin.ts | 84 + studio/src/stores/behaviors.ts | 106 ++ studio/src/stores/styles.ts | 88 + studio/src/vite-env.d.ts | 1 + studio/tsconfig.json | 21 + studio/vite.config.ts | 11 + 68 files changed, 6529 insertions(+) create mode 100644 studio/index.html create mode 100644 studio/package-lock.json create mode 100644 studio/package.json create mode 100644 studio/src/App.tsx create mode 100644 studio/src/components/HvBody.tsx create mode 100644 studio/src/components/HvDateField.tsx create mode 100644 studio/src/components/HvDoc.tsx create mode 100644 studio/src/components/HvForm.tsx create mode 100644 studio/src/components/HvHeader.tsx create mode 100644 studio/src/components/HvImage.tsx create mode 100644 studio/src/components/HvItem.tsx create mode 100644 studio/src/components/HvList.tsx create mode 100644 studio/src/components/HvOption.tsx create mode 100644 studio/src/components/HvScreen.tsx create mode 100644 studio/src/components/HvSelectMultiple.tsx create mode 100644 studio/src/components/HvSelectSingle.tsx create mode 100644 studio/src/components/HvSpinner.tsx create mode 100644 studio/src/components/HvSwitch.tsx create mode 100644 studio/src/components/HvText.tsx create mode 100644 studio/src/components/HvTextField.tsx create mode 100644 studio/src/components/HvView.tsx create mode 100644 studio/src/components/index.ts create mode 100644 studio/src/components/mds/MdsAlert.tsx create mode 100644 studio/src/components/mds/MdsAvatar.tsx create mode 100644 studio/src/components/mds/MdsBanner.tsx create mode 100644 studio/src/components/mds/MdsBottomsheet.tsx create mode 100644 studio/src/components/mds/MdsButton.tsx create mode 100644 studio/src/components/mds/MdsCallout.tsx create mode 100644 studio/src/components/mds/MdsCard.tsx create mode 100644 studio/src/components/mds/MdsCarousel.tsx create mode 100644 studio/src/components/mds/MdsCarouselItem.tsx create mode 100644 studio/src/components/mds/MdsDialog.tsx create mode 100644 studio/src/components/mds/MdsEmptyStatus.tsx create mode 100644 studio/src/components/mds/MdsFooter.tsx create mode 100644 studio/src/components/mds/MdsIcon.tsx create mode 100644 studio/src/components/mds/MdsList.tsx create mode 100644 studio/src/components/mds/MdsListItem.tsx create mode 100644 studio/src/components/mds/MdsPill.tsx create mode 100644 studio/src/components/mds/MdsSeparator.tsx create mode 100644 studio/src/components/mds/MdsSkeleton.tsx create mode 100644 studio/src/components/mds/MdsText.tsx create mode 100644 studio/src/components/mds/MdsTitlebar.tsx create mode 100644 studio/src/components/mds/index.ts create mode 100644 studio/src/components/mds/types.ts create mode 100644 studio/src/components/types.ts create mode 100644 studio/src/editor/BehaviorEditor.tsx create mode 100644 studio/src/editor/CodePanel.tsx create mode 100644 studio/src/editor/IconPicker.tsx create mode 100644 studio/src/editor/LayersTree.tsx create mode 100644 studio/src/editor/PropertyPanel.tsx create mode 100644 studio/src/editor/RepeatableForm.tsx create mode 100644 studio/src/editor/SlotIndicator.tsx create mode 100644 studio/src/editor/SlotRegion.tsx create mode 100644 studio/src/editor/StyleCatalog.tsx create mode 100644 studio/src/editor/Toolbar.tsx create mode 100644 studio/src/editor/Toolbox.tsx create mode 100644 studio/src/editor/useKeyboardShortcuts.ts create mode 100644 studio/src/editor/usePreviewSync.ts create mode 100644 studio/src/index.css create mode 100644 studio/src/main.tsx create mode 100644 studio/src/serialization/deserialize.ts create mode 100644 studio/src/serialization/serialize.ts create mode 100644 studio/src/server/vite-plugin.ts create mode 100644 studio/src/stores/behaviors.ts create mode 100644 studio/src/stores/styles.ts create mode 100644 studio/src/vite-env.d.ts create mode 100644 studio/tsconfig.json create mode 100644 studio/vite.config.ts 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()}
+ ) : ( + + {type === 'person' + ? <> + : <> + } + + )} +
+ ); +} + +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}`} + > + + + {name.slice(0, 2)} + +
+ ); +} + +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 */} + +
+ +
+
Header
+ + {/* header slot */} + +
+ +
+
Item Template
+
+ + + +
+
+ +
+
Footer
+ + {/* footer slot */} + +
+ +
+
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 && ( +
+ {/* Trigger */} + + + {/* Action */} + + + {/* Href (for navigation/fetch actions) */} + {showHref && ( + + )} + + {/* Target (for show/hide/toggle/set-value) */} + {showTarget && ( + + )} + + {/* Event name */} + {showEventName && ( + + )} + + {/* New value (for set-value) */} + {showNewValue && ( + + )} + + {/* Loading indicators */} + {showLoadIndicators && ( +
+ + +
+ )} + + {/* Alert fields */} + {showAlert && ( + <> + + + + )} + + {/* Scroll fields */} + {showScroll && ( + <> + +
+ + +
+ + )} +
+ )} +
+ ); +} + +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 && ( +
+ + {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 ( +
+
+ + Layers +
+ +
+ ); +} 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 ( +
+ {STYLE_PROPERTIES.map((prop) => ( + + ))} +
+ ); +} + +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 ( +
+
+ + Styles +
+ +
+ 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`; + } + } + 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}\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}\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}\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, + }, +});