feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
/ * *
* Structural operations — what the model sends instead of XML .
*
* The tree is re - derived from the canvas on every call , so an operation names existing
* nodes by id and says what to change . Adding one node costs a few dozen tokens ; the
* equivalent as raw mxCell XML is hundreds , and re - emitting the whole diagram to add one
* icon costs thousands .
*
* Operations are applied in order , each against the result of the last , so a sequence
* like "add a frame, then move two nodes into it" works in a single call .
* /
import { z } from "zod"
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime.
import { graphToOperations } from "./graph"
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
import { parseTw , type TwLayout } from "./tw"
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
import {
type ContainerNode ,
type DiagramNode ,
type DiagramTree ,
findNode ,
findParent ,
isContainer ,
type LinkSpec ,
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 TextStyle ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
walkTree ,
} from "./types"
export const OperationSchema = z . discriminatedUnion ( "op" , [
z . object ( {
op : z.literal ( "add_icon" ) ,
id : z.string ( ) . describe ( "New unique id for this node" ) ,
parent : z
. string ( )
. optional ( )
. describe ( "Container id to add into; omit for top level" ) ,
name : z.string ( ) . describe ( "Catalog stencil name, e.g. 's3' or 'ec2'" ) ,
label : z.string ( ) . optional ( ) ,
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
lane : z
. number ( )
. optional ( )
. describe (
"Inside a pool: which lane (0-based row) this belongs to" ,
) ,
col : z
. number ( )
. optional ( )
. describe (
"Inside a pool: which column (0-based step) this sits in" ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
after : z
. string ( )
. optional ( )
. describe ( "Insert after this sibling id; omit to append" ) ,
} ) ,
z . object ( {
op : z.literal ( "add_box" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z.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
role : z
. enum ( [
"banner" ,
"heading" ,
"body" ,
"callout" ,
"good" ,
"bad" ,
"metric" ,
"muted" ,
] )
. optional ( )
. describe (
"What this IS: banner=masthead, heading=section title, callout=must-not-miss, good/bad=verdict, metric=key number, muted=fine print. The theme decides how each looks" ,
) ,
group : z
. string ( )
. optional ( )
. describe (
"Semantic zone name; nodes and panels sharing a group get the same hue from the engine's palette. Never pick colours" ,
) ,
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
fill : z
. string ( )
. optional ( )
. describe (
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
"Fill colour, e.g. #DAE8FC. Prefer the group field over picking colours" ,
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
) ,
stroke : z
. string ( )
. optional ( )
. describe ( "Border colour; pair it with fill" ) ,
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
shape : z
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
. string ( )
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
. optional ( )
. describe (
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
"What the node IS, drawn as its conventional outline. Catalog: decision/diamond, terminator (start/end), round, data (input/output), document, cylinder (database), queue, person (actor/user), cloud (external system), hexagon (service), ellipse (concept), callout (note), step (pipeline stage), note, card, process, tape, cube. Any other draw.io shape token also works verbatim. Omit for a plain rectangle" ,
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
) ,
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
grow : z
. number ( )
. optional ( )
. describe (
"Flex-grow weight: this box takes that share of the parent's leftover space along its stacking axis. Omit for natural size" ,
) ,
align : z
. enum ( [ "start" , "center" , "end" , "stretch" ] )
. optional ( )
. describe (
"Cross-axis position in the parent: start/end pin to an edge, stretch fills the axis (a divider or highlight bar spanning its card). Default center" ,
) ,
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
maxW : z
. number ( )
. optional ( )
. describe (
"Hard width cap in px. Long text rewraps to fit instead of stretching the box, so this is what keeps a paragraph from making the whole page a letterbox. Beats grow" ,
) ,
class : z
. string ( )
. optional ( )
. describe (
'Tailwind layout classes, e.g. "grow-2 self-stretch max-w-md". Supported: grow / grow-N / flex-N, w-1/3 (a share of the row), w-full, min-w-0, self-start|center|end|stretch, max-w-N or max-w-xs..4xl. NO colour classes — colour comes from role and group. Unknown classes are ignored and reported back' ,
) ,
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
lane : z
. number ( )
. optional ( )
. describe (
"Inside a pool: which lane (0-based row) this belongs to" ,
) ,
col : z
. number ( )
. optional ( )
. describe (
"Inside a pool: which column (0-based step) this sits in" ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
after : z.string ( ) . optional ( ) ,
} ) ,
z . object ( {
op : z.literal ( "add_container" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z
. string ( )
. describe ( "Frame title; empty string means invisible wrapper" ) ,
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
role : z
. enum ( [
"banner" ,
"heading" ,
"body" ,
"callout" ,
"good" ,
"bad" ,
"metric" ,
"muted" ,
] )
. optional ( )
. describe (
"Section role: heading=titled tinted panel, banner=masthead strip, good/bad=verdict panel" ,
) ,
group : z
. string ( )
. optional ( )
. describe (
"Semantic zone name; the panel and everything sharing this group take one hue" ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
dir : z.enum ( [ "row" , "col" ] ) . describe ( "How children stack" ) ,
gname : z
. string ( )
. optional ( )
. describe (
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame" ,
) ,
gap : z.number ( ) . optional ( ) ,
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
pad : z
. number ( )
. optional ( )
. describe (
"Interior padding px (default 24). Small values make tight cards; nest containers for internal structure" ,
) ,
grow : z
. number ( )
. optional ( )
. describe (
"Flex-grow weight: this container takes that share of the parent's leftover space. E.g. two columns with grow 2 and 1 split the width 2:1" ,
) ,
align : z
. enum ( [ "start" , "center" , "end" , "stretch" ] )
. optional ( )
. describe (
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center" ,
) ,
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
justify : z
. enum ( [ "start" , "center" , "end" , "between" , "around" , "evenly" ] )
. optional ( )
. describe (
"How children spread along dir when there is spare room. Default start packs them and leaves the gap at the far end — set between or evenly to spread a short column down its full height instead of leaving a hole at the bottom" ,
) ,
alignItems : z
. enum ( [ "start" , "center" , "end" , "stretch" ] )
. optional ( )
. describe (
"Cross-axis default for every child, so cards in a column all span the same width without setting align on each. stretch is what makes a column of cards line up" ,
) ,
maxW : z
. number ( )
. optional ( )
. describe (
"Hard width cap in px. Children wrap or shrink to fit rather than run past it. Beats grow" ,
) ,
class : z
. string ( )
. optional ( )
. describe (
'Tailwind classes, e.g. "flex-col gap-4 p-4 grow-3 items-stretch justify-between max-w-2xl". LAYOUT: flex-row|flex-col, grow / grow-N / flex-N, w-1/3, w-full, min-w-0 (let a weight shrink this below its own text width — needed on every column when you want an exact ratio), items-* and self-* (start|center|end|stretch), justify-start|center|end|between|around|evenly, gap-N, p-N, max-w-N or max-w-xs..4xl. Spacing is Tailwind\'s 4px scale, so gap-4 is 16px. TEXT (applies to the frame title): font-bold / font-normal, italic, underline, text-xs..text-4xl, text-left|center|right, align-top|middle|bottom, whitespace-nowrap. BORDER: border / border-N, border-dashed / border-dotted / border-solid — a dashed frame reads as planned or logical rather than deployed. NOT accepted: any colour class — colour comes from role and group; the other seven font weights; opacity-*, truncate, rounded-*, shadow-*, outline-*, transforms. Unknown classes are dropped and reported back' ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
after : z.string ( ) . optional ( ) ,
} ) ,
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
z . object ( {
op : z.literal ( "add_graph" ) ,
id : z.string ( ) ,
parent : z
. string ( )
. optional ( )
. describe ( "Container to embed the graph in; omit for top level" ) ,
label : z.string ( ) . optional ( ) . describe ( "Frame title; omit for none" ) ,
dir : z
. enum ( [ "col" , "row" ] )
. optional ( )
. describe (
"Flow direction: col (default) downwards, row rightwards" ,
) ,
nodes : z
. array (
z . object ( {
id : z.string ( ) ,
label : z.string ( ) ,
shape : z.string ( ) . optional ( ) ,
icon : z.string ( ) . optional ( ) ,
group : z.string ( ) . optional ( ) ,
role : z
. enum ( [
"banner" ,
"heading" ,
"body" ,
"callout" ,
"good" ,
"bad" ,
"metric" ,
"muted" ,
] )
. optional ( ) ,
} ) ,
)
. describe ( "The graph's nodes" ) ,
edges : z
. array (
z . object ( {
source : z.string ( ) ,
target : z.string ( ) ,
label : z.string ( ) . optional ( ) ,
dashed : z.boolean ( ) . optional ( ) ,
bold : z.boolean ( ) . optional ( ) ,
head : z.string ( ) . optional ( ) ,
tail : z.string ( ) . optional ( ) ,
headFill : z.boolean ( ) . optional ( ) ,
tailFill : z.boolean ( ) . optional ( ) ,
} ) ,
)
. describe (
"The arrows. THEY decide the node positions — layering and ordering are computed from them" ,
) ,
after : z.string ( ) . optional ( ) ,
} ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
z . object ( {
op : z.literal ( "add_grid" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z.string ( ) ,
cols : z.number ( ) . describe ( "Number of columns" ) ,
gap : z.number ( ) . optional ( ) ,
after : z.string ( ) . optional ( ) ,
} ) ,
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
z . object ( {
op : z.literal ( "add_pool" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z.string ( ) . describe ( "Pool title, e.g. the process name" ) ,
lanes : z
. array ( z . string ( ) )
. describe (
"Role names, one per lane, top to bottom. Steps go in these lanes via add_box lane/col" ,
) ,
phases : z
. array ( z . string ( ) )
. optional ( )
. describe ( "Milestone labels spanning the columns; omit for none" ) ,
orientation : z
. enum ( [ "horizontal" , "vertical" ] )
. optional ( )
. describe (
"horizontal (default): lanes stack down, flow goes right" ,
) ,
gap : z.number ( ) . optional ( ) ,
after : z.string ( ) . optional ( ) ,
} ) ,
z . object ( {
op : z.literal ( "add_sequence" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z.string ( ) . describe ( "Diagram title; empty string for none" ) ,
gap : z
. number ( )
. optional ( )
. describe ( "Horizontal spacing between participants" ) ,
step : z
. number ( )
. optional ( )
. describe ( "Vertical spacing between messages" ) ,
after : z.string ( ) . optional ( ) ,
} ) ,
z . object ( {
op : z.literal ( "add_radial" ) ,
id : z.string ( ) ,
parent : z.string ( ) . optional ( ) ,
label : z.string ( ) . describe ( "Frame title; empty string for none" ) ,
spread : z
. enum ( [ "radial" , "down" ] )
. optional ( )
. describe (
"radial (default): branches on both sides, for a mind map. down: everything below the centre, for an org chart" ,
) ,
gap : z.number ( ) . optional ( ) ,
after : z.string ( ) . optional ( ) ,
} ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
z . object ( {
op : z.literal ( "remove" ) ,
id : z
. string ( )
. describe ( "Node to delete; its descendants and edges go too" ) ,
} ) ,
z . object ( {
op : z.literal ( "move" ) ,
id : z.string ( ) ,
parent : z
. string ( )
. optional ( )
. describe ( "New container id; omit to move to top level" ) ,
after : z.string ( ) . optional ( ) ,
} ) ,
z . object ( {
op : z.literal ( "set_label" ) ,
id : z.string ( ) ,
label : z.string ( ) ,
} ) ,
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
z . object ( {
op : z.literal ( "set_shape" ) ,
id : z.string ( ) ,
shape : z
. string ( )
. describe ( "New shape token; 'box' resets to a plain rectangle" ) ,
} ) ,
z . object ( {
op : z.literal ( "set_role" ) ,
id : z.string ( ) ,
role : z
. enum ( [
"banner" ,
"heading" ,
"body" ,
"callout" ,
"good" ,
"bad" ,
"metric" ,
"muted" ,
] )
. describe ( "New information role; 'body' resets to the default" ) ,
} ) ,
z . object ( {
op : z.literal ( "set_group" ) ,
id : z.string ( ) ,
group : z
. string ( )
. describe ( "New semantic zone; empty string removes the zone" ) ,
} ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
z . object ( {
op : z.literal ( "set_dir" ) ,
id : z.string ( ) . describe ( "Container to re-orient" ) ,
dir : z.enum ( [ "row" , "col" ] ) ,
} ) ,
z . object ( {
op : z.literal ( "set_gap" ) ,
id : z.string ( ) ,
gap : z.number ( ) ,
} ) ,
z . object ( {
op : z.literal ( "link" ) ,
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
id : z
. string ( )
. optional ( )
. describe (
"Edge id. Required for a second edge between the same two nodes (parallel relationships), so each can be addressed later" ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
source : z.string ( ) ,
target : z.string ( ) ,
label : z.string ( ) . optional ( ) ,
dashed : z
. boolean ( )
. optional ( )
. describe ( "Dashed line — replication, sync, policy" ) ,
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
bold : z
. boolean ( )
. optional ( )
. describe (
"A thick coloured arrow for THE key relationship — a transformation, the main flow. Use sparingly: one or two per diagram" ,
) ,
head : z
. string ( )
. optional ( )
. describe (
"Arrowhead at the target. block/open/diamond/diamondThin/oval/cross/none, ER: ERone/ERmany/ERoneToMany/ERzeroToMany/ERzeroToOne. UML inheritance: head=block headFill=false. Omit for a plain arrow" ,
) ,
tail : z
. string ( )
. optional ( )
. describe (
"Arrowhead at the source, same values as head. UML composition: tail=diamondThin tailFill=true. ER 1:N: tail=ERone head=ERoneToMany" ,
) ,
headFill : z
. boolean ( )
. optional ( )
. describe (
"Fill the head. Meaning-bearing in UML: filled diamond=composition, hollow=aggregation" ,
) ,
tailFill : z.boolean ( ) . optional ( ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
step : z
. number ( )
. optional ( )
. describe ( "Step number, shown as an 'N. ' prefix" ) ,
} ) ,
z . object ( {
op : z.literal ( "unlink" ) ,
source : z.string ( ) ,
target : z.string ( ) ,
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
step : z
. number ( )
. optional ( )
. describe (
"Remove only the edge with this step number; omit to remove every edge between the two" ,
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
} ) ,
z . object ( {
op : z.literal ( "set_title" ) ,
title : z.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
z . object ( {
op : z.literal ( "clear" ) ,
keepTitle : z
. boolean ( )
. optional ( )
. describe ( "Keep the page title; default drops it too" ) ,
} ) ,
z . object ( {
op : z.literal ( "set_page" ) ,
aspect : z
. number ( )
. describe (
"Target width:height for the whole page. 1 = square, 1.4 = landscape slide, 0.75 = portrait poster, 1.6 = wide architecture diagram. Declare this FIRST on any multi-column diagram: it is what gives the columns a total width to divide, so grow weights and column proportions only take effect once it is set" ,
) ,
} ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
] )
export type Operation = z . infer < typeof OperationSchema >
export interface ApplyResult {
tree : DiagramTree
/** One entry per operation that could not be applied, in order. */
errors : 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
/ * *
* Things that were drawn , but not the way they were asked for — an arrow naming a node
* that is not in the list , a loop that could not order the layers . Not errors : the
* diagram is fine and re - sending it would produce the same result , so failing would
* cost a turn and fix nothing .
* /
warnings : string [ ]
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
}
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
/ * *
* The pool cell an add operation declared , if any .
*
* ` lane ` alone is enough — a step in a lane with no column given goes to column 0 — so the
* cell is recorded whenever either is present rather than requiring both .
* /
function cellOf ( op : { lane? : number ; col? : number } ) : {
cell ? : { lane : number ; col : number }
} {
if ( op . lane == null && op . col == null ) return { }
return {
cell : {
lane : Math.max ( 0 , Math . round ( op . lane ? ? 0 ) ) ,
col : Math.max ( 0 , Math . round ( op . col ? ? 0 ) ) ,
} ,
}
}
/** Are these two nodes participants of the same sequence diagram? */
function sameSequence ( tree : DiagramTree , a : string , b : string ) : boolean {
for ( const n of walkTree ( tree ) ) {
if ( n . kind !== "sequence" ) continue
const ids = new Set ( n . children . map ( ( c ) = > c . id ) )
if ( ids . has ( a ) && ids . has ( b ) ) return true
}
return false
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
/** Insert into a child list, after a named sibling or at the end. */
function insert (
list : DiagramNode [ ] ,
node : DiagramNode ,
after : string | undefined ,
) : void {
if ( after ) {
const i = list . findIndex ( ( c ) = > c . id === after )
if ( i >= 0 ) {
list . splice ( i + 1 , 0 , node )
return
}
}
list . push ( node )
}
/** Detach a node from wherever it currently sits. Returns it, or null if not found. */
function detach ( tree : DiagramTree , id : string ) : DiagramNode | null {
const rootIdx = tree . roots . findIndex ( ( r ) = > r . id === id )
if ( rootIdx >= 0 ) return tree . roots . splice ( rootIdx , 1 ) [ 0 ]
const parent = findParent ( tree , id )
if ( ! parent ) return null
const i = parent . children . findIndex ( ( c ) = > c . id === id )
return i >= 0 ? parent . children . splice ( i , 1 ) [ 0 ] : null
}
/** Would making `id` a descendant of `parentId` create a cycle? */
function wouldCycle ( tree : DiagramTree , id : string , parentId : string ) : boolean {
if ( id === parentId ) return true
const node = findNode ( tree , id )
if ( ! node || ! isContainer ( node ) ) return false
for ( const d of walkTree ( { . . . tree , roots : [ node ] } ) )
if ( d . id === parentId ) return true
return false
}
/ * *
* Resolve where a new or moved node goes . Returns the child list to insert into , or an
* error string .
* /
function targetList (
tree : DiagramTree ,
parentId : string | undefined ,
) : DiagramNode [ ] | string {
if ( ! parentId ) return tree . roots
const p = findNode ( tree , parentId )
if ( ! p ) return ` No node with id " ${ parentId } " `
if ( ! isContainer ( p ) )
return ` " ${ parentId } " is a ${ p . kind } , not a container — it cannot hold children `
return p . children
}
/ * *
* Apply operations to a tree , in order .
*
* The input tree is deep - copied first : a partially - applied batch must not leave the
* caller ' s tree half - mutated when a later operation fails .
* /
export function applyOperations (
input : DiagramTree ,
ops : Operation [ ] ,
) : ApplyResult {
const tree : DiagramTree = structuredClone ( input )
const errors : 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
const warnings : string [ ] = [ ]
/ * *
* Resolve an operation ' s Tailwind class string into layout fields .
*
* Explicit fields win over classes . Both are accepted because they are the same
* vocabulary said two ways , and a caller mixing them — ` class: "flex-col gap-4" ` plus
* ` grow: 3 ` — means the explicit number , not a conflict to reject .
*
* Unknown classes are collected once per call rather than per operation : a poster
* repeating ` shadow-lg ` on twelve cards should say so once .
* /
const ignoredClasses = new Set < string > ( )
const twOf = ( cls : string | undefined ) : TwLayout | null = > {
if ( ! cls ? . trim ( ) ) return null
const parsed = parseTw ( cls )
for ( const c of parsed . ignored ) ignoredClasses . add ( c )
return parsed
}
/ * *
* The presentation overrides a class string asked for , or undefined when it asked for
* none . Sparse on purpose : an absent field means "let the role decide" , so a class
* string that only sets alignment cannot silently reset the type size .
* /
const textOf = ( tw : TwLayout | null ) : TextStyle | undefined = > {
if ( ! tw ) return undefined
const t : TextStyle = {
. . . ( tw . bold != null ? { bold : tw.bold } : { } ) ,
. . . ( tw . italic != null ? { italic : tw.italic } : { } ) ,
. . . ( tw . underline != null ? { underline : tw.underline } : { } ) ,
. . . ( tw . strike != null ? { strike : tw.strike } : { } ) ,
. . . ( tw . fontSize != null ? { size : tw.fontSize } : { } ) ,
. . . ( tw . textAlign ? { align : tw.textAlign } : { } ) ,
. . . ( tw . verticalAlign ? { valign : tw.verticalAlign } : { } ) ,
. . . ( tw . nowrap != null ? { nowrap : tw.nowrap } : { } ) ,
. . . ( tw . borderWidth != null ? { borderWidth : tw.borderWidth } : { } ) ,
. . . ( tw . borderStyle ? { borderStyle : tw.borderStyle } : { } ) ,
. . . ( tw . borderless != null ? { borderless : tw.borderless } : { } ) ,
. . . ( tw . radius != null ? { radius : tw.radius } : { } ) ,
. . . ( tw . shadow != null ? { shadow : tw.shadow } : { } ) ,
}
return Object . keys ( t ) . length > 0 ? t : undefined
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
const exists = ( id : string ) = > findNode ( tree , id ) !== null
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
// add_graph is a macro: the layered-graph pass (graph.ts) decides which layer each
// node belongs to and who stands beside whom, and emits ordinary container/box/link
// operations. Expanding it HERE — rather than treating graphs as a special page-level
// tool — is what lets a graph sit inside a poster column or an architecture zone and
// still participate in the outer flexbox like any other node.
const expanded : Operation [ ] = [ ]
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
for ( const op of ops ) {
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
if ( op . op !== "add_graph" ) {
expanded . push ( op )
continue
}
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
// Reject a graph that cannot be drawn, rather than emitting a broken one. Both
// checks have to happen here: an empty node list would otherwise produce an empty
// frame, and a duplicate id would surface as "add_box: id already taken", naming a
// synthetic operation the model never wrote.
if ( op . nodes . length === 0 ) {
errors . push ( ` add_graph " ${ op . id } ": no nodes — nothing to draw. ` )
continue
}
const dupes = op . nodes
. map ( ( nd ) = > nd . id )
. filter ( ( id , i , all ) = > all . indexOf ( id ) !== i )
if ( dupes . length > 0 ) {
errors . push (
` add_graph " ${ op . id } ": duplicate node id(s): ${ [ . . . new Set ( dupes ) ] . join ( ", " ) } . ` ,
)
continue
}
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
const g = graphToOperations ( op . nodes , op . edges , {
flow : op.dir ? ? "col" ,
parent : op.parent ,
// The graph's own id namespaces the synthetic layer containers, so two
// graphs on one page cannot collide on `__layer0`.
prefix : op.id ,
rootId : op.id ,
} )
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
// A stray endpoint is a warning, not an error: the rest of the graph is drawn
// correctly, so rejecting it would cost a turn and produce the same diagram.
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
if ( g . unknownEndpoints . length )
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
warnings . push (
` Dropped edge(s) naming nodes that were not in the node list: ${ g . unknownEndpoints . join ( ", " ) } . ` ,
)
if ( g . backEdges . length )
warnings . push (
` Loop(s) drawn but not used for ordering: ${ g . backEdges
. map ( ( b ) = > ` ${ b . source } → ${ b . target } ` )
. join ( ", " ) } . ` ,
feat(diagram-engine): add_graph — arrow-ordered layout as a container
The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.
add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.
Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.
4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
2026-08-09 22:08:43 +09:00
)
if ( op . label || op . after ) {
const root = g . operations [ 0 ]
if ( root ? . op === "add_container" ) {
if ( op . label ) root . label = op . label
if ( op . after ) root . after = op . after
}
}
expanded . push ( . . . g . operations )
}
for ( const op of expanded ) {
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
switch ( op . op ) {
case "add_icon" :
case "add_box" :
case "add_container" :
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
case "add_grid" :
case "add_pool" :
case "add_sequence" :
case "add_radial" : {
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
if ( exists ( op . id ) ) {
errors . push ( ` ${ op . op } : id " ${ op . id } " is already taken ` )
break
}
const list = targetList ( tree , op . parent )
if ( typeof list === "string" ) {
errors . push ( ` ${ op . op } : ${ list } ` )
break
}
let node : DiagramNode
if ( op . op === "add_icon" )
node = {
kind : "icon" ,
id : op.id ,
name : op.name ,
label : op.label ? ? "" ,
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
. . . cellOf ( op ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
}
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
else if ( op . op === "add_box" ) {
// Classes first, then the explicit fields on top: an explicit number is
// the more specific statement of the two.
const tw = twOf ( op . class )
const grow = op . grow ? ? tw ? . grow
const align = op . align ? ? tw ? . align
const maxW = op . maxW ? ? tw ? . maxW
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
node = {
kind : "box" ,
id : op.id ,
label : op.label ,
. . . ( op . shape && op . shape !== "box"
? { shape : op.shape }
: { } ) ,
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
. . . ( op . fill ? { fill : op.fill } : { } ) ,
. . . ( op . stroke ? { stroke : op.stroke } : { } ) ,
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
. . . ( op . role ? { role : op.role } : { } ) ,
. . . ( op . group ? { group : op.group } : { } ) ,
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
. . . ( grow && grow > 0 ? { grow } : { } ) ,
. . . ( align && align !== "center" ? { align } : { } ) ,
. . . ( maxW && maxW > 0 ? { maxW } : { } ) ,
. . . ( tw ? . minW0 ? { minW0 : true } : { } ) ,
. . . ( textOf ( tw ) ? { text : textOf ( tw ) } : { } ) ,
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
. . . cellOf ( op ) ,
}
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
} else if ( op . op === "add_container" ) {
const tw = twOf ( op . class )
const grow = op . grow ? ? tw ? . grow
const align = op . align ? ? tw ? . align
const justify = op . justify ? ? tw ? . justify
const alignItems = op . alignItems ? ? tw ? . alignItems
const maxW = op . maxW ? ? tw ? . maxW
const pad = op . pad ? ? tw ? . pad
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
node = {
kind : "group" ,
id : op.id ,
gname : op.gname ? ? null ,
label : op.label ,
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
// `dir` is required on the operation, so a class can only confirm
// it. Reading the class first would let `flex-col` silently override
// a declared `dir: "row"`.
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
dir : op.dir ,
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
gap : op.gap ? ? tw ? . gap ? ? 20 ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
children : [ ] ,
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
. . . ( op . role ? { role : op.role } : { } ) ,
. . . ( op . group ? { group : op.group } : { } ) ,
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
. . . ( grow && grow > 0 ? { grow } : { } ) ,
. . . ( align && align !== "center" ? { align } : { } ) ,
. . . ( justify && justify !== "start" ? { justify } : { } ) ,
. . . ( alignItems ? { alignItems } : { } ) ,
. . . ( maxW && maxW > 0 ? { maxW } : { } ) ,
. . . ( tw ? . minW0 ? { minW0 : true } : { } ) ,
. . . ( textOf ( tw ) ? { text : textOf ( tw ) } : { } ) ,
. . . ( pad != null ? { pad : Math.max ( 0 , pad ) } : { } ) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
}
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
} else if ( op . op === "add_grid" )
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
node = {
kind : "grid" ,
id : op.id ,
gname : null ,
label : op.label ,
cols : Math.max ( 1 , op . cols ) ,
gap : op.gap ? ? 14 ,
children : [ ] ,
}
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
else if ( op . op === "add_pool" ) {
if ( op . lanes . length === 0 ) {
errors . push (
` add_pool: " ${ op . id } " needs at least one lane — a swimlane diagram with no roles has nothing to divide ` ,
)
break
}
node = {
kind : "pool" ,
id : op.id ,
label : op.label ,
lanes : op.lanes ,
phases : op.phases ? ? [ ] ,
orientation : op.orientation ? ? "horizontal" ,
gap : op.gap ? ? 40 ,
children : [ ] ,
}
} else if ( op . op === "add_sequence" )
node = {
kind : "sequence" ,
id : op.id ,
label : op.label ,
gap : op.gap ? ? 60 ,
step : Math.max ( 24 , op . step ? ? 44 ) ,
children : [ ] ,
}
else
node = {
kind : "radial" ,
id : op.id ,
label : op.label ,
spread : op.spread ? ? "radial" ,
gap : op.gap ? ? 40 ,
children : [ ] ,
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
insert ( list , node , op . after )
break
}
case "remove" : {
const node = findNode ( tree , op . id )
if ( ! node ) {
errors . push ( ` remove: no node with id " ${ op . id } " ` )
break
}
// Collect the subtree's ids first — edges touching any of them go too,
// otherwise draw.io renders an arrow pointing at nothing.
const doomed = new Set < string > ( )
for ( const d of walkTree ( { . . . tree , roots : [ node ] } ) )
doomed . add ( d . id )
detach ( tree , op . id )
tree . links = tree . links . filter (
( l ) = > ! doomed . has ( l . source ) && ! doomed . has ( l . target ) ,
)
break
}
case "move" : {
if ( ! exists ( op . id ) ) {
errors . push ( ` move: no node with id " ${ op . id } " ` )
break
}
if ( op . parent && ! exists ( op . parent ) ) {
errors . push ( ` move: no node with id " ${ op . parent } " ` )
break
}
if ( op . parent && wouldCycle ( tree , op . id , op . parent ) ) {
errors . push (
` move: cannot move " ${ op . id } " into " ${ op . parent } " — that is inside itself ` ,
)
break
}
const list = targetList ( tree , op . parent )
if ( typeof list === "string" ) {
errors . push ( ` move: ${ list } ` )
break
}
const node = detach ( tree , op . id )
if ( ! node ) {
errors . push ( ` move: could not detach " ${ op . id } " ` )
break
}
insert ( list , node , op . after )
break
}
case "set_label" : {
const node = findNode ( tree , op . id )
if ( ! node ) {
errors . push ( ` set_label: no node with id " ${ op . id } " ` )
break
}
if ( node . kind === "title" ) {
errors . push (
` set_label: use set_title to change the page title ` ,
)
break
}
node . label = op . label
break
}
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
case "set_shape" : {
const node = findNode ( tree , op . id )
if ( ! node || node . kind !== "box" ) {
errors . push ( ` set_shape: " ${ op . id } " is not a box ` )
break
}
// The verbatim style is last render's composition with the OLD shape
// baked in; keeping it would override the new declaration entirely.
if ( op . shape === "box" ) delete node . shape
else node . shape = op . shape
delete node . style
delete node . w
delete node . h
break
}
case "set_role" : {
const node = findNode ( tree , op . id )
if ( ! node || ( node . kind !== "box" && node . kind !== "group" ) ) {
errors . push (
` set_role: " ${ op . id } " is not a box or container ` ,
)
break
}
if ( op . role === "body" ) delete node . role
else node . role = op . role
delete node . style
break
}
case "set_group" : {
const node = findNode ( tree , op . id )
if ( ! node || ( node . kind !== "box" && node . kind !== "group" ) ) {
errors . push (
` set_group: " ${ op . id } " is not a box or container ` ,
)
break
}
if ( op . group === "" ) delete node . group
else node . group = op . group
delete node . style
break
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
case "set_dir" : {
const node = findNode ( tree , op . id )
if ( ! node || ! isContainer ( node ) ) {
errors . push ( ` set_dir: " ${ op . id } " is not a container ` )
break
}
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
if ( node . kind !== "group" ) {
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
// A grid, pool, sequence or radial container arranges its children by its
// own rule; "row or column" is not a property they have.
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
errors . push (
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
node . kind === "grid"
? ` set_dir: " ${ op . id } " is a grid — change its column count instead `
: ` set_dir: " ${ op . id } " is a ${ node . kind } , which arranges its children by its own rule and has no row/column direction ` ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
break
}
node . dir = op . dir
break
}
case "set_gap" : {
const node = findNode ( tree , op . id )
if ( ! node || ! isContainer ( node ) ) {
errors . push ( ` set_gap: " ${ op . id } " is not a container ` )
break
}
; ( node as ContainerNode ) . gap = Math . max ( 0 , op . gap )
break
}
case "link" : {
if ( ! exists ( op . source ) ) {
errors . push ( ` link: no node with id " ${ op . source } " ` )
break
}
if ( ! exists ( op . target ) ) {
errors . push ( ` link: no node with id " ${ op . target } " ` )
break
}
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
// A second arrow between the same pair WITHOUT an id is a mistake — two
// identical lines on top of each other. With an id it is a parallel
// relationship (an ER diagram's "places" and "cancels" between the same
// two entities), addressable separately. Sequence messages are exempt
// as before: their identity is the step, not the endpoints.
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 conversation = sameSequence ( tree , op . source , op . target )
const dup =
! conversation &&
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
! op . id &&
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
tree . links . some (
( l ) = > l . source === op . source && l . target === op . target ,
)
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
if ( dup ) {
errors . push (
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
` link: " ${ op . source } " → " ${ op . target } " already exists — give this one an id to draw a second, parallel relationship ` ,
)
break
}
if ( op . id && tree . links . some ( ( l ) = > l . id === op . id ) ) {
errors . push ( ` link: edge id " ${ op . id } " is already taken ` )
break
}
// Arrowhead tokens reach the style string; the same charset gate as
// shapes keeps `block;dashed=1` from smuggling style keys in.
const badHead = [ op . head , op . tail ] . find (
( v ) = > v !== undefined && ! /^[a-zA-Z0-9]+$/ . test ( v ) ,
)
if ( badHead !== undefined ) {
errors . push (
` link: arrowhead " ${ badHead } " contains characters that are not allowed ` ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
break
}
const link : LinkSpec = { source : op.source , target : op.target }
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
if ( op . id ) link . id = op . id
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
if ( op . label ) link . label = op . label
if ( op . dashed ) link . dashed = true
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
if ( op . bold ) link . bold = true
if ( op . head !== undefined ) {
link . head = op . head
link . headFill = op . headFill ? ? false
}
if ( op . tail !== undefined ) {
link . tail = op . tail
link . tailFill = op . tailFill ? ? false
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
if ( op . step != null ) link . step = op . step
tree . links . push ( link )
break
}
case "unlink" : {
const before = tree . links . length
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
// With a step given, remove only that message: two participants of a sequence
// diagram can exchange several, and dropping all of them would delete messages
// the caller did not ask about.
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
tree . links = tree . links . filter (
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
( l ) = >
! (
l . source === op . source &&
l . target === op . target &&
( op . step == null || l . step === op . step )
) ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
if ( tree . links . length === before )
errors . push (
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
op . step == null
? ` unlink: no edge from " ${ op . source } " to " ${ op . target } " `
: ` unlink: no edge from " ${ op . source } " to " ${ op . target } " with step ${ op . step } ` ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
break
}
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
case "clear" : {
// Start over. Needed because some diagrams are rebuilt rather than
// patched: in a flowchart one new arrow can change which row several
// nodes belong in, so there is no meaningful way to merge a new graph
// into the old layout.
//
// `foreign` goes too. Those are cells the parser could not place in the
// tree, and keeping them would leave a user's stray annotations floating
// over a diagram they no longer refer to.
tree . roots = [ ]
tree . links = [ ]
tree . foreign = [ ]
if ( ! op . keepTitle ) tree . title = undefined
break
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
case "set_title" :
tree . title = op . title
break
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
case "set_page" :
// Clamped rather than rejected: an out-of-range ratio is a slip, and a
// diagram 40 times wider than it is tall is never what was meant.
tree . aspect = Math . min ( 4 , Math . max ( 0.25 , op . aspect ) )
break
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
}
}
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
// Names the whole supported vocabulary, not just the group the dropped class looked like
// it belonged to: a model that reached for `pt-8` needs to see that padding is `p-N` only,
// and one that reached for `shadow-2xl` needs the four rungs that do exist.
if ( ignoredClasses . size > 0 )
warnings . push (
` Ignored class(es) with no equivalent here: ${ [ . . . ignoredClasses ] . join ( ", " ) } . Supported — layout: flex-row/flex-col, grow/grow-N/flex-N, w-1/N, w-full, min-w-0, items-*, self-*, justify-*, gap-N, p-N, max-w-N/max-w-xs..4xl. Text: font-bold/font-normal, italic, underline, line-through, text-xs..4xl, text-left/center/right, align-top/middle/bottom, whitespace-nowrap. Border: border/border-N, border-solid/dashed/dotted, border-none, rounded/rounded-sm..4xl/rounded-full, shadow-sm/md/lg/xl/shadow-none. Colour comes from role and group; per-side borders and padding (border-l, pt-4) are not available. ` ,
)
return { tree , errors , warnings }
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
}
/** Every icon/group name in a tree, for validating against the catalog. */
export function collectNames (
tree : DiagramTree ,
) : { id : string ; name : string ; kind : "icon" | "group" } [ ] {
const out : { id : string ; name : string ; kind : "icon" | "group" } [ ] = [ ]
for ( const n of walkTree ( tree ) ) {
if ( n . kind === "icon" && n . name )
out . push ( { id : n.id , name : n.name , kind : "icon" } )
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
else if ( ( n . kind === "group" || n . kind === "grid" ) && n . gname )
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
out . push ( { id : n.id , name : n.gname , kind : "group" } )
}
return out
}
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
/** How a container arranges its children, in one short phrase for the outline. */
function containerMeta ( n : ContainerNode ) : string {
switch ( n . kind ) {
case "grid" :
return ` grid cols= ${ n . cols } `
case "pool" :
return ` pool lanes=[ ${ n . lanes . join ( " | " ) } ] ${
n . phases . length ? ` phases=[ ${ n . phases . join ( " | " ) } ] ` : ""
} $ { n . orientation === "vertical" ? " vertical" : "" } `
case "sequence" :
return "sequence"
case "radial" :
return ` radial ${ n . spread } `
default :
return n . dir
}
}
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
/ * *
* A compact text outline of the tree , for showing the model what is on the canvas .
*
* Sending the tree as JSON would cost several times more for the same information , and
* the model does not need coordinates — it needs to know what exists and how it nests so
* it can name ids in the next operation .
* /
export function outline ( tree : DiagramTree ) : string {
const lines : string [ ] = [ ]
if ( tree . title ) lines . push ( ` title: ${ tree . title } ` )
const walk = ( n : DiagramNode , depth : number ) = > {
const pad = " " . repeat ( depth )
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
// A node inside a pool reports its cell: that is how the model knows which lane a
// step ended up in, which is exactly what it needs to move one.
const at = ( x : DiagramNode ) = >
( x . kind === "icon" || x . kind === "box" ) && x . cell
? ` @lane ${ x . cell . lane } ,col ${ x . cell . col } `
: ""
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
if ( n . kind === "icon" )
lines . push (
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
` ${ pad } ${ n . id } : icon ${ n . name } ${ n . label ? ` " ${ n . label } " ` : "" } ${ at ( n ) } ` ,
)
else if ( n . kind === "box" )
lines . push (
` ${ pad } ${ n . id } : box ${ n . shape ? ` ${ n . shape } ` : "" } " ${ n . label } " ${ at ( n ) } ` ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
else if ( n . kind === "title" ) lines . push ( ` ${ pad } ${ n . id } : title ` )
else {
lines . push (
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
` ${ pad } ${ n . id } : ${ containerMeta ( n ) } ${ n . label ? ` " ${ n . label } " ` : " (wrapper)" } ` ,
feat(diagram-engine): wire up restructure_diagram + stencil catalog
Closes the loop: the model can now build and edit AWS architecture diagrams by
declaring structure, and never writes an mxCell again.
catalog.ts — 983 AWS icon and 19 group stencils as a name→style map, generated from
drawio-ai-kit's catalog (itself generated from jgraph's draw.io shape index). Styles
are verbatim, so the official category colours, connection points and aspect=fixed
come along for free and nothing is hand-assembled. An invented name is rejected with
suggestions instead of rendering as a blank square, which is what draw.io does with
an unknown resIcon today.
operations.ts — what the model actually sends: add_icon / add_container / move /
link / set_dir and so on, applied in order against the tree. Guards the things that
break a diagram quietly: duplicate ids (draw.io drops one of the two cells), edges
left pointing at a removed node, and moving a container inside itself.
index.ts — the entry point. current XML → parse → apply ops → check names → layout →
render → new XML. The tree is not stored between calls; it is re-derived from the
canvas every time, so a user's manual edits are input to the next layout rather than
state to reconcile.
Token cost, measured with Claude's tokenizer rather than estimated:
- build a VPC diagram: 515 tok as operations vs 3180 as XML (6.2x)
- add one icon: 27 tok as an operation vs 3823 re-emitting (142x)
- read current state: 216 tok as an outline vs 3180 as XML (14.7x)
The 142x is the one that matters day to day: "add a Redis" is one operation, not a
rewrite of the whole diagram.
Routing in the system prompt sends AWS architecture through this path and leaves
flowcharts, BPMN, sequence diagrams, mind maps and Azure/GCP on display_diagram —
the layout engine's primitives (nested rows, columns, grids) do not model a sequence
diagram's lifelines or a mind map's radial spread, and pretending otherwise would
make those worse rather than better.
Also: added a NOTICE recording the MIT port and the AWS Architecture Icons terms,
and a narrow .gitignore exception so the generated catalog is tracked while the
root data/ directory (admin settings, contains secrets) stays ignored.
403 unit tests + 3 new e2e. Verified in the real app: a structural tool call renders
with real stencils and container markers; a second call adds one node and keeps
everything from the first; an invented name is refused and nothing is drawn. The 13
existing diagram e2e tests still pass.
2026-08-09 12:13:54 +09:00
)
for ( const c of n . children ) walk ( c , depth + 1 )
}
}
for ( const r of tree . roots ) walk ( r , 0 )
for ( const l of tree . links ) {
const bits = [ l . label , l . dashed ? "dashed" : null ]
. filter ( Boolean )
. join ( ", " )
lines . push ( ` link ${ l . source } -> ${ l . target } ${ bits ? ` ( ${ bits } ) ` : "" } ` )
}
if ( tree . foreign . length )
lines . push (
` ${ tree . foreign . length } cell(s) kept as-is: ${ tree . foreign . map ( ( f ) = > f . id ) . join ( ", " ) } ` ,
)
return lines . join ( "\n" )
}