Files
next-ai-draw-io/lib/diagram-engine/markers.ts

586 lines
23 KiB
TypeScript
Raw Normal View History

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 markers how layout structure survives a round-trip through draw.io.
*
* The layout engine's tree carries information plain draw.io XML does not: which
* direction a container stacks its children, the gap between them, and whether the
* user has pinned a node's position. We encode that as extra `key=value` tokens in
* the cell's style string.
*
* Two behaviours this relies on, both verified in a real browser (Playwright drag
* against the embedded editor, reading the editor's own autosave payload):
*
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
* back byte-identical.
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
* dragged into it was reparented. So we can append a normalising token without
* first parsing out the old one.
*
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
* group_security_group and group_corporate_data_center ship WITH it. Appending
* unconditionally normalises all of them.
*/
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
export const MARKER = {
/** Node kind, so the parser does not have to re-guess it from the shape. */
kind: "dai_kind",
/** Child stacking direction of a container: "row" | "col" | "grid". */
dir: "dai_dir",
/** Gap between children, in px. */
gap: "dai_gap",
/** Column count, for grid containers. */
cols: "dai_cols",
/** Set by the user to freeze a node's position across re-layouts. */
pin: "dai_pin",
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
/**
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
* base64 image with no name anywhere in it, so the style alone cannot identify it.
*/
name: "dai_name",
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 (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
*
* Position alone cannot recover this once the user drags a node: the cell it lands in
* is a guess, whereas the marker records which lane the model assigned it to. It is
* also the only way an empty cell stays empty geometry can only tell us where things
* ARE, never that a role deliberately does nothing at a given step.
*/
cell: "dai_cell",
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
lanes: "dai_lanes",
/** A pool's milestone labels, tab-separated. */
phases: "dai_phases",
/** A pool's orientation: "h" or "v". */
orient: "dai_orient",
/** Vertical distance between consecutive messages in a sequence diagram. */
step: "dai_step",
/** How a radial container fans its branches out: "radial" or "down". */
spread: "dai_spread",
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
/** The node's information role (banner, heading, callout…), for the round trip. */
role: "dai_role",
/** The node's semantic zone, whose hue ramp colours it. */
group: "dai_group",
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
/**
* The declared shape token, verbatim. Appearance-based reverse mapping is ambiguous
* (aliases, rotated variants, styles with no unique shape= token), so the round trip
* carries the declaration itself.
*/
shape: "dai_shape",
/**
* Marks a node's size as engine-computed rather than user-fixed. Without it, the
* w/h read back from the canvas would freeze the first layout's measurement: change
* the label and the box would keep the old size instead of re-measuring.
*/
auto: "dai_auto",
/** Share of the parent's leftover flow-axis space — flex-grow. */
grow: "dai_grow",
/** Cross-axis position within the parent: "start" | "center" | "end". */
align: "dai_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 a container spreads children along its own axis — justify-content. */
justify: "dai_justify",
/** A container's cross-axis default for children that declare no align of their own. */
alignItems: "dai_aitems",
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
minw0: "dai_minw0",
/**
* Declared width cap, px.
*
* Has to be a marker rather than inferred from the drawn width: the two are only equal
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
* back with a 260 cap, and the next re-layout could never let it grow again.
*/
maxw: "dai_maxw",
/** A container's interior padding, px. */
pad: "dai_pad",
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
/**
* The page's declared width:height, on the default layer's cell.
*
* Page-level rather than per-node, so it goes on layer "1" the one cell every
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
* pageHeight: those are what the last layout produced, so reading them back would
* turn whatever shape a diagram happened to come out as into a standing request to
* keep it.
*/
aspect: "dai_aspect",
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
/**
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
* columns, its milestone strip. The parser must not read these back as nodes they are
* re-derived from the pool's own parameters on every layout and the edge router must
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
*/
lane: "dai_lane",
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
} as const
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
export type NodeKind =
| "group"
| "grid"
| "pool"
| "sequence"
| "radial"
| "icon"
| "box"
| "title"
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 = "row" | "col" | "grid"
/**
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
* dragged into it and reparents that shape (setting `parent` and switching the
* child's geometry to parent-relative).
*
* `pointerEvents=0` keeps clicks falling through to the children without it the
* frame swallows them and the user cannot select what is inside. `collapsible=0`
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
* the frame is resized, which would fight the layout engine.
*/
const CONTAINER_TOKENS =
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
/**
* A container that groups children for layout but should not be visible.
*
* The reference project solves this with a "phantom": a wrapper that participates in
* layout and then emits NO cell, reparenting its children onto the nearest visible
* ancestor. That makes the round-trip lossy by construction the wrapper's direction
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
* container's "col" direction, leaving children in a 2-D arrangement that can only be
* read back as a grid.
*
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
* in exchange for structure that survives being read back.
*/
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
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
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
export function readMarker(style: string, key: string): string | null {
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
let last: string | null = null
let m = re.exec(style)
while (m !== null) {
last = m[1]
m = re.exec(style)
}
return last
}
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
const KINDS: readonly NodeKind[] = [
"group",
"grid",
"pool",
"sequence",
"radial",
"icon",
"box",
"title",
]
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 readKind(style: string): NodeKind | null {
const v = readMarker(style, MARKER.kind)
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 KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
}
/**
* The (lane, column) cell a node occupies in a swimlane pool, or null.
*
* Both must be non-negative integers: a malformed value is safer read as "no cell
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
* place it outside the pool's frame.
*/
export function readCell(style: string): { lane: number; col: number } | null {
const v = readMarker(style, MARKER.cell)
if (!v) return null
const m = v.match(/^(\d+),(\d+)$/)
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
}
/**
* A tab-separated marker list, as written by `joinList`.
*
* A tab cannot appear in a draw.io style value the editor writes styles as a single
* semicolon-separated line so it is safe as a separator inside one value, where a comma
* would collide with the label text it has to carry.
*/
export function readList(style: string, key: string): string[] | null {
const v = readMarker(style, key)
if (v === null) return null
if (v === "") return []
return v.split("\t").map(decodeURIComponent)
}
/** Encode a list of labels into one marker value. */
export function joinList(items: string[]): string {
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
return items.map((s) => encodeURIComponent(s)).join("\t")
}
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
export function isLaneChrome(style: string): boolean {
return readMarker(style, MARKER.lane) !== null
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 readDir(style: string): Direction | null {
const v = readMarker(style, MARKER.dir)
if (v === "row" || v === "col" || v === "grid") return v
return null
}
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
export function readIntMarker(style: string, key: string): number | null {
const v = readMarker(style, key)
if (v === null) return null
const n = Number(v)
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
}
/**
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
* "Edit Style" dialog gets what they expect.
*/
export function isPinned(style: string): boolean {
const v = readMarker(style, MARKER.pin)
if (v === null) return false
const s = v.trim().toLowerCase()
return s !== "" && s !== "0" && s !== "false"
}
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
/**
* Append `key=value;`, replacing any existing occurrence of the key first.
*
* Styles are re-stamped on every render, and a style recovered from the canvas already
* carries last render's markers blindly appending grew the string by one duplicate per
* round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED
* wrong, which is why it went unnoticed until a byte-identity test caught it.
*
* Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
* style that may say `container=0`.
*/
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
/**
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
*
* Matching is per token, not on the whole run: a catalog style may already declare
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
* because one token was missing is what let these accumulate.
*
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
* port keys, and the router appended a fresh set on top of them every render 76 characters
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
* recovered from the canvas needs this rather than `+=`.
*/
export function appendOnce(style: string, tokens: string): string {
let s = style
for (const tok of tokens.split(";")) {
if (!tok) continue
const key = tok.slice(0, tok.indexOf("="))
// The key must not be present with ANY value: `container=0` from a catalog stencil
// has to be overwritten, which is what appending the correct value does.
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
if (has) {
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
}
s = s.endsWith(";") || s === "" ? s : `${s};`
s += `${tok};`
}
return s
}
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
function append(style: string, key: string, value: string | number): 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
const cleaned = key.startsWith("dai_")
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
: style
const base =
cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};`
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
return `${base}${key}=${value};`
}
/**
* Stamp a container's style: make it a real draw.io container and record its
* layout parameters.
*
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
* a catalog style that already says `container=1` is unharmed, and one that says
* nothing (or `container=0`) is corrected.
*/
export function stampContainer(
style: string,
opts: {
kind: "group" | "grid"
dir: Direction
gap: number
cols?: number
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
/** Layout-only wrapper: emit a real cell, but draw nothing. */
invisible?: 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
},
): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
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
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
// never reached a fixed point and every edit shipped a longer style.
s = appendOnce(s, CONTAINER_TOKENS)
if (opts.invisible) s = appendOnce(s, INVISIBLE_TOKENS)
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
s = append(s, MARKER.kind, opts.kind)
s = append(s, MARKER.dir, opts.dir)
s = append(s, MARKER.gap, Math.round(opts.gap))
if (opts.kind === "grid" && opts.cols != null)
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
return s
}
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
/**
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
*
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
* cells sitting inside it, and they are what a shape should reparent into when the user
* drags it that is how "the user moved this step to a different role" gets recorded. If
* the pool itself claimed the drop, every node would come back in lane 0.
*/
export function stampPool(
style: string,
opts: {
lanes: string[]
phases: string[]
orientation: "horizontal" | "vertical"
gap: number
},
): string {
let s = append(style, MARKER.kind, "pool")
s = append(s, MARKER.lanes, joinList(opts.lanes))
s = append(s, MARKER.phases, joinList(opts.phases))
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
return append(s, MARKER.gap, Math.round(opts.gap))
}
/** Stamp a sequence container: participant spacing and message spacing. */
export function stampSequence(
style: string,
opts: { gap: number; step: number },
): string {
const s = append(style, MARKER.kind, "sequence")
return append(
append(s, MARKER.gap, Math.round(opts.gap)),
MARKER.step,
Math.round(opts.step),
)
}
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
export function stampRadial(
style: string,
opts: { spread: "radial" | "down"; gap: number },
): string {
const s = append(style, MARKER.kind, "radial")
return append(
append(s, MARKER.spread, opts.spread),
MARKER.gap,
Math.round(opts.gap),
)
}
/**
* Stamp one of a pool's lane bands.
*
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
* there and the marker on the band tells the parser which lane that is. The lane index is
* the band's identity, not its position, so the assignment survives the pool being
* re-measured to a different size.
*/
export function stampLane(style: string, lane: number): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
s += CONTAINER_TOKENS
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
}
/**
* Stamp a pool's own decoration a lane-name column or a milestone strip.
*
refactor(diagram-engine): apply review findings, fix vertical pool phases Four reviewers went over the previous commit (three Claude, one Codex). Their findings, verified independently before applying: A REAL BUG. A vertical pool with milestone labels drew the label strip outside the pool frame. The measure pass reserves width as padding + content + strip with no gap between the last two; the renderer placed the strip one gap further out. No test caught it because every vertical case omitted phases and every phases case was horizontal — both regression cases added. Duplicated logic, now single-sourced: - messageCount existed byte-identically in layout.ts and render.ts. Two copies that had to agree or the lifelines stop reaching the last message. - sequenceMetrics was called twice per sequence container, once inside the chrome builder and again for the message positions. Same drift hazard, in the file whose own comment warns about it. Dead code, each verified unreachable rather than assumed: - Placed.extent: declared and documented, never written or read. Every .extent access belongs to RadialTree. - SequenceMetrics.top: computed, returned, no reader. - spread()'s level parameter: threaded through the recursion, never used. - radialReach's .slice(0, generations): widestPerLevel writes one entry per generation, so its length IS the depth. Confirmed over 20,000 random trees; removing it made RadialTree.depth dead too. - Two of three cycle guards in radialHierarchy: self-links are already skipped when the parent map is built, and that map holds one parent per node, so the structure is a forest and the visited-set filter cannot fire. The rootOf guard does fire and stays. - GraphOptions.layerGap/nodeGap/idPrefix: no caller, not in the tool schema. Simplifications: - LayoutContext wrapped a single field; the link array now passes directly, which also removes the NO_CONTEXT default no call site ever took. - stretches() and the mirror-image check five lines below it expressed one rule two ways; unified, with the rationale stated once. - hasStencilFrame/isDirectional: one caller each, and isDirectional's name contradicted its body, which the guarded branch then re-discriminated anyway. - poolFrameStyle() took no arguments and had one caller. - poolCellOf clamped a value already clamped at the model boundary and unreachable-by-construction from the parser. - A comment on stampPoolDecoration described container behaviour the function does not implement. Kept deliberately, with evidence: - The best-arrangement tracking in the crossing reducer. Two reviewers suspected it was dead weight. Measured: barycentre sweeping regressed below its own running best in 180 of 500 random graphs, so without it a third of flowcharts would keep a worse arrangement than one already found. - Vertical pools. Two reviewers recommended deleting the feature as undiscoverable. The bug was one line, and vertical swimlanes are a real convention — documented to the model instead, which is what was actually missing. - styleValue duplicating readMarker, isLeaf, findPageIndex: all genuinely redundant, all predating this branch. Left alone to keep the diff scoped. 525 unit tests and 11 diagram e2e tests pass.
2026-08-09 14:47:46 +09:00
* `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
* than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
* container: a step dropped on a label column belongs to no role, and letting it reparent
* there would lose the step's lane.
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
*/
export function stampPoolDecoration(style: string): string {
return append(style, MARKER.lane, -1)
}
/** Record which pool cell a node occupies. */
export function stampCell(
style: string,
cell: { lane: number; col: number },
): string {
return append(
style,
MARKER.cell,
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
)
}
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
/**
* Is this an invisible layout wrapper? Both colours set to `none` and no group
* stencil a visible frame always has a stroke or a stencil.
*/
export function isInvisible(style: string): boolean {
if (/grIcon=/.test(style)) return false
const fill = readMarker(style, "fillColor")
const stroke = readMarker(style, "strokeColor")
return fill === "none" && stroke === "none"
}
/**
* Stamp a leaf with its kind, so the parser need not infer it.
*
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
* base64 image with no name in it, so the style alone cannot identify which icon it is.
*/
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 stampLeaf(
style: string,
kind: "icon" | "box" | "title",
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
opts: { name?: string } = {},
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
): string {
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
const s = append(style, MARKER.kind, kind)
return opts.name ? append(s, MARKER.name, opts.name) : s
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): 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
/** Stamp the node's information role, replacing any previous one. */
export function stampRole(style: string, role: string): string {
return append(style, MARKER.role, role)
}
/** Stamp the node's semantic zone, replacing any previous one. */
export function stampGroup(style: string, group: string): string {
return append(style, MARKER.group, encodeURIComponent(group))
}
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
/** Stamp the declared shape token, so the round trip carries the declaration itself. */
export function stampShape(style: string, shape: string): string {
return append(style, MARKER.shape, encodeURIComponent(shape))
}
/** Mark a node's size as engine-computed, so a re-layout re-measures it. */
export function stampAuto(style: string): string {
return append(style, MARKER.auto, 1)
}
/** Was this node's size computed by the engine (vs fixed by the user or the model)? */
export function isAutoSized(style: string): boolean {
return readMarker(style, MARKER.auto) === "1"
}
type FlexAlign = "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
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
export function stampFlex(
style: 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
opts: {
grow?: number
align?: FlexAlign
justify?: FlexJustify
alignItems?: FlexAlign
maxW?: number
minW0?: boolean
pad?: number
},
): string {
let s = style
if (opts.grow != null && opts.grow > 0)
s = append(s, MARKER.grow, opts.grow)
if (opts.align && opts.align !== "center")
s = append(s, MARKER.align, opts.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
if (opts.justify && opts.justify !== "start")
s = append(s, MARKER.justify, opts.justify)
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
if (opts.maxW != null && opts.maxW > 0)
s = append(s, MARKER.maxw, Math.round(opts.maxW))
if (opts.minW0) s = append(s, MARKER.minw0, 1)
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
return s
}
/** Read the align marker back. Anything unrecognised means the default (center). */
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
const v = readMarker(style, MARKER.align)
return v === "start" || v === "end" || v === "stretch" ? v : null
}
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
/** Read a container's cross-axis default. Null means it declared none. */
export function readAlignItems(style: string): FlexAlign | null {
const v = readMarker(style, MARKER.alignItems)
return v === "start" || v === "end" || v === "stretch" || v === "center"
? v
: null
}
/** Read the justify marker back. Anything unrecognised means the default (start). */
export function readJustify(
style: string,
): Exclude<FlexJustify, "start"> | null {
const v = readMarker(style, MARKER.justify)
return v === "center" ||
v === "end" ||
v === "between" ||
v === "around" ||
v === "evenly"
? v
: null
}
/** Read the declared width cap back, or null when there was none. */
export function readMaxW(style: string): number | null {
const v = Number(readMarker(style, MARKER.maxw))
return Number.isFinite(v) && v > 0 ? v : null
}
/** Did this node opt out of the content-width floor? */
export function readMinW0(style: string): boolean {
return readMarker(style, MARKER.minw0) === "1"
}
/** The page's declared aspect ratio, stamped on the default layer. */
export function stampAspect(layerXml: string, aspect: number): string {
return layerXml.replace(
/<mxCell id="1" parent="0"\/>/,
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
)
}
/**
* Read the page's declared aspect back out of a model body.
*
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
* marker name is namespaced, so a match cannot be anything else, and this keeps working
* if draw.io ever reorders or reformats that cell.
*/
export function readAspect(page: string): number | undefined {
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
if (!m) return undefined
const v = Number(m[1])
return Number.isFinite(v) && v > 0
? Math.min(4, Math.max(0.25, v))
: undefined
}
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
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
export function stripMarkers(style: string): string {
return style
.split(";")
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
.join(";")
.concat(";")
.replace(/^;$/, "")
}
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
export function hasMarkers(style: string): boolean {
return /(?:^|;)dai_[a-z]+=/.test(style)
}