diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 77848ba..add668a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -704,7 +704,9 @@ Example: If previous output ended with ', , ,
— 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 "✗ Often Wrong" 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. diff --git a/lib/diagram-engine/layout.ts b/lib/diagram-engine/layout.ts index af5409b..ca71a1b 100644 --- a/lib/diagram-engine/layout.ts +++ b/lib/diagram-engine/layout.ts @@ -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
is a line break, any + * other tag is invisible markup around visible text. Measuring the raw string counted + * `` as thirty characters of text, making rich boxes twice as + * wide as their content. + */ +function visibleText(label: string): string { + return String(label ?? "") + .replace(//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 } } diff --git a/lib/diagram-engine/markers.ts b/lib/diagram-engine/markers.ts index 368f9ea..4aa282b 100644 --- a/lib/diagram-engine/markers.ts +++ b/lib/diagram-engine/markers.ts @@ -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 | 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 diff --git a/lib/diagram-engine/operations.ts b/lib/diagram-engine/operations.ts index 89a09cd..e0fbde8 100644 --- a/lib/diagram-engine/operations.ts +++ b/lib/diagram-engine/operations.ts @@ -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 = { diff --git a/lib/diagram-engine/parse.ts b/lib/diagram-engine/parse.ts index 3fd5d42..a8413b5 100644 --- a/lib/diagram-engine/parse.ts +++ b/lib/diagram-engine/parse.ts @@ -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 } diff --git a/lib/diagram-engine/render.ts b/lib/diagram-engine/render.ts index bd99ce0..895ea82 100644 --- a/lib/diagram-engine/render.ts +++ b/lib/diagram-engine/render.ts @@ -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, "'") } +// NOTE ON RICH TEXT: a label may carry inline HTML — , , ,
, +// — 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, diff --git a/lib/diagram-engine/types.ts b/lib/diagram-engine/types.ts index 64082d8..4504c4e 100644 --- a/lib/diagram-engine/types.ts +++ b/lib/diagram-engine/types.ts @@ -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 diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index 082b07f..c84aec7 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -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 — , , ,
— 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="Standard Prompting" 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="✗ Often Wrong" align=start Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout would be wrong rather than merely different: diff --git a/tests/unit/diagram-engine-flex.test.ts b/tests/unit/diagram-engine-flex.test.ts new file mode 100644 index 0000000..8d2ef56 --- /dev/null +++ b/tests/unit/diagram-engine-flex.test.ts @@ -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*]*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* { + 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
as a line", () => { + const plain = autoBoxSize("Often Wrong") + const rich = autoBoxSize( + 'Often Wrong', + ) + expect(rich.w).toBe(plain.w) + expect(rich.h).toBe(plain.h) + const twoLines = autoBoxSize("first line
second line") + const oneLine = autoBoxSize("first line") + expect(twoLines.h).toBeGreaterThan(oneLine.h) + }) +})