Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.