diff --git a/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx index f7151275..df211dab 100644 --- a/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx +++ b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx @@ -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 = { + 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 ( +
+ + Tree introspection + + {interesting.map(([, nodes]) => nodes.length).reduce((a, b) => a + b, 0)}{" "} + annotated node(s) + + + +
+ {interesting.map(([cap, nodes]) => ( +
+
+ {cap} ({nodes.length}) +
+
+ {nodes.map((n) => ( + + ))} +
+
+ ))} +
+ +

+ Click a node ID to select it. Set Typography on the root{" "} + flex node to cascade font to all TEXT widgets at once. +

+
+ ); +} + // ─── Inner page (needs EditorContext in scope) ──────────────────────────────── function FlexNodeDemoInner() { @@ -83,12 +194,19 @@ function FlexNodeDemoInner() { return (
{/* ── Toolbar ── */} -
+

Flex node scaffold

- Node model §14 — flex + flex-row via{" "} - EditableNodeRenderer. Toggle edit to drag-reorder. + Node model §14 — flex + flex-row +{" "} + image widgets. Toggle edit to drag-reorder.

@@ -129,12 +247,17 @@ function FlexNodeDemoInner() {
+ {/* ── Tree introspection ── */} + {page && } + {/* ── Raw tree inspector ── */} -
+
Node tree (raw) -
+        
           {JSON.stringify(page?.root ?? null, null, 2)}
         
diff --git a/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx b/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx index 852af80e..5a7f9197 100644 --- a/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx +++ b/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx @@ -227,12 +227,28 @@ export interface NodePropertiesFormProps { onPropsChange: (next: Record) => 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>; + + const grouped = groupFields(filteredSchema); const update = (key: string, value: unknown) => { onPropsChange({ ...currentProps, [key]: value }); @@ -240,22 +256,21 @@ export function NodePropertiesForm({ return (
+ {/* ── Pinned "General" section (always present) ───────────────────── */} +
+
General
+ {renderField( + "enabled", + { type: "boolean", label: "Enabled" }, + currentProps["enabled"] ?? true, + (v) => update("enabled", v), + )} +
+ + {/* ── Widget-specific schema groups ────────────────────────────────── */} {Object.entries(grouped).map(([group, fields]) => (
-
- {group} -
+
{group}
{fields.map(([key, cfg]) => renderField(key, cfg, currentProps[key], (v) => update(key, v)), )} diff --git a/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx index 116f34cc..eb7b2f68 100644 --- a/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx +++ b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx @@ -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({ + 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 }, diff --git a/packages/ui-next/src/widgets/index.ts b/packages/ui-next/src/widgets/index.ts index 2dc3dba4..1eb0d82e 100644 --- a/packages/ui-next/src/widgets/index.ts +++ b/packages/ui-next/src/widgets/index.ts @@ -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"; diff --git a/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx b/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx index 33211041..2d13518b 100644 --- a/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx +++ b/packages/ui-next/src/widgets/nodes/flex/FlexNode.tsx @@ -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 = {}; + if (fontFamily) cssVars["--widget-font-family"] = fontFamily; + if (fontSize != null) cssVars["--widget-font-size"] = `${fontSize}px`; + const inner = ( -
+
{children}
); diff --git a/packages/ui-next/src/widgets/types.ts b/packages/ui-next/src/widgets/types.ts index 35dbf808..b994c658 100644 --- a/packages/ui-next/src/widgets/types.ts +++ b/packages/ui-next/src/widgets/types.ts @@ -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 { diff --git a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx index ff24df89..3f2dffa2 100644 --- a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx +++ b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx @@ -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 ( -
+

{title ?? "Text block"}

{body ?

{body}

:

Empty body

}
diff --git a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidgetEdit.tsx b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidgetEdit.tsx index 48b8962c..4567ebd6 100644 --- a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidgetEdit.tsx +++ b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidgetEdit.tsx @@ -11,6 +11,8 @@ import type { TextBlockWidgetProps } from "./TextBlockWidget"; export function TextBlockWidgetEdit({ title, body, + fontFamily, + fontSize, onPropsChange, }: TextBlockWidgetProps) { const titleRef = useRef(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)}