feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* The declarative node tree the layout engine works on.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The model never writes coordinates. It declares nesting and direction; the engine
|
|
|
|
|
|
* computes every x/y/width/height. The tree is not persisted anywhere — it is
|
|
|
|
|
|
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
|
|
|
|
|
|
* stays the single source of truth and a user's manual edits are an input, never
|
|
|
|
|
|
* something to be reconciled against a second copy of the state.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import type { Direction } from "./markers"
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
import type { Role } from "./theme"
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
|
|
|
|
|
|
export type { Direction } from "./markers"
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
export type { Role } from "./theme"
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Which cell of a swimlane pool a node sits in.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
|
|
|
|
|
|
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface PoolCell {
|
|
|
|
|
|
lane: number
|
|
|
|
|
|
col: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
|
|
|
|
|
* pin to either edge, centre (the default), or stretch to fill the axis.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type Align = "start" | "center" | "end" | "stretch"
|
|
|
|
|
|
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Presentation a node may override, beyond what its `role` decides.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
|
|
|
|
|
|
* properties that failed it and why. Most fields here are one style key with one value; a few
|
|
|
|
|
|
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
|
|
|
|
|
|
* because the field still names one visual decision. What is not allowed is a field whose
|
|
|
|
|
|
* values collapse onto fewer pictures than it promises.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
|
|
|
|
|
|
* thing to carry and the node type does not grow a field per CSS property.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `role` remains the primary way to say what a node IS; this is for the cases where the
|
|
|
|
|
|
* model needs to override one aspect of how it looks.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface TextStyle {
|
|
|
|
|
|
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
|
|
|
|
|
|
bold?: boolean
|
|
|
|
|
|
italic?: boolean
|
|
|
|
|
|
underline?: boolean
|
|
|
|
|
|
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
|
|
|
|
|
|
strike?: boolean
|
|
|
|
|
|
/** Type size in px. */
|
|
|
|
|
|
size?: number
|
|
|
|
|
|
/** Horizontal text alignment inside the shape. */
|
|
|
|
|
|
align?: "left" | "center" | "right"
|
|
|
|
|
|
/** Vertical text alignment inside the shape. */
|
|
|
|
|
|
valign?: "top" | "middle" | "bottom"
|
|
|
|
|
|
/** Keep the label on one line instead of wrapping it. */
|
|
|
|
|
|
nowrap?: boolean
|
|
|
|
|
|
/** Border thickness in px. */
|
|
|
|
|
|
borderWidth?: number
|
|
|
|
|
|
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
|
|
|
|
|
|
borderStyle?: "solid" | "dashed" | "dotted"
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Corner radius in px.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
|
|
|
|
|
|
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
|
|
|
|
|
|
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
|
|
|
|
|
|
* mxShape.js:1172-1189).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
|
|
|
|
|
|
* rounded rectangles already, and changing how round they are does not change what they
|
|
|
|
|
|
* are, so a radius class is allowed to win.
|
|
|
|
|
|
*/
|
|
|
|
|
|
radius?: number
|
|
|
|
|
|
/** No border at all — a plain colour field. */
|
|
|
|
|
|
borderless?: boolean
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Drop shadow, as a rung: 1–4 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A rung rather than raw offsets because draw.io takes five separate numbers
|
|
|
|
|
|
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
|
|
|
|
|
|
* and letting a caller set them individually is exactly the magic-number freedom this
|
|
|
|
|
|
* vocabulary exists to remove.
|
|
|
|
|
|
*/
|
|
|
|
|
|
shadow?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* How a container spreads its children along its own stacking axis — CSS's
|
|
|
|
|
|
* justify-content, and Yoga's six values.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Until this existed the policy was hard-coded and differed per axis: a row padded its
|
|
|
|
|
|
* gaps and centred the result, a column packed to the top and left every spare pixel in
|
|
|
|
|
|
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
|
|
|
|
|
|
* nothing the model could declare would move it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type Justify =
|
|
|
|
|
|
| "start"
|
|
|
|
|
|
| "center"
|
|
|
|
|
|
| "end"
|
|
|
|
|
|
| "between"
|
|
|
|
|
|
| "around"
|
|
|
|
|
|
| "evenly"
|
|
|
|
|
|
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/**
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
* What a box IS, drawn as its conventional outline.
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
*
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
|
|
|
|
|
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
|
|
|
|
|
* token passes through verbatim and degrades to a rectangle if the editor does not
|
|
|
|
|
|
* know it. See shapes.ts.
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
*/
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
export type BoxShape = string
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
|
|
|
|
|
|
export interface IconNode {
|
|
|
|
|
|
kind: "icon"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
|
|
|
|
|
|
name: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
/** Glyph size in px. Defaults to the diagram's icon size. */
|
|
|
|
|
|
size?: number
|
|
|
|
|
|
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
/** User froze this node's position — the engine must not move it. */
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
|
|
|
|
|
|
rect?: Rect
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/** Position within a `pool` parent. Ignored elsewhere. */
|
|
|
|
|
|
cell?: PoolCell
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A plain labelled rectangle, for things the catalog has no icon for. */
|
|
|
|
|
|
export interface BoxNode {
|
|
|
|
|
|
kind: "box"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
w?: number
|
|
|
|
|
|
h?: number
|
|
|
|
|
|
fill?: string
|
|
|
|
|
|
stroke?: string
|
|
|
|
|
|
bold?: boolean
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
|
|
|
|
|
role?: Role
|
|
|
|
|
|
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
|
|
|
|
|
group?: string
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
|
|
|
|
|
grow?: number
|
|
|
|
|
|
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
|
|
|
|
align?: Align
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
|
|
|
|
|
|
* this is what stops one long sentence stretching a whole page into a letterbox.
|
|
|
|
|
|
* Higher priority than `grow`, matching Yoga's min/max rule.
|
|
|
|
|
|
*/
|
|
|
|
|
|
maxW?: number
|
|
|
|
|
|
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
|
|
|
|
|
|
minW0?: boolean
|
|
|
|
|
|
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
|
|
|
|
|
|
text?: TextStyle
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/** Flowchart outline. Absent means a plain rectangle. */
|
|
|
|
|
|
shape?: BoxShape
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/** Position within a `pool` parent. Ignored elsewhere. */
|
|
|
|
|
|
cell?: PoolCell
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A page title. At most one per diagram; laid out outside the tree flow. */
|
|
|
|
|
|
export interface TitleNode {
|
|
|
|
|
|
kind: "title"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A container that stacks its children in one direction.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
|
|
|
|
|
|
* container renders as a plain frame — a labelled rectangle with a border.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface GroupNode {
|
|
|
|
|
|
kind: "group"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
gname: string | null
|
|
|
|
|
|
label: string
|
|
|
|
|
|
dir: Extract<Direction, "row" | "col">
|
|
|
|
|
|
gap: number
|
|
|
|
|
|
children: DiagramNode[]
|
|
|
|
|
|
fill?: string
|
|
|
|
|
|
stroke?: string
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
/** Section role; a themed panel for its children. */
|
|
|
|
|
|
role?: Role
|
|
|
|
|
|
/** Semantic zone name; the panel takes this hue's tint. */
|
|
|
|
|
|
group?: string
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
|
|
|
|
|
grow?: number
|
|
|
|
|
|
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
|
|
|
|
align?: Align
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
|
|
|
|
|
|
justify?: Justify
|
|
|
|
|
|
/** Cross-axis default for every child that does not declare its own `align`. */
|
|
|
|
|
|
alignItems?: Align
|
|
|
|
|
|
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
|
|
|
|
|
|
maxW?: number
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Without it a weighted child is floored by its text, which is real flexbox behaviour
|
|
|
|
|
|
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
|
|
|
|
|
|
* whatever the two columns' text allows.
|
|
|
|
|
|
*/
|
|
|
|
|
|
minW0?: boolean
|
|
|
|
|
|
/** Presentation overrides: title type, alignment, frame border. */
|
|
|
|
|
|
text?: TextStyle
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
/** Interior padding, px. Absent = the default (24). */
|
|
|
|
|
|
pad?: number
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A container that packs its children into a fixed number of columns. */
|
|
|
|
|
|
export interface GridNode {
|
|
|
|
|
|
kind: "grid"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
gname: string | null
|
|
|
|
|
|
label: string
|
|
|
|
|
|
cols: number
|
|
|
|
|
|
gap: number
|
|
|
|
|
|
children: DiagramNode[]
|
|
|
|
|
|
fill?: string
|
|
|
|
|
|
stroke?: string
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* A swimlane pool: a sparse grid of (lane, column) cells.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
|
|
|
|
|
|
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
|
|
|
|
|
|
* to exactly one role and the columns show the order things happen in.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `phases` is an optional band of milestone labels above the columns.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface PoolNode {
|
|
|
|
|
|
kind: "pool"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
/** Role names, one per band. */
|
|
|
|
|
|
lanes: string[]
|
|
|
|
|
|
/** Milestone labels spanning the columns. Empty means no milestone band. */
|
|
|
|
|
|
phases: string[]
|
|
|
|
|
|
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
|
|
|
|
|
|
orientation: "horizontal" | "vertical"
|
|
|
|
|
|
gap: number
|
|
|
|
|
|
children: DiagramNode[]
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A sequence diagram: participants across the top, lifelines hanging below them.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Children are the participant heads, in left-to-right order. The messages are ordinary
|
|
|
|
|
|
* links whose `step` gives the vertical order — so the same `link` operation that draws
|
|
|
|
|
|
* an arrow in a flowchart draws a message here.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
|
|
|
|
|
|
* ever attaches to a lifeline directly.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface SequenceNode {
|
|
|
|
|
|
kind: "sequence"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
/** Horizontal distance between participant centres. */
|
|
|
|
|
|
gap: number
|
|
|
|
|
|
/** Vertical distance between consecutive messages. */
|
|
|
|
|
|
step: number
|
|
|
|
|
|
children: DiagramNode[]
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A mind map or org chart: a root with branches radiating from it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
|
|
|
|
|
|
* an arrow from A to B means B is a branch of A — not from nesting.
|
|
|
|
|
|
*
|
|
|
|
|
|
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
|
|
|
|
|
|
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
|
|
|
|
|
|
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
|
|
|
|
|
|
* org chart the arrows ARE the structure.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
|
|
|
|
|
|
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
|
|
|
|
|
|
* chart wants: a reporting line only reads correctly downwards.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface RadialNode {
|
|
|
|
|
|
kind: "radial"
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
spread: "radial" | "down"
|
|
|
|
|
|
/** Distance from a parent's edge to its children. */
|
|
|
|
|
|
gap: number
|
|
|
|
|
|
children: DiagramNode[]
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
pinned?: boolean
|
|
|
|
|
|
rect?: Rect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type ContainerNode =
|
|
|
|
|
|
| GroupNode
|
|
|
|
|
|
| GridNode
|
|
|
|
|
|
| PoolNode
|
|
|
|
|
|
| SequenceNode
|
|
|
|
|
|
| RadialNode
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
export type LeafNode = IconNode | BoxNode | TitleNode
|
|
|
|
|
|
export type DiagramNode = ContainerNode | LeafNode
|
|
|
|
|
|
|
|
|
|
|
|
export interface Rect {
|
|
|
|
|
|
x: number
|
|
|
|
|
|
y: number
|
|
|
|
|
|
w: number
|
|
|
|
|
|
h: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
|
|
|
|
|
|
export interface LinkSpec {
|
|
|
|
|
|
/** Cell id, so an existing edge can be addressed by later operations. */
|
|
|
|
|
|
id?: string
|
|
|
|
|
|
source: string
|
|
|
|
|
|
target: string
|
|
|
|
|
|
label?: string
|
|
|
|
|
|
/** Dashed line — replication, sync, policy, lineage. */
|
|
|
|
|
|
dashed?: boolean
|
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
|
|
|
|
|
* Thick and coloured, a visual element rather than a hairline connector.
|
|
|
|
|
|
*/
|
|
|
|
|
|
bold?: boolean
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
|
|
|
|
|
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
|
|
|
|
|
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
|
|
|
|
|
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
|
|
|
|
|
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
|
|
|
|
|
*/
|
|
|
|
|
|
head?: string
|
|
|
|
|
|
tail?: string
|
|
|
|
|
|
headFill?: boolean
|
|
|
|
|
|
tailFill?: boolean
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
/** Step number, rendered as an "N. " prefix on the label. */
|
|
|
|
|
|
step?: number
|
|
|
|
|
|
/** Verbatim style, when recovered from XML. */
|
|
|
|
|
|
style?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A whole diagram page: the node forest plus its arrows. */
|
|
|
|
|
|
export interface DiagramTree {
|
|
|
|
|
|
/** Top-level nodes, in layout order. */
|
|
|
|
|
|
roots: DiagramNode[]
|
|
|
|
|
|
links: LinkSpec[]
|
|
|
|
|
|
/** Page title, if the diagram has one. */
|
|
|
|
|
|
title?: string
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
|
|
|
|
|
|
*
|
|
|
|
|
|
* This is the one number that decides whether a diagram reads as a poster or as a
|
|
|
|
|
|
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
|
|
|
|
|
|
* portrait or 3-column landscape. So the model declares it, the engine gives the top
|
|
|
|
|
|
* level a width to match, and every proportional rule below finally has a share of
|
|
|
|
|
|
* something real to divide up.
|
|
|
|
|
|
*/
|
|
|
|
|
|
aspect?: number
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
|
|
|
|
|
|
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
|
|
|
|
|
|
* a re-layout never destroys work the engine does not understand.
|
|
|
|
|
|
*/
|
|
|
|
|
|
foreign: ForeignCell[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** A cell carried through the round-trip without interpretation. */
|
|
|
|
|
|
export interface ForeignCell {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
/** The cell's own serialised XML, verbatim. */
|
|
|
|
|
|
xml: string
|
|
|
|
|
|
/** Parent id at parse time, so it can be re-attached. */
|
|
|
|
|
|
parent: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isContainer(n: DiagramNode): n is ContainerNode {
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
|
return (
|
|
|
|
|
|
n.kind === "group" ||
|
|
|
|
|
|
n.kind === "grid" ||
|
|
|
|
|
|
n.kind === "pool" ||
|
|
|
|
|
|
n.kind === "sequence" ||
|
|
|
|
|
|
n.kind === "radial"
|
|
|
|
|
|
)
|
feat(diagram-engine): style markers + XML→tree reverse parser
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.
2026-08-09 11:35:32 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isLeaf(n: DiagramNode): n is LeafNode {
|
|
|
|
|
|
return !isContainer(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Depth-first walk over a node and its descendants. */
|
|
|
|
|
|
export function* walk(n: DiagramNode): Generator<DiagramNode> {
|
|
|
|
|
|
yield n
|
|
|
|
|
|
if (isContainer(n)) for (const c of n.children) yield* walk(c)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Every node in a tree, in document order. */
|
|
|
|
|
|
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
|
|
|
|
|
|
for (const r of t.roots) yield* walk(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Find a node by id, or null. */
|
|
|
|
|
|
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
|
|
|
|
|
|
for (const n of walkTree(t)) if (n.id === id) return n
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The container holding `id`, or null when it is a root or absent. */
|
|
|
|
|
|
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
|
|
|
|
|
|
for (const n of walkTree(t)) {
|
|
|
|
|
|
if (!isContainer(n)) continue
|
|
|
|
|
|
if (n.children.some((c) => c.id === id)) return n
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|