feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
import { describe, expect, it } from "vitest"
|
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 { restructureDiagram } from "@/lib/diagram-engine"
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
import {
|
|
|
|
|
absoluteRects,
|
|
|
|
|
escapesParent,
|
|
|
|
|
outsidePage,
|
|
|
|
|
overlaps,
|
|
|
|
|
rectOf,
|
|
|
|
|
} from "./fixtures/geometry"
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Roles: a node says WHAT IT IS and the engine's theme decides how that looks.
|
|
|
|
|
*
|
|
|
|
|
* This is what lets one engine cover poster-style content — paper summaries, cheat
|
|
|
|
|
* sheets — that previously had to be hand-written XML with no layout guarantees. The
|
|
|
|
|
* model judges "this is a heading, this is a warning"; the theme guarantees the same
|
|
|
|
|
* role always renders the same way.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const styleOf = (xml: string, id: string): string =>
|
|
|
|
|
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))?.[1] ?? ""
|
|
|
|
|
const last = (style: string, key: string): string | undefined =>
|
|
|
|
|
[...style.matchAll(new RegExp(`${key}=([^;]*)`, "g"))].pop()?.[1]
|
|
|
|
|
|
|
|
|
|
describe("roles", () => {
|
|
|
|
|
it("styles each role from the theme, and measures type at its real size", () => {
|
|
|
|
|
const r = restructureDiagram("", [
|
|
|
|
|
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "mast",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "Title",
|
|
|
|
|
role: "banner",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "note",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "fine print",
|
|
|
|
|
role: "muted",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "num",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "18% -> 57%",
|
|
|
|
|
role: "metric",
|
|
|
|
|
},
|
|
|
|
|
{ op: "add_box", id: "plain", parent: "page", label: "ordinary" },
|
|
|
|
|
])
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
expect(last(styleOf(xml, "mast"), "fontSize")).toBe("20")
|
|
|
|
|
expect(last(styleOf(xml, "mast"), "fillColor")).toBe("#1A237E")
|
|
|
|
|
expect(last(styleOf(xml, "note"), "fontSize")).toBe("9")
|
|
|
|
|
expect(last(styleOf(xml, "num"), "fontSize")).toBe("20")
|
|
|
|
|
// The default look is unchanged: the fallback's own 11px, no theme tokens.
|
|
|
|
|
expect(last(styleOf(xml, "plain"), "fontSize")).toBe("11")
|
|
|
|
|
expect(styleOf(xml, "plain")).not.toContain("dai_role")
|
|
|
|
|
|
|
|
|
|
// Layout reserved room for the larger type: the banner's cell is taller than a
|
|
|
|
|
// plain box, or its 20px line would overflow.
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
expect(rectOf(rects, "mast").h).toBeGreaterThan(
|
|
|
|
|
rectOf(rects, "plain").h,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("a banner spans its column; a heading spans its section", () => {
|
|
|
|
|
const r = restructureDiagram("", [
|
|
|
|
|
{ op: "add_container", id: "page", label: "", dir: "col", gap: 12 },
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "mast",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "T",
|
|
|
|
|
role: "banner",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "wide",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 12,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "a",
|
|
|
|
|
parent: "wide",
|
|
|
|
|
label: "left column content here",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "b",
|
|
|
|
|
parent: "wide",
|
|
|
|
|
label: "right column content here",
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
const rects = absoluteRects(r.xml as string)
|
|
|
|
|
const mast = rectOf(rects, "mast")
|
|
|
|
|
const wide = rectOf(rects, "wide")
|
|
|
|
|
// The masthead fills the page column's width, not just its own text width.
|
|
|
|
|
expect(mast.w).toBeGreaterThanOrEqual(wide.w - 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("roles survive the round trip", () => {
|
|
|
|
|
const r = restructureDiagram("", [
|
|
|
|
|
{ op: "add_box", id: "m", label: "Title", role: "banner" },
|
|
|
|
|
])
|
|
|
|
|
const again = restructureDiagram(r.xml as string, [])
|
|
|
|
|
expect(styleOf(again.xml as string, "m")).toContain("dai_role=banner")
|
|
|
|
|
expect(last(styleOf(again.xml as string, "m"), "fontSize")).toBe("20")
|
|
|
|
|
// Fixed point: a third pass matches the second.
|
|
|
|
|
const third = restructureDiagram(again.xml as string, [])
|
|
|
|
|
expect(third.xml).toBe(again.xml)
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
it("add_graph nodes accept roles too", () => {
|
|
|
|
|
const r = restructureDiagram("", [
|
|
|
|
|
{
|
|
|
|
|
op: "add_graph",
|
|
|
|
|
id: "g",
|
|
|
|
|
nodes: [
|
|
|
|
|
{ id: "t", label: "Pipeline", role: "heading" },
|
|
|
|
|
{ id: "a", label: "Build" },
|
|
|
|
|
{ id: "warn", label: "Flaky stage", role: "bad" },
|
|
|
|
|
],
|
|
|
|
|
edges: [
|
|
|
|
|
{ source: "t", target: "a" },
|
|
|
|
|
{ source: "a", target: "warn" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
])
|
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
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
expect(last(styleOf(r.xml as string, "warn"), "fillColor")).toBe(
|
|
|
|
|
"#F8CECC",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("a full poster lays out with no sibling overlaps and nothing outside the page", () => {
|
|
|
|
|
const r = restructureDiagram("", [
|
|
|
|
|
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "mast",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "Chain-of-Thought Prompting",
|
|
|
|
|
role: "banner",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "byline",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "Wei et al. | NeurIPS 2022",
|
|
|
|
|
role: "muted",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "cols",
|
|
|
|
|
parent: "page",
|
|
|
|
|
label: "",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 20,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "c1",
|
|
|
|
|
parent: "cols",
|
|
|
|
|
label: "",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 14,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "c2",
|
|
|
|
|
parent: "cols",
|
|
|
|
|
label: "",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 14,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "core",
|
|
|
|
|
parent: "c1",
|
|
|
|
|
label: "Core Idea",
|
|
|
|
|
role: "heading",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 8,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "def",
|
|
|
|
|
parent: "core",
|
|
|
|
|
label: "CoT = intermediate reasoning steps",
|
|
|
|
|
role: "callout",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "vs",
|
|
|
|
|
parent: "c1",
|
|
|
|
|
label: "Standard vs CoT",
|
|
|
|
|
role: "heading",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 8,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "std",
|
|
|
|
|
parent: "vs",
|
|
|
|
|
label: "Often wrong",
|
|
|
|
|
role: "bad",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "cot",
|
|
|
|
|
parent: "vs",
|
|
|
|
|
label: "Correct",
|
|
|
|
|
role: "good",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_container",
|
|
|
|
|
id: "res",
|
|
|
|
|
parent: "c2",
|
|
|
|
|
label: "Key Results",
|
|
|
|
|
role: "heading",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 8,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
op: "add_box",
|
|
|
|
|
id: "m1",
|
|
|
|
|
parent: "res",
|
|
|
|
|
label: "GSM8K: 18% -> 57%",
|
|
|
|
|
role: "metric",
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
expect(r.errors).toEqual([])
|
|
|
|
|
const xml = r.xml as string
|
|
|
|
|
const rects = absoluteRects(xml)
|
|
|
|
|
// Overlaps are judged among siblings — containment of children is by design.
|
|
|
|
|
const parentOfId = new Map<string, string>()
|
|
|
|
|
for (const m of xml.matchAll(
|
|
|
|
|
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
|
|
|
|
|
))
|
|
|
|
|
parentOfId.set(m[1], m[2])
|
|
|
|
|
const byParent = new Map<string, string[]>()
|
|
|
|
|
for (const [id, p] of parentOfId) {
|
|
|
|
|
if (id.startsWith("__")) continue
|
|
|
|
|
const l = byParent.get(p) ?? []
|
|
|
|
|
l.push(id)
|
|
|
|
|
byParent.set(p, l)
|
|
|
|
|
}
|
|
|
|
|
for (const sibs of byParent.values())
|
|
|
|
|
expect(overlaps(rects, sibs)).toEqual([])
|
|
|
|
|
expect(outsidePage(xml)).toEqual([])
|
|
|
|
|
expect(escapesParent(xml)).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
})
|