diff --git a/packages/ui-next/docs/legacy/layout.json b/packages/ui-next/docs/legacy/layout.json new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui-next/package-lock.json b/packages/ui-next/package-lock.json index 02bd1fa2..cc4a4f44 100644 --- a/packages/ui-next/package-lock.json +++ b/packages/ui-next/package-lock.json @@ -15,7 +15,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwindcss": "^4.2.2", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { "@types/node": "^22.10.1", @@ -733,356 +734,6 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1457,14 +1108,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1664,7 +1315,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2467,7 +2118,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2965,6 +2615,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 8237b9b7..42a1e40d 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -19,7 +19,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwindcss": "^4.2.2", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { "@types/node": "^22.10.1", diff --git a/packages/ui-next/src/examples/migration/routeTree.tsx b/packages/ui-next/src/examples/migration/routeTree.tsx index e79b1fda..ea76a65c 100644 --- a/packages/ui-next/src/examples/migration/routeTree.tsx +++ b/packages/ui-next/src/examples/migration/routeTree.tsx @@ -21,6 +21,7 @@ import { } from "@/examples/migration/searchSchema"; import { ScrollRestorationDemo } from "@/examples/migration/ScrollRestorationDemo"; import { WidgetSystemDemoPage } from "@/examples/widgets/WidgetSystemDemoPage"; +import { FlexNodeDemoPage } from "@/examples/widgets/FlexNodeDemoPage"; export function buildMigrationExamplesBranch(rootRoute: AnyRoute) { function ExamplesIndex() { @@ -94,6 +95,9 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) { widgets + plugins + + layout nodes + loader @@ -294,6 +298,12 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) { component: WidgetSystemDemoPage, }); + const flexNodeDemoRoute = createRoute({ + getParentRoute: () => examplesRoute, + path: "layout", + component: FlexNodeDemoPage, + }); + const loaderDemoRoute = createRoute({ getParentRoute: () => examplesRoute, path: "loader-demo", @@ -375,6 +385,7 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) { scrollRestorationDemoRoute, lazyScrollRestorationRoute, widgetSystemDemoRoute, + flexNodeDemoRoute, loaderDemoRoute, navigateDemoRoute, redirectRoute, diff --git a/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx new file mode 100644 index 00000000..1b4bd0b9 --- /dev/null +++ b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx @@ -0,0 +1,103 @@ +import { useEffect } from "react"; + +import { bootWidgetSystem } from "@/widgets/bootstrap/boot"; +import { NodeRenderer } from "@/widgets/renderer/NodeRenderer"; +import { useLayoutStore } from "@/store/useLayoutStore"; +import type { LayoutNode, PageLayout } from "@/widgets/types"; + +// ─── Seed data ──────────────────────────────────────────────────────────────── +// Mirrors the "FlexibleContainer + PhotoCards" example from widgets-api §14, +// adapted to use text-block widgets (available in the core plugin). + +const PAGE_ID = "demo-flex-page"; + +function makeSeedLayout(): PageLayout { + const textBlock = (id: string, title: string, body: string): LayoutNode => ({ + id, + type: "text-block", + props: { title, body }, + children: [], + parentId: null, + }); + + const row1: LayoutNode = { + id: "r1", + type: "flex-row", + props: {}, + children: [textBlock("w1", "Hero section", "Full-width content spanning the single column.")], + parentId: "root", + layout: { display: "grid", columns: 1, gap: 16 }, + }; + + const row2: LayoutNode = { + id: "r2", + type: "flex-row", + props: {}, + children: [ + textBlock("w2", "Card A", "Column 0 — flex-row with 3 equal columns."), + textBlock("w3", "Card B", "Column 1 — position = order in children[]."), + textBlock("w4", "Card C", "Column 2 — no rowId/column indices needed."), + ], + parentId: "root", + layout: { display: "grid", columns: 3, gap: 12 }, + }; + + const root: LayoutNode = { + id: "root", + type: "flex", + props: { title: "Demo flex container", showTitle: true, gap: 24 }, + children: [row1, row2], + parentId: null, + layout: { display: "flex", direction: "column", gap: 24 }, + }; + + return { + id: PAGE_ID, + name: "Flex node demo", + root, + version: "1.0.0", + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +// ─── Page component ─────────────────────────────────────────────────────────── + +export function FlexNodeDemoPage() { + const { pages, initPage } = useLayoutStore(); + const page = pages[PAGE_ID]; + + useEffect(() => { + void bootWidgetSystem().then(() => { + if (!useLayoutStore.getState().pages[PAGE_ID]) { + initPage(makeSeedLayout()); + } + }); + }, [initPage]); + + return ( +
+

Flex node scaffold

+

+ Unified Node model (§14) — flex + flex-row nodes rendered by{" "} + NodeRenderer. No rowId/column indices; position = order in{" "} + children[]. +

+ + {page ? ( + + ) : ( +

Bootstrapping…

+ )} + +
+ + Node tree (raw) + +
+          {JSON.stringify(page?.root ?? null, null, 2)}
+        
+
+
+ ); +} diff --git a/packages/ui-next/src/router.tsx b/packages/ui-next/src/router.tsx index 4f95e022..1505e898 100644 --- a/packages/ui-next/src/router.tsx +++ b/packages/ui-next/src/router.tsx @@ -24,7 +24,7 @@ const rootRoute = createRootRoute({ App → Settings - Migration examples + Examples diff --git a/packages/ui-next/src/store/persistence/LocalStoragePagePersistence.ts b/packages/ui-next/src/store/persistence/LocalStoragePagePersistence.ts new file mode 100644 index 00000000..95acbe92 --- /dev/null +++ b/packages/ui-next/src/store/persistence/LocalStoragePagePersistence.ts @@ -0,0 +1,62 @@ +import type { PageLayout } from "@/widgets/types"; +import type { PagePersistence } from "./PagePersistence"; + +const KEY_PREFIX = "layout:"; +const INDEX_KEY = "layout:__index__"; + +type IndexEntry = { id: string; name: string }; + +function indexKey(): string { + return INDEX_KEY; +} + +function pageKey(pageId: string): string { + return `${KEY_PREFIX}${pageId}`; +} + +function readIndex(): IndexEntry[] { + try { + return JSON.parse(localStorage.getItem(indexKey()) ?? "[]") as IndexEntry[]; + } catch { + return []; + } +} + +function writeIndex(entries: IndexEntry[]): void { + localStorage.setItem(indexKey(), JSON.stringify(entries)); +} + +export class LocalStoragePagePersistence implements PagePersistence { + async load(pageId: string): Promise { + try { + const raw = localStorage.getItem(pageKey(pageId)); + if (!raw) return null; + return JSON.parse(raw) as PageLayout; + } catch { + return null; + } + } + + async save(pageId: string, layout: PageLayout): Promise { + localStorage.setItem(pageKey(pageId), JSON.stringify(layout)); + + const index = readIndex(); + const existing = index.findIndex((e) => e.id === pageId); + const entry: IndexEntry = { id: pageId, name: layout.name }; + if (existing >= 0) { + index[existing] = entry; + } else { + index.push(entry); + } + writeIndex(index); + } + + async remove(pageId: string): Promise { + localStorage.removeItem(pageKey(pageId)); + writeIndex(readIndex().filter((e) => e.id !== pageId)); + } + + async list(): Promise { + return readIndex(); + } +} diff --git a/packages/ui-next/src/store/persistence/PagePersistence.ts b/packages/ui-next/src/store/persistence/PagePersistence.ts new file mode 100644 index 00000000..70252cb9 --- /dev/null +++ b/packages/ui-next/src/store/persistence/PagePersistence.ts @@ -0,0 +1,9 @@ +import type { PageLayout } from "@/widgets/types"; + +/** Swap implementations to change the storage backend. */ +export interface PagePersistence { + load(pageId: string): Promise; + save(pageId: string, layout: PageLayout): Promise; + remove(pageId: string): Promise; + list(): Promise>; +} diff --git a/packages/ui-next/src/store/useLayoutStore.ts b/packages/ui-next/src/store/useLayoutStore.ts new file mode 100644 index 00000000..fe59b792 --- /dev/null +++ b/packages/ui-next/src/store/useLayoutStore.ts @@ -0,0 +1,182 @@ +import { create } from "zustand"; + +import type { LayoutNode, LayoutStoreAccessor, PageLayout } from "@/widgets/types"; +import type { PagePersistence } from "./persistence/PagePersistence"; +import { LocalStoragePagePersistence } from "./persistence/LocalStoragePagePersistence"; + +// ─── Tree helpers ──────────────────────────────────────────────────────────── + +function findNode(root: LayoutNode, id: string): LayoutNode | null { + if (root.id === id) return root; + for (const child of root.children) { + const found = findNode(child, id); + if (found) return found; + } + return null; +} + +/** + * Returns a new root with the given node replaced by `updater(node)`. + * Immutable — does not mutate the original tree. + */ +function mapNode( + root: LayoutNode, + id: string, + updater: (node: LayoutNode) => LayoutNode, +): LayoutNode { + if (root.id === id) return updater(root); + return { + ...root, + children: root.children.map((c) => mapNode(c, id, updater)), + }; +} + +/** Remove a node from the tree (by id). Returns new root. */ +function removeNodeFromTree(root: LayoutNode, id: string): LayoutNode { + return { + ...root, + children: root.children + .filter((c) => c.id !== id) + .map((c) => removeNodeFromTree(c, id)), + }; +} + +// ─── Store types ───────────────────────────────────────────────────────────── + +export interface LayoutState { + pages: Record; + + // ── Page lifecycle ────────────────────────────────────────────────────── + initPage(layout: PageLayout): void; + loadPage(pageId: string): Promise; + savePage(pageId: string): Promise; + removePage(pageId: string): Promise; + + // ── Tree operations ───────────────────────────────────────────────────── + /** Append (or insert at index) a node under parentId. */ + addChild(pageId: string, parentId: string, node: LayoutNode, index?: number): void; + /** Remove a node from the tree (by nodeId). */ + removeNode(pageId: string, nodeId: string): void; + /** Move nodeId to a new parent, optionally at a specific index. */ + moveNode(pageId: string, nodeId: string, newParentId: string, index?: number): void; + /** Shallow-merge props onto a node. */ + updateNodeProps(pageId: string, nodeId: string, props: Partial>): void; +} + +// ─── Factory ───────────────────────────────────────────────────────────────── + +function createStore(persistence: PagePersistence) { + return create()((set, get) => ({ + pages: {}, + + // ── Page lifecycle ────────────────────────────────────────────────── + + initPage(layout) { + set((s) => ({ pages: { ...s.pages, [layout.id]: layout } })); + }, + + async loadPage(pageId) { + const layout = await persistence.load(pageId); + if (layout) { + set((s) => ({ pages: { ...s.pages, [pageId]: layout } })); + } + return layout; + }, + + async savePage(pageId) { + const layout = get().pages[pageId]; + if (layout) await persistence.save(pageId, { ...layout, updatedAt: Date.now() }); + }, + + async removePage(pageId) { + await persistence.remove(pageId); + set((s) => { + const pages = { ...s.pages }; + delete pages[pageId]; + return { pages }; + }); + }, + + // ── Tree operations ───────────────────────────────────────────────── + + addChild(pageId, parentId, node, index) { + set((s) => { + const page = s.pages[pageId]; + if (!page) return s; + const newRoot = mapNode(page.root, parentId, (parent) => { + const kids = [...parent.children]; + const at = index ?? kids.length; + kids.splice(at, 0, { ...node, parentId }); + return { ...parent, children: kids }; + }); + return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } }; + }); + }, + + removeNode(pageId, nodeId) { + set((s) => { + const page = s.pages[pageId]; + if (!page) return s; + const newRoot = removeNodeFromTree(page.root, nodeId); + return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } }; + }); + }, + + moveNode(pageId, nodeId, newParentId, index) { + set((s) => { + const page = s.pages[pageId]; + if (!page) return s; + + const moving = findNode(page.root, nodeId); + if (!moving) return s; + + // Remove from old position, then insert under new parent + const withoutNode = removeNodeFromTree(page.root, nodeId); + const newRoot = mapNode(withoutNode, newParentId, (parent) => { + const kids = [...parent.children]; + const at = index ?? kids.length; + kids.splice(at, 0, { ...moving, parentId: newParentId }); + return { ...parent, children: kids }; + }); + + return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } }; + }); + }, + + updateNodeProps(pageId, nodeId, props) { + set((s) => { + const page = s.pages[pageId]; + if (!page) return s; + const newRoot = mapNode(page.root, nodeId, (n) => ({ + ...n, + props: { ...n.props, ...props }, + })); + return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } }; + }); + }, + })); +} + +// ─── Singleton + accessor ──────────────────────────────────────────────────── + +export const useLayoutStore = createStore(new LocalStoragePagePersistence()); + +/** + * Adapter implementing `LayoutStoreAccessor` so the plugin system can read + * the store without depending directly on Zustand. + */ +export const layoutStoreAccessor: LayoutStoreAccessor = { + getState: () => ({ + pages: useLayoutStore.getState().pages, + }), + subscribe: (selector, callback) => { + let prev = selector({ pages: useLayoutStore.getState().pages }); + return useLayoutStore.subscribe((s) => { + const next = selector({ pages: s.pages }); + if (!Object.is(next, prev)) { + prev = next; + callback(next); + } + }); + }, +}; diff --git a/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx index cdbed0d4..dcaf2628 100644 --- a/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx +++ b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx @@ -3,8 +3,14 @@ import type { WidgetPlugin } from "@/widgets/types"; import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget"; import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget"; +import { FlexNode } from "@/widgets/nodes/flex/FlexNode"; +import type { FlexNodeProps } from "@/widgets/nodes/flex/FlexNode"; +import { FlexRowNode } from "@/widgets/nodes/flex/FlexRowNode"; +import type { FlexRowNodeProps } from "@/widgets/nodes/flex/FlexRowNode"; + /** - * Registers baseline widgets — mirrors wrapping `registerWidgets()` as a plugin (widgets-api §13). + * Registers baseline widgets and layout nodes. + * Mirrors `registerWidgets()` as a plugin (widgets-api §13). */ export const coreWidgetsPlugin: WidgetPlugin = { id: "polymech:core-widgets", @@ -12,6 +18,44 @@ export const coreWidgetsPlugin: WidgetPlugin = { version: "0.0.0", priority: 100, setup(api) { + // ── Layout nodes (§14) ────────────────────────────────────────────────── + + api.registerWidget({ + component: FlexNode, + metadata: { + id: "flex", + name: "Flex container", + category: "layout", + description: "Vertical flex column; children are flex-row nodes.", + tags: ["layout", "core"], + defaultProps: { gap: 16 }, + }, + constraints: { + canHaveChildren: true, + allowedChildTypes: ["flex-row"], + draggable: true, + deletable: true, + }, + }); + + api.registerWidget({ + component: FlexRowNode, + metadata: { + id: "flex-row", + name: "Flex row", + category: "layout", + description: "CSS grid row; columns controlled by node.layout.columns.", + tags: ["layout", "core"], + }, + constraints: { + canHaveChildren: true, + draggable: false, + deletable: true, + }, + }); + + // ── Content widgets ────────────────────────────────────────────────────── + api.registerWidget({ component: TextBlockWidget, metadata: { @@ -29,6 +73,7 @@ export const coreWidgetsPlugin: WidgetPlugin = { body: { type: "markdown", label: "Body", group: "Content" }, }, }, + constraints: { canHaveChildren: false, draggable: true, deletable: true }, }); }, }; diff --git a/packages/ui-next/src/widgets/index.ts b/packages/ui-next/src/widgets/index.ts index 8f114de4..2dc3dba4 100644 --- a/packages/ui-next/src/widgets/index.ts +++ b/packages/ui-next/src/widgets/index.ts @@ -1,13 +1,20 @@ /** * Widget / plugin / extension-slot scaffolding (see `packages/ui/docs/widgets-api.md`). */ + +// ─── Types ─────────────────────────────────────────────────────────────────── export type { BaseWidgetProps, ConfigField, HookContext, HookHandler, HookName, + LayoutNode, LayoutStore, + LayoutStoreAccessor, + NodeConstraints, + NodeLayout, + PageLayout, PluginAPI, SlotId, SlotProps, @@ -18,6 +25,7 @@ export type { WidgetWrapper, } from "@/widgets/types"; +// ─── Registries & plugin system ────────────────────────────────────────────── export { WidgetRegistry } from "@/widgets/registry/WidgetRegistry"; export { HookRegistry } from "@/widgets/plugins/HookRegistry"; export { SlotRegistry } from "@/widgets/plugins/SlotRegistry"; @@ -25,17 +33,34 @@ export { PluginManager } from "@/widgets/plugins/PluginManager"; export { createPluginAPI } from "@/widgets/plugins/createPluginAPI"; export { ExtensionSlot } from "@/widgets/slots/ExtensionSlot"; +// ─── Singletons ─────────────────────────────────────────────────────────────── export { - layoutStore, + useLayoutStore, widgetRegistry, hookRegistry, slotRegistry, pluginManager, } from "@/widgets/system"; +// ─── Bootstrap ─────────────────────────────────────────────────────────────── export { bootWidgetSystem } from "@/widgets/bootstrap/boot"; export { coreWidgetsPlugin } from "@/widgets/bootstrap/corePlugin"; export { demoToolbarPlugin } from "@/widgets/bootstrap/demoToolbarPlugin"; +// ─── Node renderer ─────────────────────────────────────────────────────────── +export { NodeRenderer } from "@/widgets/renderer/NodeRenderer"; +export type { NodeRendererProps } from "@/widgets/renderer/NodeRenderer"; + +// ─── Layout nodes ──────────────────────────────────────────────────────────── +export { FlexNode } from "@/widgets/nodes/flex/FlexNode"; +export type { FlexNodeProps } from "@/widgets/nodes/flex/FlexNode"; +export { FlexRowNode } from "@/widgets/nodes/flex/FlexRowNode"; +export type { FlexRowNodeProps } from "@/widgets/nodes/flex/FlexRowNode"; + +// ─── Persistence ───────────────────────────────────────────────────────────── +export type { PagePersistence } from "@/store/persistence/PagePersistence"; +export { LocalStoragePagePersistence } from "@/store/persistence/LocalStoragePagePersistence"; + +// ─── Sample widgets ────────────────────────────────────────────────────────── export { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget"; export type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget"; diff --git a/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx b/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx new file mode 100644 index 00000000..95561ec9 --- /dev/null +++ b/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx @@ -0,0 +1,50 @@ +import type { BaseWidgetProps } from "@/widgets/types"; + +export interface FlexNodeProps extends BaseWidgetProps { + gap?: number; + title?: string; + showTitle?: boolean; + collapsible?: boolean; + enabled?: boolean; +} + +/** + * Flex container node — renders children in a vertical flex column. + * + * Structural: `children` is populated by NodeRenderer when + * `constraints.canHaveChildren` is true. + * + * node.layout.gap is the preferred source; `gap` prop is the fallback + * (stored in node.props for persistence). + */ +export function FlexNode({ + layout, + gap, + title, + showTitle, + enabled = true, + children, +}: FlexNodeProps) { + if (!enabled) return null; + + const resolvedGap = layout?.gap ?? gap ?? 16; + + const inner = ( +
+ {children} +
+ ); + + if (showTitle && title) { + return ( +
+

+ {title} +

+ {inner} +
+ ); + } + + return inner; +} diff --git a/packages/ui-next/src/widgets/nodes/flex/FlexRowNode.tsx b/packages/ui-next/src/widgets/nodes/flex/FlexRowNode.tsx new file mode 100644 index 00000000..3bd6043a --- /dev/null +++ b/packages/ui-next/src/widgets/nodes/flex/FlexRowNode.tsx @@ -0,0 +1,31 @@ +import type { BaseWidgetProps } from "@/widgets/types"; + +export interface FlexRowNodeProps extends BaseWidgetProps { + // All layout config lives in node.layout — no extra props needed. +} + +/** + * Flex-row node — renders children as a CSS grid row. + * + * `layout.columns` → gridTemplateColumns (equal fr units) + * `layout.gap` → column + row gap + * + * On narrow viewports (`max-md`) the grid collapses to a single column, + * matching the behaviour of the legacy FlexContainerView. + */ +export function FlexRowNode({ layout, children }: FlexRowNodeProps) { + const columns = layout?.columns ?? 1; + const gap = layout?.gap ?? 16; + + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui-next/src/widgets/plugins/HookRegistry.ts b/packages/ui-next/src/widgets/plugins/HookRegistry.ts index c726276a..57582420 100644 --- a/packages/ui-next/src/widgets/plugins/HookRegistry.ts +++ b/packages/ui-next/src/widgets/plugins/HookRegistry.ts @@ -71,7 +71,3 @@ export class HookRegistry { } } -/** Stub store until real Zustand layout store is wired */ -export function createStubLayoutStore(): LayoutStore { - return { __stub: true }; -} diff --git a/packages/ui-next/src/widgets/plugins/PluginManager.ts b/packages/ui-next/src/widgets/plugins/PluginManager.ts index 9dee23a0..da8e2a48 100644 --- a/packages/ui-next/src/widgets/plugins/PluginManager.ts +++ b/packages/ui-next/src/widgets/plugins/PluginManager.ts @@ -2,7 +2,7 @@ import { createPluginAPI } from "@/widgets/plugins/createPluginAPI"; import { HookRegistry } from "@/widgets/plugins/HookRegistry"; import { SlotRegistry } from "@/widgets/plugins/SlotRegistry"; import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry"; -import type { WidgetPlugin } from "@/widgets/types"; +import type { LayoutStoreAccessor, WidgetPlugin } from "@/widgets/types"; export class PluginManager { private readonly plugins = new Map(); @@ -11,7 +11,7 @@ export class PluginManager { private readonly widgetRegistry: WidgetRegistry, private readonly hookRegistry: HookRegistry, private readonly slotRegistry: SlotRegistry, - private readonly layoutStore: Record, + private readonly storeAccessor: LayoutStoreAccessor, ) {} async register(plugin: WidgetPlugin): Promise { @@ -24,7 +24,7 @@ export class PluginManager { widgetRegistry: this.widgetRegistry, hookRegistry: this.hookRegistry, slotRegistry: this.slotRegistry, - layoutStore: this.layoutStore, + storeAccessor: this.storeAccessor, pluginId: plugin.id, priority: plugin.priority ?? 0, }); diff --git a/packages/ui-next/src/widgets/plugins/createPluginAPI.ts b/packages/ui-next/src/widgets/plugins/createPluginAPI.ts index 725bf3ee..5fd06f7a 100644 --- a/packages/ui-next/src/widgets/plugins/createPluginAPI.ts +++ b/packages/ui-next/src/widgets/plugins/createPluginAPI.ts @@ -6,6 +6,7 @@ import type { HookHandler, HookName, LayoutStore, + LayoutStoreAccessor, PluginAPI, SlotId, SlotProps, @@ -19,7 +20,7 @@ type CreatePluginAPIParams = { widgetRegistry: WidgetRegistry; hookRegistry: HookRegistry; slotRegistry: SlotRegistry; - layoutStore: Record; + storeAccessor: LayoutStoreAccessor; pluginId: string; priority: number; }; @@ -28,7 +29,7 @@ export function createPluginAPI({ widgetRegistry, hookRegistry, slotRegistry, - layoutStore, + storeAccessor, pluginId, priority, }: CreatePluginAPIParams): PluginAPI { @@ -78,11 +79,9 @@ export function createPluginAPI({ slotRegistry.inject(slotId, pluginId, component); }, - getStore: () => layoutStore as Readonly, + getStore: () => storeAccessor.getState(), - subscribe: (selector: (state: LayoutStore) => T, callback: (value: T) => void) => { - callback(selector(layoutStore as LayoutStore)); - return () => {}; - }, + subscribe: (selector: (state: LayoutStore) => T, callback: (value: T) => void) => + storeAccessor.subscribe(selector, callback), }; } diff --git a/packages/ui-next/src/widgets/renderer/NodeRenderer.tsx b/packages/ui-next/src/widgets/renderer/NodeRenderer.tsx new file mode 100644 index 00000000..92c26aa3 --- /dev/null +++ b/packages/ui-next/src/widgets/renderer/NodeRenderer.tsx @@ -0,0 +1,64 @@ +import { widgetRegistry } from "@/widgets/system"; +import type { LayoutNode } from "@/widgets/types"; + +export interface NodeRendererProps { + node: LayoutNode; + depth?: number; + isEditMode?: boolean; + pageId?: string; + onPropsChange?: (nodeId: string, partial: Record) => Promise; +} + +/** + * Recursive renderer for the unified Node model (widgets-api §14). + * + * Resolves `node.type` from the WidgetRegistry, injects base node props, and + * recurses into `node.children` for nodes with `constraints.canHaveChildren`. + */ +export function NodeRenderer({ + node, + depth = 0, + isEditMode = false, + pageId = "", + onPropsChange, +}: NodeRendererProps) { + const def = widgetRegistry.get(node.type); + if (!def) { + if (import.meta.env.DEV) { + console.warn(`[NodeRenderer] Unknown node type: "${node.type}"`); + } + return null; + } + + const Component = def.component; + const canNest = def.constraints?.canHaveChildren ?? false; + + const handlePropsChange = async (partial: Record) => { + await onPropsChange?.(node.id, partial); + }; + + return ( + )} + nodeId={node.id} + widgetInstanceId={node.id} + widgetDefId={node.type} + layout={node.layout} + isEditMode={isEditMode} + onPropsChange={handlePropsChange} + > + {canNest + ? node.children.map((child) => ( + + )) + : null} + + ); +} diff --git a/packages/ui-next/src/widgets/system.ts b/packages/ui-next/src/widgets/system.ts index 9a5f69a6..ed90a56b 100644 --- a/packages/ui-next/src/widgets/system.ts +++ b/packages/ui-next/src/widgets/system.ts @@ -1,12 +1,14 @@ /** - * Singleton widget system for the POC. For tests, instantiate `PluginManager` + registries manually. + * Process-wide widget system singletons. + * For tests, instantiate PluginManager + registries manually. */ import { PluginManager } from "@/widgets/plugins/PluginManager"; import { HookRegistry } from "@/widgets/plugins/HookRegistry"; import { SlotRegistry } from "@/widgets/plugins/SlotRegistry"; import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry"; +import { layoutStoreAccessor } from "@/store/useLayoutStore"; -export const layoutStore: Record = {}; +export { useLayoutStore } from "@/store/useLayoutStore"; export const widgetRegistry = new WidgetRegistry(); export const hookRegistry = new HookRegistry(); @@ -16,5 +18,5 @@ export const pluginManager = new PluginManager( widgetRegistry, hookRegistry, slotRegistry, - layoutStore, + layoutStoreAccessor, ); diff --git a/packages/ui-next/src/widgets/types.ts b/packages/ui-next/src/widgets/types.ts index a13a8030..35dbf808 100644 --- a/packages/ui-next/src/widgets/types.ts +++ b/packages/ui-next/src/widgets/types.ts @@ -1,8 +1,46 @@ /** - * Types aligned with `packages/ui/docs/widgets-api.md` (§1, §2, §13). + * Types aligned with `packages/ui/docs/widgets-api.md` (§1, §2, §13, §14). * Intentionally minimal — extend as the host app gains real layout stores. */ -import type { ComponentType, ReactElement } from "react"; +import type { ComponentType, ReactElement, ReactNode } from "react"; + +// ─── Node model (§14) ────────────────────────────────────────────────────── + +export interface NodeLayout { + display: "flex" | "grid" | "block" | "none"; + columns?: number; + gap?: number; + direction?: "row" | "column"; + align?: "stretch" | "start" | "center" | "end"; +} + +export interface NodeConstraints { + canHaveChildren: boolean; + allowedChildTypes?: string[]; + maxChildren?: number; + draggable?: boolean; + deletable?: boolean; +} + +/** A single element in the layout tree — replaces LayoutContainer + WidgetInstance. */ +export interface LayoutNode { + id: string; + /** Registry key: 'flex' | 'flex-row' | 'text-block' | 'image' | … */ + type: string; + props: Record; + children: LayoutNode[]; + parentId: string | null; + layout?: NodeLayout; +} + +export interface PageLayout { + id: string; + name: string; + root: LayoutNode; + version: string; + createdAt: number; + updatedAt: number; +} // ─── Widget props (§2) ───────────────────────────────────────────────────── @@ -21,6 +59,12 @@ export interface BaseWidgetProps { contextVariables?: Record; pageContext?: Record; customClassName?: string; + /** Injected by NodeRenderer — the node's id (equals widgetInstanceId in node mode). */ + nodeId?: string; + /** Injected by NodeRenderer — layout hints for structural nodes. */ + layout?: NodeLayout; + /** Populated by NodeRenderer for nodes with canHaveChildren: true. */ + children?: ReactNode; } export type WidgetCategory = @@ -75,6 +119,8 @@ export interface WidgetDefinition

{ editComponent?: React.LazyExoticComponent>; previewComponent?: ComponentType

; validate?: (props: P) => Record | null; + /** Node model: structural constraints evaluated at runtime (not persisted). */ + constraints?: NodeConstraints; } export interface WidgetInstance { @@ -86,7 +132,10 @@ export interface WidgetInstance { // ─── Plugins & hooks (§13) ─────────────────────────────────────────────────── -export type LayoutStore = Record; +/** Read-only snapshot exposed to plugins via PluginAPI.getStore(). */ +export type LayoutStore = { + pages: Record; +}; export interface HookContext { pluginId: string; @@ -140,6 +189,15 @@ export type WidgetWrapper =

( Component: ComponentType

, ) => ComponentType

; +/** Minimal accessor interface passed into PluginManager / createPluginAPI. */ +export interface LayoutStoreAccessor { + getState: () => Readonly; + subscribe: ( + selector: (state: LayoutStore) => T, + callback: (value: T) => void, + ) => () => void; +} + export interface PluginAPI { registerWidget:

(definition: WidgetDefinition

) => void; unregisterWidget: (widgetId: string) => void;