ui:next widgets 1/2

This commit is contained in:
lovebird 2026-04-09 20:21:32 +02:00
parent a20bb1b6e9
commit 302e0ee5c1
8 changed files with 294 additions and 45 deletions

View File

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { bootWidgetSystem } from "@/widgets/bootstrap/boot";
import { useLayoutStore } from "@/store/useLayoutStore";
@ -10,10 +10,12 @@ import {
LazyEditableNodeRenderer,
LazyNodePropertiesPanel,
} from "@/widgets-editor/lazy";
import { treeCapabilityMap } from "@/widgets/utils/introspection";
// ─── Seed data ────────────────────────────────────────────────────────────────
const PAGE_ID = "demo-flex-page";
// Bump the ID to force a fresh seed (localStorage keys are page-scoped).
const PAGE_ID = "demo-flex-page-v3";
function makeSeedLayout(): PageLayout {
const textBlock = (id: string, title: string, body: string): LayoutNode => ({
@ -24,15 +26,33 @@ function makeSeedLayout(): PageLayout {
parentId: null,
});
const imageNode = (id: string, seed: string): LayoutNode => ({
id,
type: "image",
props: {
url: `https://picsum.photos/seed/${seed}/800/400`,
alt: "Demo image",
fit: "cover",
height: 180,
},
children: [],
parentId: null,
});
// Row 1 — 2-col: hero text | image
const row1: LayoutNode = {
id: "r1",
type: "flex-row",
props: {},
children: [textBlock("w1", "Hero section", "Full-width — single column row.")],
children: [
textBlock("w1", "Hero section", "Full-width — two-column row with an image."),
imageNode("img1", "polymech"),
],
parentId: "root",
layout: { display: "grid", columns: 1, gap: 16 },
layout: { display: "grid", columns: 2, gap: 16 },
};
// Row 2 — 3-col: cards
const row2: LayoutNode = {
id: "r2",
type: "flex-row",
@ -46,11 +66,24 @@ function makeSeedLayout(): PageLayout {
layout: { display: "grid", columns: 3, gap: 12 },
};
// Row 3 — 2-col: more images
const row3: LayoutNode = {
id: "r3",
type: "flex-row",
props: {},
children: [
imageNode("img2", "alpine"),
imageNode("img3", "forest"),
],
parentId: "root",
layout: { display: "grid", columns: 2, gap: 12 },
};
const root: LayoutNode = {
id: "root",
type: "flex",
props: { title: "Demo flex container", showTitle: true, gap: 24 },
children: [row1, row2],
children: [row1, row2, row3],
parentId: null,
layout: { display: "flex", direction: "column", gap: 24 },
};
@ -65,6 +98,84 @@ function makeSeedLayout(): PageLayout {
};
}
// ─── Introspection badge ──────────────────────────────────────────────────────
const CAP_COLORS: Record<string, string> = {
text: "#3b82f6",
image: "#f59e0b",
};
function TreeIntrospectionPanel({ root }: { root: LayoutNode }) {
const { setSelectedNodeId } = useEditorContext();
const capMap = useMemo(() => treeCapabilityMap(root), [root]);
const interesting = Object.entries(capMap).filter(([cap]) =>
["text", "image"].includes(cap),
);
if (interesting.length === 0) return null;
return (
<details className="panel" style={{ marginTop: "1rem" }}>
<summary style={{ cursor: "pointer" }}>
<strong>Tree introspection</strong>
<span className="muted" style={{ fontSize: "0.75rem", marginLeft: "0.5rem" }}>
{interesting.map(([, nodes]) => nodes.length).reduce((a, b) => a + b, 0)}{" "}
annotated node(s)
</span>
</summary>
<div style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem", marginTop: "0.75rem" }}>
{interesting.map(([cap, nodes]) => (
<div key={cap}>
<div
style={{
fontSize: "0.625rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.06em",
color: CAP_COLORS[cap] ?? "#94a3b8",
marginBottom: "0.4rem",
}}
>
{cap} ({nodes.length})
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem" }}>
{nodes.map((n) => (
<button
key={n.id}
onClick={() => setSelectedNodeId(n.id)}
style={{
padding: "0.15rem 0.45rem",
fontSize: "0.7rem",
fontFamily: "monospace",
borderRadius: "0.25rem",
border: `1px solid ${CAP_COLORS[cap] ?? "#334155"}`,
background: "transparent",
color: CAP_COLORS[cap] ?? "#e2e8f0",
cursor: "pointer",
}}
>
{n.id}
</button>
))}
</div>
</div>
))}
</div>
<p
className="muted"
style={{ fontSize: "0.7rem", marginTop: "0.75rem", marginBottom: 0 }}
>
Click a node ID to select it. Set Typography on the root{" "}
<code>flex</code> node to cascade font to all TEXT widgets at once.
</p>
</details>
);
}
// ─── Inner page (needs EditorContext in scope) ────────────────────────────────
function FlexNodeDemoInner() {
@ -83,12 +194,19 @@ function FlexNodeDemoInner() {
return (
<section className="nested">
{/* ── Toolbar ── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
}}
>
<div>
<h1 style={{ margin: 0 }}>Flex node scaffold</h1>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
Node model §14 <code>flex</code> + <code>flex-row</code> via{" "}
<code>EditableNodeRenderer</code>. Toggle edit to drag-reorder.
Node model §14 <code>flex</code> + <code>flex-row</code> +{" "}
<code>image</code> widgets. Toggle edit to drag-reorder.
</p>
</div>
<LazyEditToggle />
@ -129,12 +247,17 @@ function FlexNodeDemoInner() {
<LazyNodePropertiesPanel />
</div>
{/* ── Tree introspection ── */}
{page && <TreeIntrospectionPanel root={page.root} />}
{/* ── Raw tree inspector ── */}
<details className="panel" style={{ marginTop: "2rem" }}>
<details className="panel" style={{ marginTop: "1rem" }}>
<summary style={{ cursor: "pointer" }}>
<strong>Node tree (raw)</strong>
</summary>
<pre style={{ fontSize: "0.75rem", overflow: "auto", maxHeight: "400px" }}>
<pre
style={{ fontSize: "0.75rem", overflow: "auto", maxHeight: "400px" }}
>
{JSON.stringify(page?.root ?? null, null, 2)}
</pre>
</details>

View File

@ -227,12 +227,28 @@ export interface NodePropertiesFormProps {
onPropsChange: (next: Record<string, unknown>) => void;
}
const sectionHeader: React.CSSProperties = {
fontSize: "0.625rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#475569",
borderBottom: "1px solid #1e293b",
paddingBottom: "0.25rem",
marginBottom: "0.75rem",
};
export function NodePropertiesForm({
schema,
currentProps,
onPropsChange,
}: NodePropertiesFormProps) {
const grouped = groupFields(schema);
// Strip `enabled` from schema-rendered groups — we always render it ourselves.
const filteredSchema = Object.fromEntries(
Object.entries(schema).filter(([k]) => k !== "enabled"),
) as ConfigSchema<Record<string, unknown>>;
const grouped = groupFields(filteredSchema);
const update = (key: string, value: unknown) => {
onPropsChange({ ...currentProps, [key]: value });
@ -240,22 +256,21 @@ export function NodePropertiesForm({
return (
<div>
{/* ── Pinned "General" section (always present) ───────────────────── */}
<section style={{ marginBottom: "1.25rem" }}>
<div style={sectionHeader}>General</div>
{renderField(
"enabled",
{ type: "boolean", label: "Enabled" },
currentProps["enabled"] ?? true,
(v) => update("enabled", v),
)}
</section>
{/* ── Widget-specific schema groups ────────────────────────────────── */}
{Object.entries(grouped).map(([group, fields]) => (
<section key={group} style={{ marginBottom: "1.25rem" }}>
<div
style={{
fontSize: "0.625rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#475569",
borderBottom: "1px solid #1e293b",
paddingBottom: "0.25rem",
marginBottom: "0.75rem",
}}
>
{group}
</div>
<div style={sectionHeader}>{group}</div>
{fields.map(([key, cfg]) =>
renderField(key, cfg, currentProps[key], (v) => update(key, v)),
)}

View File

@ -3,22 +3,45 @@ import type { WidgetPlugin } from "@/widgets/types";
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
import { ImageWidget } from "@/widgets/widgets/sample/ImageWidget";
import type { ImageWidgetProps } from "@/widgets/widgets/sample/ImageWidget";
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";
/**
* Lazy-loaded edit component for text-block.
* Code-split: never loaded in view/preview mode, only when the editor is active.
*/
// ─── Lazy edit components (never loaded in view mode) ─────────────────────────
const TextBlockWidgetEdit = React.lazy(() =>
import("@/widgets/widgets/sample/TextBlockWidgetEdit").then((m) => ({
default: m.TextBlockWidgetEdit,
})),
);
const ImageWidgetEdit = React.lazy(() =>
import("@/widgets/widgets/sample/ImageWidgetEdit").then((m) => ({
default: m.ImageWidgetEdit,
})),
);
// ─── Shared option lists ──────────────────────────────────────────────────────
const FONT_FAMILY_OPTIONS = [
{ value: "", label: "Inherit" },
{ value: "system-ui, sans-serif", label: "System UI" },
{ value: "'Inter', sans-serif", label: "Inter" },
{ value: "Georgia, serif", label: "Georgia" },
{ value: "'Courier New', monospace", label: "Monospace" },
];
const IMAGE_FIT_OPTIONS = [
{ value: "cover", label: "Cover" },
{ value: "contain", label: "Contain" },
{ value: "fill", label: "Fill" },
{ value: "none", label: "None" },
];
/**
* Registers baseline widgets and layout nodes.
* Mirrors `registerWidgets()` as a plugin (widgets-api §13).
@ -39,12 +62,17 @@ export const coreWidgetsPlugin: WidgetPlugin = {
category: "layout",
description: "Vertical flex column; children are flex-row nodes.",
tags: ["layout", "core"],
capabilities: ["nested-layout"],
defaultProps: { gap: 16 },
configSchema: {
title: { type: "text", label: "Title", group: "Display", description: "Section heading" },
showTitle: { type: "boolean", label: "Show title", group: "Display" },
gap: { type: "number", label: "Row gap", group: "Layout", default: 16, min: 0, max: 128 },
enabled: { type: "boolean", label: "Enabled", group: "Display" },
// Display group
title: { type: "text", label: "Title", group: "Display", description: "Section heading" },
showTitle: { type: "boolean", label: "Show title", group: "Display" },
// Layout group
gap: { type: "number", label: "Row gap", group: "Layout", default: 16, min: 0, max: 128 },
// Typography group — cascades to nested TEXT widgets via CSS custom properties
fontFamily: { type: "select", label: "Font family", group: "Typography", options: FONT_FAMILY_OPTIONS },
fontSize: { type: "number", label: "Font size (px)", group: "Typography", min: 10, max: 64 },
},
},
constraints: {
@ -63,6 +91,7 @@ export const coreWidgetsPlugin: WidgetPlugin = {
category: "layout",
description: "CSS grid row; columns controlled by columns prop.",
tags: ["layout", "core"],
capabilities: ["nested-layout"],
configSchema: {
columns: { type: "number", label: "Columns", group: "Layout", default: 1, min: 1, max: 12 },
gap: { type: "number", label: "Gap", group: "Layout", default: 16, min: 0, max: 128 },
@ -86,13 +115,41 @@ export const coreWidgetsPlugin: WidgetPlugin = {
category: "display",
description: "Heading + body (scaffold)",
tags: ["text", "core"],
capabilities: ["text"],
defaultProps: {
title: "Hello",
body: "Widget registry + plugin bootstrap.",
},
configSchema: {
title: { type: "text", label: "Title", group: "Content" },
body: { type: "markdown", label: "Body", group: "Content" },
title: { type: "text", label: "Title", group: "Content" },
body: { type: "markdown", label: "Body", group: "Content" },
fontFamily: { type: "select", label: "Font family", group: "Typography", options: FONT_FAMILY_OPTIONS },
fontSize: { type: "number", label: "Font size (px)", group: "Typography", min: 10, max: 64 },
},
},
constraints: { canHaveChildren: false, draggable: true, deletable: true },
});
api.registerWidget<ImageWidgetProps>({
component: ImageWidget,
editComponent: ImageWidgetEdit,
metadata: {
id: "image",
name: "Image",
category: "display",
description: "Full-width image with optional placeholder.",
tags: ["image", "media", "core"],
capabilities: ["image"],
defaultProps: {
url: "",
fit: "cover",
height: 200,
},
configSchema: {
url: { type: "text", label: "URL", group: "Content", description: "Leave empty for random placeholder" },
alt: { type: "text", label: "Alt text", group: "Content" },
fit: { type: "select", label: "Fit", group: "Layout", options: IMAGE_FIT_OPTIONS },
height: { type: "number", label: "Height (px)", group: "Layout", min: 40, max: 800 },
},
},
constraints: { canHaveChildren: false, draggable: true, deletable: true },

View File

@ -64,3 +64,12 @@ export { LocalStoragePagePersistence } from "@/store/persistence/LocalStoragePag
// ─── Sample widgets ──────────────────────────────────────────────────────────
export { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
export type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
export { ImageWidget } from "@/widgets/widgets/sample/ImageWidget";
export type { ImageWidgetProps, ImageFit } from "@/widgets/widgets/sample/ImageWidget";
// ─── Introspection utilities ──────────────────────────────────────────────────
export {
findNodesByCapability,
treeCapabilityMap,
nodeHasCapability,
} from "@/widgets/utils/introspection";

View File

@ -6,16 +6,24 @@ export interface FlexNodeProps extends BaseWidgetProps {
showTitle?: boolean;
collapsible?: boolean;
enabled?: boolean;
/**
* Font family to cascade to all descendant TEXT widgets via the
* `--widget-font-family` CSS custom property.
*/
fontFamily?: string;
/**
* Base font size (px) to cascade via `--widget-font-size`.
* Individual text-block nodes can override this with their own `fontSize` prop.
*/
fontSize?: number;
}
/**
* 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).
* Typography cascade: setting `fontFamily` / `fontSize` here propagates to all
* nested TEXT-capability widgets via CSS custom properties, so you can control
* the look of an entire section in one go.
*/
export function FlexNode({
layout,
@ -23,15 +31,25 @@ export function FlexNode({
title,
showTitle,
enabled = true,
fontFamily,
fontSize,
children,
}: FlexNodeProps) {
if (!enabled) return null;
// props.gap takes priority over layout.gap (props are edited at runtime; layout is the seed)
const resolvedGap = gap ?? layout?.gap ?? 16;
// Build CSS custom property overrides for typography cascade.
// We use `as React.CSSProperties` because TS doesn't model custom properties.
const cssVars: Record<string, string | number> = {};
if (fontFamily) cssVars["--widget-font-family"] = fontFamily;
if (fontSize != null) cssVars["--widget-font-size"] = `${fontSize}px`;
const inner = (
<div className="relative min-w-0 flex flex-col" style={{ gap: resolvedGap }}>
<div
className="relative min-w-0 flex flex-col"
style={{ gap: resolvedGap, ...cssVars } as React.CSSProperties}
>
{children}
</div>
);

View File

@ -80,7 +80,11 @@ export type WidgetCapability =
| "translatable"
| "data-bound"
| "interactive"
| "exportable";
| "exportable"
/** Widget primarily displays or edits textual content. */
| "text"
/** Widget primarily displays or edits image/media content. */
| "image";
/** Property editor field — full union lives in widgets-api §3 */
export interface ConfigField {

View File

@ -3,12 +3,31 @@ import type { BaseWidgetProps } from "@/widgets/types";
export interface TextBlockWidgetProps extends BaseWidgetProps {
title?: string;
body?: string;
/**
* Explicit font family for this widget.
* Falls back to the `--widget-font-family` CSS variable set by a parent FlexNode,
* then to the browser default.
*/
fontFamily?: string;
/**
* Explicit font size (px) for this widget.
* Falls back to the `--widget-font-size` CSS variable, then browser default.
*/
fontSize?: number;
}
export function TextBlockWidget(props: TextBlockWidgetProps) {
const { title, body } = props;
const { title, body, fontFamily, fontSize } = props;
// Consume the CSS custom properties set by a parent FlexNode for typography
// cascade. Individual props override them.
const wrapStyle: React.CSSProperties = {
fontFamily: fontFamily ?? "var(--widget-font-family)",
...(fontSize != null ? { fontSize } : { fontSize: "var(--widget-font-size)" }),
};
return (
<div className="panel widget-text-block">
<div className="panel widget-text-block" style={wrapStyle}>
<h3>{title ?? "Text block"}</h3>
{body ? <p>{body}</p> : <p className="muted">Empty body</p>}
</div>

View File

@ -11,6 +11,8 @@ import type { TextBlockWidgetProps } from "./TextBlockWidget";
export function TextBlockWidgetEdit({
title,
body,
fontFamily,
fontSize,
onPropsChange,
}: TextBlockWidgetProps) {
const titleRef = useRef<HTMLHeadingElement>(null);
@ -48,6 +50,8 @@ export function TextBlockWidgetEdit({
outlineOffset: 2,
transition: "outline-color 0.15s ease",
cursor: "default",
fontFamily: fontFamily ?? "var(--widget-font-family)",
...(fontSize != null ? { fontSize } : { fontSize: "var(--widget-font-size)" }),
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}