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.
This commit is contained in:
dayuan.jiang
2026-08-09 20:55:12 +09:00
parent e78322ca52
commit db9db1ff4e
9 changed files with 432 additions and 23 deletions

View File

@@ -704,7 +704,9 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
PREFER THIS over display_diagram/edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
For a POSTER (paper summary, cheat sheet): one col container as the page; a banner box as the masthead (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container holding 2-4 col containers as columns; each section is a container with role "heading" holding its items. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print.
The layout model is FLEXBOX. row/col containers nest freely; a box with internal structure is an invisible col container (pad 10-14) holding smaller boxes. Three knobs: grow (columns split leftover width by weight — grow 3 / grow 2 gives a 3:2 page), align "stretch" (child fills the cross axis — headings, bars and body boxes should almost always stretch or the column looks ragged), pad (8-14 tight card, default 24 roomy section). Labels take inline HTML — <b>, <i>, <font color="#...">, <br> — so one box carries a bold keyword, a second paragraph, a coloured verdict line. Emoji in headings (💡 Core Idea) read instantly.
For a POSTER (paper summary, cheat sheet): one col container as the page; a banner box as the masthead with align stretch (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container holding 2-4 col containers with grow weights as columns; each section a heading-role box + content boxes, all align stretch. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col gap=8 pad=12 grow=1 role=bad, then a bold title box, the body text, a role=bad answer bar (all align stretch), and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with align start.
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.

View File

@@ -43,6 +43,23 @@ import { isContainer } from "./types"
export const ICON_SIZE = 48
/** Interior padding of a container. */
const PAD = 24
/** A group's interior padding: its own `pad` when declared, the default otherwise. */
function padOf(n: ContainerNode): number {
return n.kind === "group" && n.pad != null ? Math.max(0, n.pad) : PAD
}
/** The flex-grow weight a node declared, 0 when none. */
function growOf(n: DiagramNode): number {
const g = (n.kind === "box" || n.kind === "group") && n.grow
return typeof g === "number" && g > 0 ? g : 0
}
/** The cross-axis alignment a node declared. */
function alignOf(n: DiagramNode): "start" | "center" | "end" | "stretch" {
const a = (n.kind === "box" || n.kind === "group") && n.align
return a === "start" || a === "end" || a === "stretch" ? a : "center"
}
/** Height of a container's title strip. Zero when it has no label — an empty strip
* reads as a dead band at the top of the frame. */
const HEADER = 36
@@ -172,6 +189,20 @@ export interface Placed {
*/
export type LayoutLinks = { source: string; target: string; step?: number }[]
/**
* Reduce a label to the text draw.io will actually lay out.
*
* Labels may carry inline HTML (every style has `html=1`): a <br> is a line break, any
* other tag is invisible markup around visible text. Measuring the raw string counted
* `<font color="#B85450">` as thirty characters of text, making rich boxes twice as
* wide as their content.
*/
function visibleText(label: string): string {
return String(label ?? "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/?(?:b|i|u|s|sub|sup|font|span|div)(?:\s[^<>]*)?>/gi, "")
}
/**
* Intrinsic size of a text box: widest wrapped line by line count.
*
@@ -184,7 +215,7 @@ export function autoBoxSize(
): { w: number; h: number } {
const r = roleMetrics(role)
const maxW = Math.round(260 * Math.max(1, r.charScale))
const explicit = String(label ?? "").split("\n")
const explicit = visibleText(label).split("\n")
const longest = Math.max(1, ...explicit.map((l) => l.length))
const w = Math.min(
maxW,
@@ -531,6 +562,7 @@ function measure(
}
// group: row or col
const pad = padOf(n)
if (n.dir === "row") {
const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
// Only a group stretches to match its siblings. A leaf keeps its natural size,
@@ -541,13 +573,13 @@ function measure(
for (const k of kids)
if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
const w =
PAD * 2 +
pad * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
gap * Math.max(0, kids.length - 1)
const h = head + PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.h))
const h = head + pad * 2 + Math.max(0, ...kids.map((k) => k.rect.h))
return {
node: n,
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h },
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
children: kids,
}
}
@@ -556,15 +588,15 @@ function measure(
// Only a group stretches — same reasoning as the row branch above.
for (const k of kids)
if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest)
const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
const w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
const h =
head +
PAD * 2 +
pad * 2 +
kids.reduce((s, k) => s + k.rect.h, 0) +
gap * Math.max(0, kids.length - 1)
return {
node: n,
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h },
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
children: kids,
}
}
@@ -585,10 +617,11 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
if (!isContainer(n)) return
const head = headerFor(n)
const innerX = p.rect.x + PAD
const innerTop = p.rect.y + head + PAD
const innerW = p.rect.w - PAD * 2
const innerH = p.rect.h - head - PAD * 2
const pad = n.kind === "group" ? padOf(n) : PAD
const innerX = p.rect.x + pad
const innerTop = p.rect.y + head + pad
const innerW = p.rect.w - pad * 2
const innerH = p.rect.h - head - pad * 2
const kids = p.children
if (n.kind === "grid") {
@@ -654,9 +687,27 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
const content = sizes.reduce((s, v) => s + v, 0)
const extent = alongRow ? innerW : innerH
const k = kids.length
const slack = Math.max(0, extent - content - n.gap * (k - 1))
let slack = Math.max(0, extent - content - n.gap * (k - 1))
// flex-grow: children with a weight split the leftover space between them, TeX's
// glue. This runs before the gap stretch below — declared weights are a statement
// about where the slack should go, and padding it into the gaps instead would
// silently override that statement.
const weights = kids.map((kid) => growOf(kid.node))
const totalWeight = weights.reduce((s, v) => s + v, 0)
if (totalWeight > 0 && slack > 0) {
kids.forEach((kid, i) => {
const extra = (slack * weights[i]) / totalWeight
if (alongRow) kid.rect.w += extra
else kid.rect.h += extra
})
slack = 0
}
const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
const span = content + gap * Math.max(0, k - 1)
const span =
kids.reduce((s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h), 0) +
gap * Math.max(0, k - 1)
let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2)
for (const kid of kids) {
@@ -664,15 +715,23 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
// heading spans its column. Measured at its text width, then widened here — the
// container's size still comes from the widest ordinary child.
const kn = kid.node
const a = alignOf(kn)
const stretches =
kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch
a === "stretch" ||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch)
// Cross-axis position: centred unless the child asked for an edge.
const cross = (room: number, size: number): number => {
if (a === "start") return 0
if (a === "end") return Math.max(0, room - size)
return (room - size) / 2
}
if (alongRow) {
if (stretches) kid.rect.h = innerH
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
place(kid, cur, innerTop + cross(innerH, kid.rect.h), links)
cur += kid.rect.w + gap
} else {
if (stretches) kid.rect.w = innerW
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links)
place(kid, innerX + cross(innerW, kid.rect.w), cur, links)
cur += kid.rect.h + gap
}
}

View File

@@ -64,6 +64,12 @@ export const MARKER = {
role: "dai_role",
/** The node's semantic zone, whose hue ramp colours it. */
group: "dai_group",
/** Share of the parent's leftover flow-axis space — flex-grow. */
grow: "dai_grow",
/** Cross-axis position within the parent: "start" | "center" | "end". */
align: "dai_align",
/** A container's interior padding, px. */
pad: "dai_pad",
/**
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
* columns, its milestone strip. The parser must not read these back as nodes — they are
@@ -382,6 +388,28 @@ export function stampGroup(style: string, group: string): string {
return append(style, MARKER.group, encodeURIComponent(group))
}
type FlexAlign = "start" | "center" | "end" | "stretch"
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
export function stampFlex(
style: string,
opts: { grow?: number; align?: FlexAlign; pad?: number },
): string {
let s = style
if (opts.grow != null && opts.grow > 0)
s = append(s, MARKER.grow, opts.grow)
if (opts.align && opts.align !== "center")
s = append(s, MARKER.align, opts.align)
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
return s
}
/** Read the align marker back. Anything unrecognised means the default (center). */
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
const v = readMarker(style, MARKER.align)
return v === "start" || v === "end" || v === "stretch" ? v : null
}
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
export function stripMarkers(style: string): string {
return style

View File

@@ -98,6 +98,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
),
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",
),
lane: z
.number()
.optional()
@@ -148,6 +160,24 @@ export const OperationSchema = z.discriminatedUnion("op", [
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
),
gap: z.number().optional(),
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",
),
after: z.string().optional(),
}),
z.object({
@@ -414,6 +444,10 @@ export function applyOperations(
...(op.stroke ? { stroke: op.stroke } : {}),
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...cellOf(op),
}
else if (op.op === "add_container")
@@ -427,6 +461,11 @@ export function applyOperations(
children: [],
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...(op.pad != null ? { pad: Math.max(0, op.pad) } : {}),
}
else if (op.op === "add_grid")
node = {

View File

@@ -29,6 +29,7 @@ import {
isPinned,
MARKER,
type NodeKind,
readAlign,
readCell,
readDir,
readIntMarker,
@@ -360,6 +361,19 @@ function zoneOf(style: string): string | undefined {
return v ? decodeURIComponent(v) : undefined
}
/** The flex fields stamped on a cell, as sparse properties ready to spread. */
function flexOf(style: string): {
grow?: number
align?: "start" | "end" | "stretch"
} {
const grow = readIntMarker(style, MARKER.grow)
const align = readAlign(style)
return {
...(grow !== null && grow > 0 ? { grow } : {}),
...(align ? { align } : {}),
}
}
function boxShape(style: string): BoxShape | undefined {
const shape = styleValue(style, "shape")
if (shape === "parallelogram") return "data"
@@ -1070,6 +1084,7 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
shape: boxShape(c.style),
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
// would re-emit a lifeline that no longer matches the new message count.
style: lifeline ? undefined : c.style,
@@ -1215,12 +1230,15 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
}
return node
}
const markedPad = readIntMarker(c.style, MARKER.pad)
const node: GroupNode = {
kind: "group",
...common,
dir: dir === "col" ? "col" : "row",
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),
...(markedPad !== null ? { pad: markedPad } : {}),
}
return node
}

View File

@@ -25,6 +25,7 @@ import {
isInvisible,
stampCell,
stampContainer,
stampFlex,
stampGroup,
stampLane,
stampLeaf,
@@ -35,7 +36,7 @@ import {
stampSequence,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import { hueOf, NEUTRAL, type Role, themedStyle } from "./theme"
import { hueOf, NEUTRAL, themedStyle } from "./theme"
import {
type BoxShape,
type DiagramNode,
@@ -58,6 +59,14 @@ export function esc(s: string): string {
.replace(/'/g, "&#39;")
}
// NOTE ON RICH TEXT: a label may carry inline HTML — <b>, <i>, <font color>, <br>,
// <span> — and needs no special handling here. `esc()` writes it into the value
// attribute as entities, the XML parser decodes them back, and because every style
// carries `html=1` draw.io renders the tags. That is exactly how hand-written rich
// labels have always worked; the editor sanitises HTML labels itself. The one place
// tags DO need handling is the measure pass (layout.ts autoBoxSize), which must not
// count markup as text.
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
export type StyleResolver = (
name: string,
@@ -177,6 +186,7 @@ function styleFor(
let stamped = stampLeaf(base, "box")
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
if (n.group) stamped = stampGroup(stamped, n.group)
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
return n.cell ? stampCell(stamped, n.cell) : stamped
}
@@ -218,6 +228,8 @@ function styleFor(
}
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
if (zone) base = stampGroup(base, zone)
if (n.kind === "group")
base = stampFlex(base, { grow: n.grow, align: n.align, pad: n.pad })
return stampContainer(base, {
kind: n.kind,
dir: n.kind === "grid" ? "grid" : n.dir,

View File

@@ -25,6 +25,12 @@ export interface PoolCell {
col: number
}
/**
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
* pin to either edge, centre (the default), or stretch to fill the axis.
*/
export type Align = "start" | "center" | "end" | "stretch"
/**
* The outline a flowchart box is drawn with.
*
@@ -73,6 +79,10 @@ export interface BoxNode {
role?: Role
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
group?: string
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string
@@ -109,6 +119,12 @@ export interface GroupNode {
role?: Role
/** Semantic zone name; the panel takes this hue's tint. */
group?: string
/** Share of the parent's leftover flow-axis space, like flex-grow. */
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/** Interior padding, px. Absent = the default (24). */
pad?: number
style?: string
pinned?: boolean
rect?: Rect

View File

@@ -104,11 +104,26 @@ Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed f
This applies to BOTH creating and editing.
Use restructure_diagram ALSO for poster-style layouts — paper summaries, cheat sheets,
infographics, comparison sheets: a col container as the page, a banner box as the masthead
(no set_title — the banner IS the title), a row of col containers as columns, each section
a "heading"-role container with its own group name (sections sharing a group share a hue).
Give boxes roles (callout, good/bad, metric, muted) — roles are the visual hierarchy,
groups are the colour, and the engine guarantees nothing overlaps.
infographics, comparison sheets. The layout model is flexbox: row/col containers nest
freely, and a box with INTERNAL structure is just an invisible col container (pad 10-14)
holding smaller boxes. Three knobs, use them everywhere:
- grow: columns split leftover width by weight (grow 3 / grow 2 makes a 3:2 page).
- align "stretch": a child fills its parent's cross axis — headings, highlight bars and
body boxes should almost always stretch, or the column looks ragged.
- pad: small (8-14) for tight cards, default 24 for roomy sections.
Labels take inline HTML — <b>, <i>, <font color="#1B5E20">, <br> — so one box can hold a
bold keyword, a second paragraph, a coloured verdict line. Emoji in headings (💡 Core Idea)
cost nothing and read instantly.
Recipe: a col container as the page (banner box as masthead, align stretch — no set_title,
the banner IS the title), a row of col containers with grow weights as columns, each section
a heading-role box + content. Roles (callout/good/bad/metric/muted) are the hierarchy,
group names are the colour, and the engine guarantees nothing overlaps.
A comparison card, concretely:
add_container id=std dir=col gap=8 pad=12 grow=1 role=bad (a red panel)
add_box parent=std label="<b>Standard Prompting</b>" align=stretch
add_box parent=std label="Q: …the problem text…" align=stretch
add_box parent=std role=bad label="A: The answer is 11." align=stretch
add_box parent=std label="<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" align=start
Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout
would be wrong rather than merely different:

View File

@@ -0,0 +1,220 @@
import { describe, expect, it } from "vitest"
import { restructureDiagram } from "@/lib/diagram-engine"
import { autoBoxSize } from "@/lib/diagram-engine/layout"
import { parseDiagram } from "@/lib/diagram-engine/parse"
/**
* The flex knobs: grow, align, pad — TeX's glue and CSS's align-items, as node fields.
*
* These are what make nested containers expressive enough for card-like content.
* Graphviz (HTML-like table labels), D2 (grid containers) and TeX (box+glue) all
* converged on the same primitives: nested boxes, proportional space distribution,
* per-cell alignment. Without grow, two columns cannot split a page 2:1; without
* stretch, a verdict bar cannot span its card; without pad, a tight card and a roomy
* section cannot coexist on one page.
*/
const rectOf = (xml: string, id: string): { w: number; h: number } => {
const m = xml.match(
new RegExp(
`id="${id}"[^>]*>\\s*<mxGeometry[^>]*width="([\\d.]+)" height="([\\d.]+)"`,
),
)
if (!m) throw new Error(`no geometry for ${id}`)
return { w: Number(m[1]), h: Number(m[2]) }
}
const xyOf = (xml: string, id: string): { x: number; y: number } => {
const m = xml.match(
new RegExp(
`id="${id}"[^>]*>\\s*<mxGeometry x="([\\d.-]+)" y="([\\d.-]+)"`,
),
)
if (!m) throw new Error(`no position for ${id}`)
return { x: Number(m[1]), y: Number(m[2]) }
}
describe("grow", () => {
it("splits a row's leftover space by weight", () => {
// A wide banner forces the page wider than the two columns need; grow 2:1
// must hand the columns that slack in proportion.
const make = (withGrow: boolean) =>
restructureDiagram("", [
{
op: "add_container",
id: "page",
label: "",
dir: "col",
gap: 16,
},
{
op: "add_box",
id: "mast",
parent: "page",
label: "A very wide masthead banner that stretches the page out",
role: "banner",
},
{
op: "add_container",
id: "cols",
parent: "page",
label: "",
dir: "row",
gap: 16,
},
{
op: "add_container",
id: "main",
parent: "cols",
label: "",
dir: "col",
gap: 8,
...(withGrow ? { grow: 2 } : {}),
},
{
op: "add_container",
id: "side",
parent: "cols",
label: "",
dir: "col",
gap: 8,
...(withGrow ? { grow: 1 } : {}),
},
{
op: "add_box",
id: "a",
parent: "main",
label: "main content",
},
{ op: "add_box", id: "b", parent: "side", label: "aside" },
]).xml as string
const flat = make(false)
const grown = make(true)
const extraMain = rectOf(grown, "main").w - rectOf(flat, "main").w
const extraSide = rectOf(grown, "side").w - rectOf(flat, "side").w
// The slack lands on the columns instead of the gaps, split 2:1.
expect(extraMain).toBeGreaterThan(0)
expect(extraMain / extraSide).toBeCloseTo(2, 0)
})
})
describe("align", () => {
it("stretch spans the cross axis; start pins to the edge", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "card", label: "", dir: "col", gap: 8 },
{
op: "add_box",
id: "wide",
parent: "card",
label: "This is a long question line that sets the card width",
},
{
op: "add_box",
id: "bar",
parent: "card",
label: "A: 11",
align: "stretch",
},
{
op: "add_box",
id: "verdict",
parent: "card",
label: "✗ Wrong",
align: "start",
},
])
expect(r.errors).toEqual([])
const xml = r.xml as string
// The bar fills the card's interior width, like the wide line does.
expect(rectOf(xml, "bar").w).toBe(rectOf(xml, "wide").w)
// start pins to the interior's left edge — same x as the widest child.
expect(xyOf(xml, "verdict").x).toBe(xyOf(xml, "wide").x)
})
})
describe("pad", () => {
it("a tight card is smaller than the default for the same content", () => {
const make = (pad?: number) =>
restructureDiagram("", [
{
op: "add_container",
id: "c",
label: "",
dir: "col",
gap: 8,
...(pad != null ? { pad } : {}),
},
{ op: "add_box", id: "x", parent: "c", label: "content" },
]).xml as string
const tight = rectOf(make(8), "c")
const roomy = rectOf(make(), "c")
expect(roomy.w - tight.w).toBe(32) // (24-8)*2
expect(roomy.h - tight.h).toBe(32)
})
})
describe("round trip", () => {
it("grow, align and pad survive parse and re-render", () => {
const r = restructureDiagram("", [
{
op: "add_container",
id: "row",
label: "",
dir: "row",
gap: 10,
pad: 10,
},
{
op: "add_container",
id: "left",
parent: "row",
label: "",
dir: "col",
gap: 6,
grow: 3,
},
{
op: "add_box",
id: "a",
parent: "left",
label: "hello",
align: "stretch",
},
{
op: "add_box",
id: "b",
parent: "row",
label: "side",
grow: 1,
align: "end",
},
])
expect(r.errors).toEqual([])
const back = parseDiagram(r.xml as string)
const row = back.tree.roots.find((n) => n.id === "row")
expect(row?.kind).toBe("group")
if (row?.kind !== "group") return
expect(row.pad).toBe(10)
const left = row.children.find((n) => n.id === "left")
expect(left?.kind === "group" && left.grow).toBe(3)
const a = left?.kind === "group" && left.children[0]
expect(a && a.kind === "box" && a.align).toBe("stretch")
const b = row.children.find((n) => n.id === "b")
expect(b?.kind === "box" && b.grow).toBe(1)
expect(b?.kind === "box" && b.align).toBe("end")
})
})
describe("rich text measurement", () => {
it("does not count markup as text, and counts <br> as a line", () => {
const plain = autoBoxSize("Often Wrong")
const rich = autoBoxSize(
'<font color="#B85450"><b>Often Wrong</b></font>',
)
expect(rich.w).toBe(plain.w)
expect(rich.h).toBe(plain.h)
const twoLines = autoBoxSize("first line<br>second line")
const oneLine = autoBoxSize("first line")
expect(twoLines.h).toBeGreaterThan(oneLine.h)
})
})