Skip to content

feat: allow nesting primitives inside containers in the builder - #28

Merged
DCCA merged 1 commit into
masterfrom
feat/nested-containers
Jul 18, 2026
Merged

feat: allow nesting primitives inside containers in the builder#28
DCCA merged 1 commit into
masterfrom
feat/nested-containers

Conversation

@DCCA

@DCCA DCCA commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Phase C of three on making Sandy usable as a daily design-system tool, after #26 (correctness) and #27 (the library).

The ceiling

A composite could only ever be a flat stack of primitives. builder-node-tree.tsx carried the comment "display only - nested operations not supported", and it was accurate:

  • handleAddPrimitive always appended to the top-level nodes array, so nothing could go inside a container
  • move, delete and the selection lookup all searched only that array, so a nested node could not be selected, reordered, deleted, or have its properties edited
  • the tree rendered children but hid every action button on them

A designer could add a container, watch it render as an empty box, and never put anything in it. That rules out most real components - a card is a container with a heading and a body inside it.

What was already there

The limitation was only in the builder UI. Everything underneath already supported nesting:

  • PrimitiveNode has children; ContainerRenderer renders them
  • applyBindings / setNestedValue walk arbitrary paths, so a binding can target [0, "children", 1, "props", "text"]
  • getBindableTargets already recursed into children, so the binding picker would have offered nested targets the moment any existed

So this adds no data model change, no migration, and no new persistence concern.

Design

Tree manipulation moves into pure immutable helpers in lib/composite/tree.ts - findNode, insertNode, removeNode, moveNode, updateNodeProps, depthOf. Keeping them pure makes the recursive cases testable without rendering anything, and collapses each builder handler to a single call.

Two decisions worth naming:

Where a new primitive lands. Inside the selected container when there is room, otherwise at the top level. The palette states which - "into selected container" / "at top level" - because the alternative is the user clicking and finding out. Selecting a non-container retargets it back.

How deep it goes. MAX_NEST_DEPTH is 2, matching the renderer's MAX_DEPTH. A node the user can build but the renderer refuses to draw is worse than one they cannot build, so only containers shallower than the ceiling are offered as a target. A test asserts the two constants agree, so moving one without the other fails.

moveNode reorders within a node's own sibling list only. Dragging across parents is a different interaction and is not attempted here.

Verification

  • 299 unit tests (up from 277), 23 e2e (up from 19), typecheck, lint, format, build - all green
  • 22 unit cases cover each helper against a two-level fixture: inserting at each level, refusing to nest into a non-container, removing a container with its children, reordering without escaping the parent, no-ops at boundaries, and immutability of the input
  • E2E asserts the persisted definition after saving a nested component, so a flattened stack fails even if the UI looks right

Spec: docs/superpowers/specs/2026-07-18-nested-containers-design.md

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.
@DCCA
DCCA merged commit f3b840b into master Jul 18, 2026
2 checks passed
@DCCA
DCCA deleted the feat/nested-containers branch July 18, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant