Skip to content

feat: group containers, a producer's own grouping of nodes - #91

Merged
endrix merged 5 commits into
mainfrom
feat/group-scopes
Oct 24, 2026
Merged

endrix merged 5 commits into
mainfrom
feat/group-scopes

Conversation

@endrix

@endrix endrix commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

A scope of kind group in a graph document, subgraphs: [{ id, kind: "group", label?, parent?, nodes? }], is drawn as a labelled frame around the nodes that declare it or that it lists; a group whose parent is a group nests inside it. The members keep their ids, ports and edges, and the frame lays out on its own, left to right (ELK layered, SEPARATE_CHILDREN), so grouping changes where members sit and never how they read.

The platform learns nothing from the grouping. What a group is, a layer, a domain, a stage, is the producer's business and shows only in its label. A scope of any other kind still creates nothing, so the root scope every producer emits does not become a frame around the whole graph. Two groups that name each other as parent both land at the root instead of vanishing inside each other; a node two groups list is claimed by the first.

Changes: NODE_GROUP / GROUP_LABEL in shared; createGroupNode and the grouping pass in the GModel transform; the layout configurator, layout engine (auto-sized container, ports kept in order) and diagram configuration (type mapping, hint: movable, not deletable); GroupNode and GroupNodeView on the client with their CSS, registered in the stock views module (parity baseline updated by one entry).

Tests: seven server cases in group-scopes.test.ts (members moved, label fallback, nesting, edges kept, other kinds ignored, parent cycle, double claim), a client source test, the parity test. Typecheck, every suite and the neutrality gates pass.

Second platform change for the NDF document diagram (ide/ndf-ide/ndf-ide-plan.md, N1); the CLI's --group refinement|domain emits these scopes.

Second commit: nested regions, edges cut at frames, moves kept inside

Modelled on mlir-viewer's nested containers:

  • Each group lays out on its own (SEPARATE_CHILDREN), drawn as a tinted body with a header band carrying the label.
  • An edge that leaves or enters a group is cut at a boundary port on the frame (port:group-boundary), one piece per level. Edges reaching the same member through the same frame share one port and one inner piece. The stretch between frames keeps the edge's id, arguments and caption; a shared piece lists its edges (wf:edgesOf) and is not deleted on its own. Every piece stays at the root, so saved and rerouted routes keep absolute coordinates.
  • ContainedMovementRestrictor (GLSP IMovementRestrictor) refuses a drag that would take a member outside its group, or under the group's header. Members of other containers (an if or loop branch) move as freely as before.
  • Fixes: reopening a saved layout placed every member of a container outside it (absolute positions written back as relative); a reopened group lost its size and boundary ports (now rebuilt from saved positions and routes); the post-move rerouter treated a group as an obstacle.
  • Tooling: scripts/render-svg/render.mjs renders a graph document to SVG through the server layout and the real client in headless Chrome, with a choice of layout.

Tests: group-edges.test.ts (8, incl. a real-ELK layout check), group-geometry.test.ts (3, reopen round trip), contained-movement-restrictor.test.ts (6), updated group-scopes and group-node-view tests, parity baseline +2 bindings. Typecheck, neutrality and all suites pass.

Review fixes (45d5f9a)

  • Only a group holds a dragged member. An if/loop branch never grows and has only 15–20 px of padding, so holding its members trapped them.
  • Moving a group now also saves the new absolute positions of its members, which would otherwise reopen shifted back by the drag (group-move.test.ts).
  • Comments: groups lay out left to right; platform comments no longer name a product.

https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8

A scope of kind `group` in a document (`subgraphs: [{ id, kind: "group",
label?, parent?, nodes? }]`) is drawn as a labelled frame around the
nodes that declare it or that it lists, nested under its parent when
that is a group too. The members, their ports and the edges between
them are untouched, and the frame is laid out in the graph's own
direction, so a group changes where its members sit and never how they
read. The platform learns nothing from the grouping; what a group is (a
layer, a domain, a stage) is the producer's business and shows only in
its label. A scope of any other kind still creates nothing: the root
scope every producer emits must not become a frame around the graph.

Claude-Session: https://claude.ai/code/session_012yAjXyWw6JyWsXvHG3cG9c
A group is now drawn and laid out the way mlir-viewer draws a nested
region. Each group lays out on its own (`SEPARATE_CHILDREN`), members
first, then the group as one block among its siblings, so the frame stays
tight around its members; the client draws a tinted body, a header band
carrying the label, and a border over both.

ELK cannot route an edge between members of different groups in one
separate layout, so an edge that leaves or enters a group is cut where it
crosses the frame, at a boundary port (`port:group-boundary`): one piece
per level, as mlir-viewer cuts an edge at each container it crosses
(`group-edges.ts`). Edges reaching the same member through the same frame
share the way in: one port per group, direction and inner endpoint, one
piece inside. The stretch between frames keeps the edge's id, arguments
and caption; a piece several edges share lists them (`wf:edgesOf`), keeps
a role only when they all have it, and is not deleted on its own. Only the
last piece draws an arrowhead. Every piece stays at the root of the model,
so routes keep their absolute coordinates and saving, restoring and
rerouting them is unchanged; ELK places each piece in its group from its
endpoints, and the engine takes a boundary port's position from ELK.

A dragged node stays inside the node holding it: `ContainedMovementRestrictor`,
bound as GLSP's `IMovementRestrictor`, refuses a position outside the
parent (below a group's header band), and a drop there is not committed.

Fixed on the way:
- Reopening a saved layout wrote each saved absolute position back as a
  position relative to the node's parent, so every member of a container
  landed outside it; the loader now subtracts the parent's origin. A
  reopened group also lost its size and its boundary ports, which only the
  layout run produces; `restoreGroupGeometry` rebuilds them from the saved
  positions and routes.
- The rerouter that runs after a move treated a group as an obstacle, so
  an edge inside a group was routed around it.

Tooling: `scripts/render-svg/render.mjs` renders a graph document to SVG
through the server's layout and the real client in headless Chrome, as
"Export as SVG" writes it, with a choice of layout (`--all-layouts` for
the three side by side), ELK options, node families and stylesheets.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
…tead

Clicking a node inside a group grew the node and the group. The group was
declared resizable in the shape type hints, and GLSP's change-bounds tool
looks for the nearest resizable ancestor of whatever is pressed: the click
became the start of a resize of the group, which measures the group and
every member in the hidden model and applies the measurements. Sprotty's
`ComputedBoundsApplicator` writes `size` directly, past the `bounds`
setter `WorkflowNode` overrides, and a node measures as its box plus what
is drawn past it, so the member grew. Reproduced in headless Chrome with
real mouse events: 217x74 became 251x72, the group 271 became 280.

- Groups are not resizable by hand (`resizable: false`); a frame grows
  when its members need it.
- `NodeSizePreservingBoundsApplicator`, bound as the
  `ComputedBoundsApplicator`: a measurement moves a workflow node but
  never resizes it, so no other measuring path can grow one either.
- `WorkflowChangeBoundsOperationHandler` keeps a moved node's own size
  unless its type is declared resizable: GLSP's handler copied whatever
  size the client sent, which would have made a grown node permanent.
- A member dragged right or down takes the frame with it:
  `growGroupsToFit` grows the group, and any group around it, after the
  move, the right-hand boundary ports follow, and their edges are routed
  again with the moved node's. The restrictor leaves a group's area open
  to the right and downwards and still refuses the left edge and the
  header; other containers stay closed on every side.

Tests: group-move (4), node-size-preserving-bounds-applicator (2), the
restrictor's updated cases, parity baseline +1 binding.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
The client draws a family's colour and icon from its own copy of the
families (`clientBehavior.nodeFamilies`), as the webview gets them from
the profile; `--families` reached only the server, which classifies the
nodes, so a render showed no family icons.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
…bers

Review of #91:

- `ContainedMovementRestrictor` held a node inside any node holding it.
  A control structure's branch (an if, a loop) holds its members the same
  way but never grows, and its layout leaves 15-20 px of padding, so a
  lone member of one could barely move and a drop further was reverted.
  Only a group now holds its members; the others move as freely as on
  main.
- Dragging a group saved only the group's absolute position. Its members
  kept their old absolute positions in the layout file and reopened
  shifted back by the drag, out of the frame (unless a later save of all
  positions happened to land last). A move now saves every named node
  inside the moved one too (`group-move.test.ts`).
- Comments: groups lay out left to right (the comment said "the graph's
  direction"), and the platform's comments no longer name a product.

Claude-Session: https://claude.ai/code/session_01E8WfqKknDNN6xpNVcJL5K8
@endrix
endrix merged commit 0ebf0b4 into main Oct 24, 2026
1 check passed
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