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.
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.
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.
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.
"Generate a diagram to illustrate the git operation" still produced
hand-written XML. Root cause: the very first working instruction in the
system prompt said "then use display_diagram tool to generate the XML" —
unconditionally. The tool-routing rules that divide by layout shape only
appear 70 lines later, so the earlier, more actionable instruction won.
That line predates draw_graph and restructure_diagram, from when
display_diagram was the only drawing tool.
- The opening instruction now says to pick the tool by layout shape, and
names the engine tools as the default with display_diagram as the
exception.
- The identity line no longer describes the job as "precise XML
specifications".
- draw_graph's examples (prompt and tool description) now include
git/branching workflows and the "illustrate how X works" phrasing.
- "Core capabilities" and "Layout constraints" are scoped to
display_diagram — they read as instructions to hand-position everything.
- The edit_diagram error-recovery note no longer funnels back to
display_diagram for restructuring.
- display_diagram's own tool description now states it is the exception
and points to draw_graph / restructure_diagram.
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.
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.
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.
* feat: add Material Design Icons shape library (#685)
Add Google Material Design Icons as a new shape library using Google's
CDN. Includes top 300 most popular icons by usage, and updates system
prompts to guide the AI to call get_shape_library before using any icon
library.
* fix: align get_shape_library guidance for non-cloud icon libraries
* feat(prompt): add language-aware response rules with english fallback
Added language handling rules for user interactions.
* refactor(prompt): simplify language matching instruction
* fix(edit_diagram): implement cascade delete for children and edges
- Add automatic cascade deletion when deleting a cell
- Recursively delete all child cells (parent attribute references)
- Delete all edges referencing deleted cells (source/target)
- Skip silently if cell already deleted (handles AI redundant ops)
- Update prompts to inform AI about cascade behavior
Fixes#450
* fix: add root cell protection and sync MCP server cascade delete
- Add protection for root cells '0' and '1' to prevent full diagram wipe
- Sync MCP server with main app's cascade delete logic
- Both lib/utils.ts and packages/mcp-server now have identical delete behavior
* chore(mcp): bump version to 0.1.9
* fix(cascade-delete): recursively collect edge children (labels)
- Change from cellsToDelete.add(edgeId) to collectDescendants(edgeId)
- Fixes orphaned edge labels causing draw.io to crash/clear canvas
- Edge labels (parent=edgeId) are now deleted with their parent edge
Fixes#374 - Models were confused by the `type` field name and sent
`operation` instead. This change:
- Renames DiagramOperation.type to DiagramOperation.operation across
all files (MCP server, web app, hooks, components, system prompts)
- Adds JSON examples in tool descriptions to show correct format
- Updates all test data to use the new field name
Affected files:
- lib/utils.ts
- app/api/chat/route.ts
- hooks/use-diagram-tool-handlers.ts
- components/chat-message-display.tsx
- lib/system-prompts.ts
- packages/mcp-server/src/diagram-operations.ts
- packages/mcp-server/src/index.ts
- scripts/test-diagram-operations.mjs
MCP server version bumped to 0.1.6
* feat: add get_shape_library tool for AI icon discovery
- Add server-side tool that returns shape library documentation
- AI can fetch icon/shape names on-demand before generating diagrams
- Includes path traversal protection and input sanitization
- Library index embedded in tool description for discoverability
- Supports 33 libraries: AWS, Azure, GCP, Kubernetes, Cisco, etc.
* fix: improve get_shape_library error handling and imports
- Move fs/path imports to top of file (avoid dynamic imports per call)
- Distinguish file-not-found vs other errors in catch block
- Include invalid input in validation error message
- Log unexpected errors for debugging
* docs: add get_shape_library to system prompt tool list
- Add Tool4 (get_shape_library) to available tools section
- Add usage guidance in 'Choose the right tool' section
- Update AWS icons note to reference get_shape_library for icon discovery
* fix: display get_shape_library tool output in chat UI
* fix: correct state check for get_shape_library output display
* fix: make get_shape_library output respect fold state
* style: auto-format with Biome
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* refactor: replace text-based edit_diagram with ID-based operations
- Add applyDiagramOperations() function using DOMParser for ID lookup
- New schema: operations array with type (update/add/delete), cell_id, new_xml
- Update chat-panel.tsx handler for new operations format
- Update OperationsDisplay component to show operation type and cell_id
- Simplify system prompts with new ID-based examples
- Add ID validation for add operations
- Add warning for edges referencing deleted cells
* fix: add ID validation to update operation and remove dead code
- Add ID mismatch validation to update operation (consistency with add)
- Remove orphaned replaceXMLParts function (~300 lines of dead code)
- Update cell_id schema description for clarity
- Add unit tests for applyDiagramOperations (11 tests)
* feat: add minimal style mode toggle for faster diagram generation
- Add Minimal/Styled toggle switch in chat input UI
- When enabled, removes color/style instructions from system prompt
- Faster generation with plain black/white diagrams
- Improves XML auto-fix: handle foreign tags, extra closing tags, trailing garbage
- Fix isMxCellXmlComplete to strip Anthropic function-calling wrappers
- Add debug logging for truncation detection diagnosis
* fix: prevent false XML parse errors during streaming
- Escape unescaped & characters in convertToLegalXml() before DOMParser validation
- Only log console.error for final output, not during streaming updates
- Prevents Next.js dev mode error overlay from showing for expected streaming states
* refactor: simplify LLM XML format to output bare mxCells only
- Update wrapWithMxFile() to always add root cells (id=0, id=1) automatically
- LLM now generates only mxCell elements starting from id=2 (no wrapper tags)
- Update system prompts and tool descriptions with new format instructions
- Update cached responses to remove root cells and wrapper tags
- Update truncation detection to check for complete mxCell endings
- Update documentation in xml_guide.md
* fix: address PR review issues for XML format refactor
- Fix critical bug: inconsistent truncation check using old </root> pattern
- Fix stale error message referencing </root> tag
- Add isMxCellXmlComplete() helper for consistent truncation detection
- Improve regex patterns to handle any attribute order in root cells
- Update wrapWithMxFile JSDoc to document root cell removal behavior
* fix: handle non-self-closing root cells in wrapWithMxFile regex
* feat: add append_diagram tool for truncation continuation
When LLM output hits maxOutputTokens mid-generation, instead of
failing with an error loop, the system now:
1. Detects truncation (missing </root> in XML)
2. Stores partial XML and tells LLM to use new append_diagram tool
3. LLM continues generating from where it stopped
4. Fragments are accumulated until XML is complete
5. Server limits to 5 steps via stepCountIs(5)
Key changes:
- Add append_diagram tool definition in route.ts
- Add append_diagram handler in chat-panel.tsx
- Track continuation mode separately from error mode
- Continuation mode has unlimited retries (not counted against limit)
- Error mode still limited to MAX_AUTO_RETRY_COUNT (1)
- Update system prompts to document append_diagram tool
* fix: show friendly message and yellow badge for truncated output
- Add yellow 'Truncated' badge in UI instead of red 'Error' when XML is incomplete
- Show friendly error message for toolUse.input is invalid errors
- Built on top of append_diagram continuation feature
* refactor: remove debug logs and simplify truncation state
- Remove all debug console.log statements
- Remove isContinuationModeRef, derive from partialXmlRef.current.length > 0
* docs: fix append_diagram instructions for consistency
- Change 'Do NOT include' to 'Do NOT start with' (clearer intent)
- Add <mxCell id="0"> to prohibited start patterns
- Change 'closing tags </root></mxGraphModel>' to just '</root>' (wrapWithMxFile handles the rest)
- Add client-side PDF text extraction using unpdf library
- Support text files (.txt, .md, .json, .csv, .py, .js, .ts, etc.)
- Add file preview with character count for PDF/text files
- Add 150k character limit for extracted content
- Highlight Paper to Diagram example with NEW badge
- Fix React hydration error by adding explicit IDs to ResizablePanelGroup
- Remove code duplication by centralizing file utilities in pdf-utils.ts
- Add Biome as formatter and linter (replaces Prettier)
- Configure Husky + lint-staged for pre-commit hooks
- Add VS Code settings for format on save
- Ignore components/ui/ (shadcn generated code)
- Remove semicolons, use 4-space indent
- Reformat all files to new style
- Add App Context section describing the left/right panel layout
- Add App Features section with icon locations (history, theme, upload, export, clear)
- Dynamically inject model name into system prompt via {{MODEL_NAME}} placeholder
- Expand edit_diagram tool description with usage guidelines
- Extract system prompts to dedicated lib/system-prompts.ts module
- Add extended system prompt (~4000 tokens) for models with higher cache minimums (Opus 4.5, Haiku 4.5)
- Clean up debug logs while preserving informational and cache-related logs
- Improve code formatting and organization in chat route