ui:next widgets1/2

This commit is contained in:
lovebird 2026-04-09 18:28:57 +02:00
parent 1407e6620e
commit 0626be2cf6
19 changed files with 696 additions and 379 deletions

View File

View File

@ -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
}
}
}
}
}

View File

@ -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",

View File

@ -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) {
<Link to="/examples/widgets-system" className="link">
widgets + plugins
</Link>
<Link to="/examples/layout" className="link">
layout nodes
</Link>
<Link to="/examples/loader-demo" className="link">
loader
</Link>
@ -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,

View File

@ -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 (
<section className="nested">
<h1>Flex node scaffold</h1>
<p className="muted">
Unified Node model (§14) <code>flex</code> + <code>flex-row</code> nodes rendered by{" "}
<code>NodeRenderer</code>. No <code>rowId</code>/column indices; position = order in{" "}
<code>children[]</code>.
</p>
{page ? (
<NodeRenderer node={page.root} pageId={PAGE_ID} />
) : (
<p className="muted">Bootstrapping</p>
)}
<details className="panel" style={{ marginTop: "2rem" }}>
<summary style={{ cursor: "pointer" }}>
<strong>Node tree (raw)</strong>
</summary>
<pre style={{ fontSize: "0.75rem", overflow: "auto", maxHeight: "400px" }}>
{JSON.stringify(page?.root ?? null, null, 2)}
</pre>
</details>
</section>
);
}

View File

@ -24,7 +24,7 @@ const rootRoute = createRootRoute({
App Settings
</Link>
<Link to="/examples" className="link">
Migration examples
Examples
</Link>
</nav>
</header>

View File

@ -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<PageLayout | null> {
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<void> {
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<void> {
localStorage.removeItem(pageKey(pageId));
writeIndex(readIndex().filter((e) => e.id !== pageId));
}
async list(): Promise<IndexEntry[]> {
return readIndex();
}
}

View File

@ -0,0 +1,9 @@
import type { PageLayout } from "@/widgets/types";
/** Swap implementations to change the storage backend. */
export interface PagePersistence {
load(pageId: string): Promise<PageLayout | null>;
save(pageId: string, layout: PageLayout): Promise<void>;
remove(pageId: string): Promise<void>;
list(): Promise<Array<{ id: string; name: string }>>;
}

View File

@ -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<string, PageLayout>;
// ── Page lifecycle ──────────────────────────────────────────────────────
initPage(layout: PageLayout): void;
loadPage(pageId: string): Promise<PageLayout | null>;
savePage(pageId: string): Promise<void>;
removePage(pageId: string): Promise<void>;
// ── 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<Record<string, unknown>>): void;
}
// ─── Factory ─────────────────────────────────────────────────────────────────
function createStore(persistence: PagePersistence) {
return create<LayoutState>()((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);
}
});
},
};

View File

@ -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<FlexNodeProps>({
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<FlexRowNodeProps>({
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<TextBlockWidgetProps>({
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 },
});
},
};

View File

@ -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";

View File

@ -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 = (
<div className="relative min-w-0 flex flex-col" style={{ gap: resolvedGap }}>
{children}
</div>
);
if (showTitle && title) {
return (
<div>
<h3 className="text-sm font-semibold text-slate-600 dark:text-slate-300 mb-2">
{title}
</h3>
{inner}
</div>
);
}
return inner;
}

View File

@ -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 (
<div
className="grid min-w-0 max-md:!grid-cols-1"
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
}}
>
{children}
</div>
);
}

View File

@ -71,7 +71,3 @@ export class HookRegistry {
}
}
/** Stub store until real Zustand layout store is wired */
export function createStubLayoutStore(): LayoutStore {
return { __stub: true };
}

View File

@ -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<string, WidgetPlugin>();
@ -11,7 +11,7 @@ export class PluginManager {
private readonly widgetRegistry: WidgetRegistry,
private readonly hookRegistry: HookRegistry,
private readonly slotRegistry: SlotRegistry,
private readonly layoutStore: Record<string, unknown>,
private readonly storeAccessor: LayoutStoreAccessor,
) {}
async register(plugin: WidgetPlugin): Promise<void> {
@ -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,
});

View File

@ -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<string, unknown>;
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<LayoutStore>,
getStore: () => storeAccessor.getState(),
subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) => {
callback(selector(layoutStore as LayoutStore));
return () => {};
},
subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) =>
storeAccessor.subscribe(selector, callback),
};
}

View File

@ -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<string, unknown>) => Promise<void>;
}
/**
* 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<string, unknown>) => {
await onPropsChange?.(node.id, partial);
};
return (
<Component
{...(node.props as Record<string, unknown>)}
nodeId={node.id}
widgetInstanceId={node.id}
widgetDefId={node.type}
layout={node.layout}
isEditMode={isEditMode}
onPropsChange={handlePropsChange}
>
{canNest
? node.children.map((child) => (
<NodeRenderer
key={child.id}
node={child}
depth={depth + 1}
isEditMode={isEditMode}
pageId={pageId}
onPropsChange={onPropsChange}
/>
))
: null}
</Component>
);
}

View File

@ -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<string, unknown> = {};
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,
);

View File

@ -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<string, unknown>;
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<string, unknown>;
pageContext?: Record<string, unknown>;
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<P extends BaseWidgetProps = BaseWidgetProps> {
editComponent?: React.LazyExoticComponent<ComponentType<P>>;
previewComponent?: ComponentType<P>;
validate?: (props: P) => Record<string, string> | 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<string, unknown>;
/** Read-only snapshot exposed to plugins via PluginAPI.getStore(). */
export type LayoutStore = {
pages: Record<string, PageLayout>;
};
export interface HookContext {
pluginId: string;
@ -140,6 +189,15 @@ export type WidgetWrapper = <P extends BaseWidgetProps>(
Component: ComponentType<P>,
) => ComponentType<P>;
/** Minimal accessor interface passed into PluginManager / createPluginAPI. */
export interface LayoutStoreAccessor {
getState: () => Readonly<LayoutStore>;
subscribe: <T>(
selector: (state: LayoutStore) => T,
callback: (value: T) => void,
) => () => void;
}
export interface PluginAPI {
registerWidget: <P extends BaseWidgetProps>(definition: WidgetDefinition<P>) => void;
unregisterWidget: (widgetId: string) => void;