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"}
+
+