From f97ec4fdabd81a967c3ce6c1a58c662124203e5e Mon Sep 17 00:00:00 2001 From: DCCA Date: Sat, 18 Jul 2026 17:57:44 -0300 Subject: [PATCH] feat: allow nesting primitives inside containers in the builder A composite could only ever be a flat stack. builder-node-tree.tsx said 'display only - nested operations not supported' and meant it: handleAddPrimitive always appended to the top-level array, and move, delete and selection all searched only that array, so a nested node could not be selected, reordered, deleted, or have its properties edited. A designer could add a container, watch it render as an empty box, and never put anything in it - which rules out most real components, since a card is a container with a heading and a body inside it. The limitation was only ever in the builder UI. PrimitiveNode already has children, ContainerRenderer already renders them, setNestedValue already walks arbitrary paths, and getBindableTargets already recursed into children - so this adds no data model change, no migration, and no new persistence concern. Tree manipulation moves into pure immutable helpers in lib/composite/tree.ts, which is what makes the recursive cases testable without rendering, and collapses each builder handler to one call. Two decisions worth naming: - A new primitive lands inside the selected container when there is room, otherwise at the top level. The palette states which, because the alternative is the user clicking and finding out. - MAX_NEST_DEPTH matches the renderer's MAX_DEPTH of 2, and a test asserts the two agree. A node the user can build but the renderer refuses to draw is worse than one they cannot build. Reordering stays within a node's own sibling list; dragging across parents is a different interaction and is not attempted here. --- __tests__/composite/tree.test.ts | 163 ++++++++++++++++++ components/sandbox/builder-node-tree.tsx | 85 +++++---- components/sandbox/builder-palette.tsx | 12 +- components/sandbox/component-builder.tsx | 58 ++++--- .../2026-07-18-nested-containers-design.md | 80 +++++++++ e2e/nested-containers.spec.ts | 91 ++++++++++ lib/composite/tree.ts | 95 ++++++++++ 7 files changed, 514 insertions(+), 70 deletions(-) create mode 100644 __tests__/composite/tree.test.ts create mode 100644 docs/superpowers/specs/2026-07-18-nested-containers-design.md create mode 100644 e2e/nested-containers.spec.ts create mode 100644 lib/composite/tree.ts diff --git a/__tests__/composite/tree.test.ts b/__tests__/composite/tree.test.ts new file mode 100644 index 0000000..eb5705e --- /dev/null +++ b/__tests__/composite/tree.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { + findNode, + insertNode, + removeNode, + moveNode, + updateNodeProps, + depthOf, + MAX_NEST_DEPTH, +} from "@/lib/composite/tree"; +import type { PrimitiveNode } from "@/lib/composite/types"; + +const node = (id: string, type: PrimitiveNode["type"] = "heading"): PrimitiveNode => ({ + id, + type, + props: { text: id }, + ...(type === "container" ? { children: [] } : {}), +}); + +/** a, then box[b, inner[c]] */ +function tree(): PrimitiveNode[] { + return [ + node("a"), + { + ...node("box", "container"), + children: [node("b"), { ...node("inner", "container"), children: [node("c")] }], + }, + ]; +} + +describe("findNode", () => { + it("finds a top-level node", () => { + expect(findNode(tree(), "a")?.id).toBe("a"); + }); + + it("finds a deeply nested node", () => { + expect(findNode(tree(), "c")?.id).toBe("c"); + }); + + it("returns null when the id is absent", () => { + expect(findNode(tree(), "nope")).toBeNull(); + }); +}); + +describe("insertNode", () => { + it("appends to the top level when no parent is given", () => { + const next = insertNode(tree(), null, node("new")); + + expect(next.map((n) => n.id)).toEqual(["a", "box", "new"]); + }); + + it("appends into the named container", () => { + const next = insertNode(tree(), "box", node("new")); + + expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["b", "inner", "new"]); + }); + + it("appends into a nested container", () => { + const next = insertNode(tree(), "inner", node("new")); + + expect(findNode(next, "inner")?.children?.map((n) => n.id)).toEqual(["c", "new"]); + }); + + it("does not mutate the input", () => { + const original = tree(); + insertNode(original, "box", node("new")); + + expect(findNode(original, "box")?.children).toHaveLength(2); + }); + + it("refuses to nest into a non-container", () => { + const next = insertNode(tree(), "a", node("new")); + + expect(findNode(next, "new")).toBeNull(); + }); +}); + +describe("removeNode", () => { + it("removes a top-level node", () => { + expect(removeNode(tree(), "a").map((n) => n.id)).toEqual(["box"]); + }); + + it("removes a nested node", () => { + const next = removeNode(tree(), "b"); + + expect(findNode(next, "b")).toBeNull(); + expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["inner"]); + }); + + it("removes a container along with its children", () => { + const next = removeNode(tree(), "box"); + + expect(findNode(next, "c")).toBeNull(); + expect(next.map((n) => n.id)).toEqual(["a"]); + }); + + it("does not mutate the input", () => { + const original = tree(); + removeNode(original, "b"); + + expect(findNode(original, "b")).not.toBeNull(); + }); +}); + +describe("moveNode", () => { + it("reorders within the top level", () => { + expect(moveNode(tree(), "box", "up").map((n) => n.id)).toEqual(["box", "a"]); + }); + + it("reorders within a container, not across parents", () => { + const next = moveNode(tree(), "inner", "up"); + + expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["inner", "b"]); + expect(next.map((n) => n.id)).toEqual(["a", "box"]); + }); + + it("is a no-op at the start of its sibling list", () => { + expect(moveNode(tree(), "a", "up").map((n) => n.id)).toEqual(["a", "box"]); + }); + + it("is a no-op at the end of its sibling list", () => { + const next = moveNode(tree(), "inner", "down"); + + expect(findNode(next, "box")?.children?.map((n) => n.id)).toEqual(["b", "inner"]); + }); +}); + +describe("updateNodeProps", () => { + it("updates a nested node's props", () => { + const next = updateNodeProps(tree(), "c", { text: "changed" }); + + expect(findNode(next, "c")?.props.text).toBe("changed"); + }); + + it("leaves siblings alone and does not mutate the input", () => { + const original = tree(); + const next = updateNodeProps(original, "b", { text: "changed" }); + + expect(findNode(next, "c")?.props.text).toBe("c"); + expect(findNode(original, "b")?.props.text).toBe("b"); + }); +}); + +describe("depthOf", () => { + it("reports 0 for a top-level node", () => { + expect(depthOf(tree(), "box")).toBe(0); + }); + + it("reports nesting depth", () => { + expect(depthOf(tree(), "b")).toBe(1); + expect(depthOf(tree(), "c")).toBe(2); + }); + + it("returns -1 for an unknown id", () => { + expect(depthOf(tree(), "nope")).toBe(-1); + }); + + it("agrees with the renderer's ceiling", () => { + // The renderer drops anything deeper than MAX_DEPTH, so the builder must not let + // the user create nodes it would refuse to draw. + expect(MAX_NEST_DEPTH).toBe(2); + }); +}); diff --git a/components/sandbox/builder-node-tree.tsx b/components/sandbox/builder-node-tree.tsx index 9a58265..bd83f9a 100644 --- a/components/sandbox/builder-node-tree.tsx +++ b/components/sandbox/builder-node-tree.tsx @@ -76,7 +76,6 @@ function NodeRow({ ? String(node.props.text ?? node.props.label ?? "") : ""; const isSelected = selectedNodeId === node.id; - const isNested = depth > 0; return ( <> @@ -96,50 +95,48 @@ function NodeRow({ {preview.length > 20 ? preview.slice(0, 20) + "..." : preview} )} - {!isNested && ( -
- - - -
- )} +
+ + + +
- {/* Render children for containers (display only — nested operations not supported) */} + {/* Children are fully operable: select, reorder within their parent, delete */} {node.type === "container" && node.children?.map((child, ci) => ( void; + /** Container id new primitives will go into, or null for the top level. */ + insertingInto?: string | null; }; -export function BuilderPalette({ onAdd }: BuilderPaletteProps) { +export function BuilderPalette({ onAdd, insertingInto = null }: BuilderPaletteProps) { return (
-
Primitives
+
+ Primitives + {/* Where a click will put the node - otherwise nesting is invisible guesswork */} + + {insertingInto ? "into selected container" : "at top level"} + +
{primitives.map(({ type, label, icon }) => (