From e78322ca526bd71836b4093d7c9d5332fc8bca9e Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 9 Aug 2026 19:48:01 +0900 Subject: [PATCH] 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. --- app/api/chat/route.ts | 19 +- lib/diagram-engine/graph.ts | 21 +- lib/diagram-engine/layout.ts | 50 ++++- lib/diagram-engine/markers.ts | 33 ++- lib/diagram-engine/operations.ts | 46 +++++ lib/diagram-engine/parse.ts | 17 ++ lib/diagram-engine/render.ts | 79 +++++--- lib/diagram-engine/theme.ts | 245 +++++++++++++++++++++++ lib/diagram-engine/types.ts | 10 + lib/system-prompts.ts | 11 +- tests/unit/diagram-engine-theme.test.ts | 256 ++++++++++++++++++++++++ 11 files changed, 727 insertions(+), 60 deletions(-) create mode 100644 lib/diagram-engine/theme.ts create mode 100644 tests/unit/diagram-engine-theme.test.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index ccea08d..77848ba 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -702,7 +702,9 @@ Example: If previous output ended with ' [n.id, n])) - // Groups become colours here, in order of first appearance, so "the second group named - // is green" holds for every diagram the engine draws. The caller only names groups. - const groupIndex = new Map() - for (const n of nodes) - if (n.group && !groupIndex.has(n.group)) - groupIndex.set(n.group, groupIndex.size) - const add = (id: string, parent: string): Operation => { const n = byId.get(id) as GraphNode - const colour = - n.group !== undefined - ? groupColour(groupIndex.get(n.group) ?? 0) - : null return n.icon ? { op: "add_icon", @@ -336,9 +326,8 @@ export function graphToOperations( parent, label: n.label, ...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}), - ...(colour - ? { fill: colour.fill, stroke: colour.stroke } - : {}), + ...(n.role && n.role !== "body" ? { role: n.role } : {}), + ...(n.group ? { group: n.group } : {}), } } diff --git a/lib/diagram-engine/layout.ts b/lib/diagram-engine/layout.ts index b886f11..af5409b 100644 --- a/lib/diagram-engine/layout.ts +++ b/lib/diagram-engine/layout.ts @@ -28,6 +28,7 @@ * `pool()` primitive; sequence and radial are original to this repository. */ +import { type Role, roleMetrics } from "./theme" import type { ContainerNode, DiagramNode, @@ -171,14 +172,37 @@ export interface Placed { */ export type LayoutLinks = { source: string; target: string; step?: number }[] -/** Intrinsic size of a text box: widest wrapped line by line count. */ -export function autoBoxSize(label: string): { w: number; h: number } { - const lines = String(label ?? "").split("\n") - const longest = Math.max(1, ...lines.map((l) => l.length)) - return { - w: Math.min(260, Math.max(120, Math.round(longest * CHAR_W + 28))), - h: Math.max(44, lines.length * 18 + 26), - } +/** + * Intrinsic size of a text box: widest wrapped line by line count. + * + * The role scales the estimate: a banner sets 20px type and a footnote 9px, and layout has + * to reserve what render will draw or the text overflows its cell. + */ +export function autoBoxSize( + label: string, + role?: Role, +): { 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 longest = Math.max(1, ...explicit.map((l) => l.length)) + const w = Math.min( + maxW, + Math.max(120, Math.round(longest * CHAR_W * r.charScale + 28)), + ) + // Count the lines the text ACTUALLY occupies: draw.io wraps at the box width, so a + // long line becomes several. Estimating by explicit newlines alone left the box one + // line tall while the text wrapped to six — and overflowed straight out of it. + const charsPerLine = Math.max( + 8, + Math.floor((w - 28) / (CHAR_W * r.charScale)), + ) + const lines = explicit.reduce( + (sum, l) => sum + Math.max(1, Math.ceil(l.length / charsPerLine)), + 0, + ) + const lineH = Math.round(r.fontSize * 1.6) + return { w, h: Math.max(r.minH, lines * lineH + 26) } } /** @@ -377,7 +401,7 @@ function measure( return { node: n, rect: { x: 0, y: 0, ...s }, children: [] } } if (n.kind === "box") { - const auto = autoBoxSize(n.label) + const auto = autoBoxSize(n.label, n.role) return { node: n, rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h }, @@ -636,10 +660,18 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void { let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2) for (const kid of kids) { + // A stretching role fills the cross axis: a masthead spans its page, a section + // 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 stretches = + kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch if (alongRow) { + if (stretches) kid.rect.h = innerH place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links) cur += kid.rect.w + gap } else { + if (stretches) kid.rect.w = innerW place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links) cur += kid.rect.h + gap } diff --git a/lib/diagram-engine/markers.ts b/lib/diagram-engine/markers.ts index 6cd8ca3..368f9ea 100644 --- a/lib/diagram-engine/markers.ts +++ b/lib/diagram-engine/markers.ts @@ -60,6 +60,10 @@ export const MARKER = { step: "dai_step", /** How a radial container fans its branches out: "radial" or "down". */ spread: "dai_spread", + /** The node's information role (banner, heading, callout…), for the round trip. */ + role: "dai_role", + /** The node's semantic zone, whose hue ramp colours it. */ + group: "dai_group", /** * 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 @@ -203,9 +207,24 @@ export function isPinned(style: string): boolean { return s !== "" && s !== "0" && s !== "false" } -/** Append `key=value;`, ensuring the style ends with a separator first. */ +/** + * Append `key=value;`, replacing any existing occurrence of the key first. + * + * Styles are re-stamped on every render, and a style recovered from the canvas already + * carries last render's markers — blindly appending grew the string by one duplicate per + * round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED + * wrong, which is why it went unnoticed until a byte-identity test caught it. + * + * Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is + * load-bearing there: the container tokens rely on appending `container=1` after a catalog + * style that may say `container=0`. + */ function append(style: string, key: string, value: string | number): string { - const base = style.endsWith(";") || style === "" ? style : `${style};` + const cleaned = key.startsWith("dai_") + ? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "") + : style + const base = + cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};` return `${base}${key}=${value};` } @@ -353,6 +372,16 @@ export function stampLeaf( return opts.name ? append(s, MARKER.name, opts.name) : s } +/** Stamp the node's information role, replacing any previous one. */ +export function stampRole(style: string, role: string): string { + return append(style, MARKER.role, role) +} + +/** Stamp the node's semantic zone, replacing any previous one. */ +export function stampGroup(style: string, group: string): string { + return append(style, MARKER.group, encodeURIComponent(group)) +} + /** 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 2533eab..89a09cd 100644 --- a/lib/diagram-engine/operations.ts +++ b/lib/diagram-engine/operations.ts @@ -54,6 +54,27 @@ export const OperationSchema = z.discriminatedUnion("op", [ id: z.string(), parent: z.string().optional(), label: z.string(), + role: z + .enum([ + "banner", + "heading", + "body", + "callout", + "good", + "bad", + "metric", + "muted", + ]) + .optional() + .describe( + "What this IS: banner=masthead, heading=section title, callout=must-not-miss, good/bad=verdict, metric=key number, muted=fine print. The theme decides how each looks", + ), + group: z + .string() + .optional() + .describe( + "Semantic zone name; nodes and panels sharing a group get the same hue from the engine's palette. Never pick colours", + ), fill: z .string() .optional() @@ -98,6 +119,27 @@ export const OperationSchema = z.discriminatedUnion("op", [ label: z .string() .describe("Frame title; empty string means invisible wrapper"), + role: z + .enum([ + "banner", + "heading", + "body", + "callout", + "good", + "bad", + "metric", + "muted", + ]) + .optional() + .describe( + "Section role: heading=titled tinted panel, banner=masthead strip, good/bad=verdict panel", + ), + group: z + .string() + .optional() + .describe( + "Semantic zone name; the panel and everything sharing this group take one hue", + ), dir: z.enum(["row", "col"]).describe("How children stack"), gname: z .string() @@ -370,6 +412,8 @@ export function applyOperations( : {}), ...(op.fill ? { fill: op.fill } : {}), ...(op.stroke ? { stroke: op.stroke } : {}), + ...(op.role ? { role: op.role } : {}), + ...(op.group ? { group: op.group } : {}), ...cellOf(op), } else if (op.op === "add_container") @@ -381,6 +425,8 @@ export function applyOperations( dir: op.dir, gap: op.gap ?? 20, children: [], + ...(op.role ? { role: op.role } : {}), + ...(op.group ? { group: op.group } : {}), } else if (op.op === "add_grid") node = { diff --git a/lib/diagram-engine/parse.ts b/lib/diagram-engine/parse.ts index 07da00d..3fd5d42 100644 --- a/lib/diagram-engine/parse.ts +++ b/lib/diagram-engine/parse.ts @@ -36,6 +36,7 @@ import { readList, readMarker, } from "./markers" +import { isRole, type Role } from "./theme" import type { BoxNode, BoxShape, @@ -347,6 +348,18 @@ function looksLikeText(style: string): boolean { } /** The flowchart outline a box is drawn with, read back from its style. */ +/** The information role stamped on a cell, if any. */ +function roleOf(style: string): Role | undefined { + const v = readMarker(style, MARKER.role) + return isRole(v) ? v : undefined +} + +/** The semantic zone stamped on a cell, if any. */ +function zoneOf(style: string): string | undefined { + const v = readMarker(style, MARKER.group) + return v ? decodeURIComponent(v) : undefined +} + function boxShape(style: string): BoxShape | undefined { const shape = styleValue(style, "shape") if (shape === "parallelogram") return "data" @@ -1055,6 +1068,8 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { fill: styleValue(c.style, "fillColor"), stroke: styleValue(c.style, "strokeColor"), shape: boxShape(c.style), + role: roleOf(c.style), + group: zoneOf(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, @@ -1204,6 +1219,8 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { kind: "group", ...common, dir: dir === "col" ? "col" : "row", + role: roleOf(c.style), + group: zoneOf(c.style), } return node } diff --git a/lib/diagram-engine/render.ts b/lib/diagram-engine/render.ts index 7a618df..bd99ce0 100644 --- a/lib/diagram-engine/render.ts +++ b/lib/diagram-engine/render.ts @@ -25,14 +25,17 @@ import { isInvisible, stampCell, stampContainer, + stampGroup, stampLane, stampLeaf, stampPool, stampPoolDecoration, stampRadial, + stampRole, stampSequence, } from "./markers" import { type RoutedEdge, routeEdges } from "./route" +import { hueOf, NEUTRAL, type Role, themedStyle } from "./theme" import { type BoxShape, type DiagramNode, @@ -64,31 +67,10 @@ export type StyleResolver = ( const FALLBACK_BOX = "rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;verticalAlign=middle;" -/** - * The palette for semantic groups: paired fill/stroke, assigned to group names in order of - * first appearance. - * - * The engine owns these hex values and the model never sees them — it only names groups - * ("remote", "local", "temp"), which is the judgement it is actually good at. Letting the - * model pick colours per diagram produced mismatched saturations and a different look every - * time; a fixed palette is what makes two diagrams from the same engine look related. - * - * All six fills are low-saturation tints that keep #1A1A1A text readable, each with a - * darker stroke of the same hue, deliberately quieter than the near-black edge lines so - * colour reads as grouping rather than emphasis. - */ -const GROUP_PALETTE: { fill: string; stroke: string }[] = [ - { fill: "#DAE8FC", stroke: "#6C8EBF" }, // blue - { fill: "#D5E8D4", stroke: "#82B366" }, // green - { fill: "#FFE6CC", stroke: "#D79B00" }, // orange - { fill: "#E1D5E7", stroke: "#9673A6" }, // purple - { fill: "#F8CECC", stroke: "#B85450" }, // red - { fill: "#FFF2CC", stroke: "#D6B656" }, // yellow -] - -/** fill/stroke for the n-th distinct group. Wraps: a 7th group reuses the 1st colour. */ +/** fill/stroke for the n-th distinct group — the theme's hue ramp, tint and base steps. */ export function groupColour(index: number): { fill: string; stroke: string } { - return GROUP_PALETTE[index % GROUP_PALETTE.length] + const h = hueOf(index) + return { fill: h.tint, stroke: h.base } } const FALLBACK_FRAME = "rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#999999;fontColor=#1A1A1A;fontSize=12;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;" @@ -160,7 +142,16 @@ export interface RenderOptions { * what is already on the canvas, including any colour the user changed by hand. We only * re-stamp the markers on top, so layout parameters stay current. */ -function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string { +/** The hue ramp for a node's group, or the neutral ramp. Assigned in document order. */ +export type HueResolver = ( + group: string | undefined, +) => ReturnType + +function styleFor( + n: DiagramNode, + resolve: StyleResolver | undefined, + hue: HueResolver = () => NEUTRAL, +): string { if (n.kind === "title") return TITLE_STYLE if (n.kind === "icon") { @@ -174,14 +165,18 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string { if (n.kind === "box") { let base = n.style ?? FALLBACK_BOX if (!n.style) { - // The outline comes first: BOX_SHAPES carries `rounded=`, which the fallback - // also sets, and appending lets the shape's value win. + // Order matters, later keys win in draw.io: outline, then the theme's + // composition of role x hue, then explicit colours on top of everything. if (n.shape) base += BOX_SHAPES[n.shape] + if (n.role || n.group) + base += themedStyle(n.role ?? "body", hue(n.group), "leaf") if (n.fill) base += `fillColor=${n.fill};` if (n.stroke) base += `strokeColor=${n.stroke};` if (n.bold) base += "fontStyle=1;" } - const stamped = stampLeaf(base, "box") + let stamped = stampLeaf(base, "box") + if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role) + if (n.group) stamped = stampGroup(stamped, n.group) return n.cell ? stampCell(stamped, n.cell) : stamped } @@ -210,12 +205,19 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string { // the structure survives a round-trip, but draw nothing. This replaces the // reference project's "phantom", which emitted no cell and therefore lost the // wrapper's direction and grouping on the way back. - const invisible = !n.gname && !n.label && !n.fill && !n.stroke + const groupRole = n.kind === "group" ? n.role : undefined + const zone = n.kind === "group" ? n.group : undefined + const invisible = + !n.gname && !n.label && !n.fill && !n.stroke && !groupRole && !zone let base = n.style ?? fromCatalog ?? FALLBACK_FRAME if (!n.style && !fromCatalog) { + if (groupRole || zone) + base += themedStyle(groupRole ?? "heading", hue(zone), "container") if (n.fill) base += `fillColor=${n.fill};` if (n.stroke) base += `strokeColor=${n.stroke};` } + if (groupRole && groupRole !== "body") base = stampRole(base, groupRole) + if (zone) base = stampGroup(base, zone) return stampContainer(base, { kind: n.kind, dir: n.kind === "grid" ? "grid" : n.dir, @@ -269,13 +271,14 @@ function vertexXml( parentRect: Rect | null, resolve: StyleResolver | undefined, defaultGlyph: number, + hue: HueResolver = () => NEUTRAL, ): string { const ox = parentRect?.x ?? 0 const oy = parentRect?.y ?? 0 const box = cellRect(n, rect, defaultGlyph) return ( `` + + ` style="${styleFor(n, resolve, hue)}" vertex="1" parent="${esc(parent)}">` + `` + `` ) @@ -748,6 +751,21 @@ export function renderDiagram( } } + // Groups become hues here, in document order, so "the second zone named is green" + // holds for every diagram the engine draws. The caller only ever names zones. + const groupIndex = new Map() + for (const f of flat) { + const g = + f.node.kind === "box" || f.node.kind === "group" + ? f.node.group + : undefined + if (g && !groupIndex.has(g)) groupIndex.set(g, groupIndex.size) + } + const hue: HueResolver = (g) => + g !== undefined && groupIndex.has(g) + ? hueOf(groupIndex.get(g) as number) + : NEUTRAL + // Parents come before children (flatten guarantees it), which draw.io requires. for (const f of flat) { // A lifeline cell already carries its participant's label and geometry. @@ -764,6 +782,7 @@ export function renderDiagram( parentRect, opts.resolveStyle, glyph, + hue, ), ) const own = chrome.get(f.node.id) diff --git a/lib/diagram-engine/theme.ts b/lib/diagram-engine/theme.ts new file mode 100644 index 0000000..beaa607 --- /dev/null +++ b/lib/diagram-engine/theme.ts @@ -0,0 +1,245 @@ +/** + * The theme: design tokens plus one composition rule, in place of style tables. + * + * The engine's original deal was: the model declares structure, the engine computes + * geometry. But every box rendered identically — white, 11px, black border — so anything + * whose meaning lives in visual hierarchy (a paper-summary poster, a cheat sheet, a + * comparison panel) came out flat, and the only escape was hand-written XML with no layout + * guarantees at all. + * + * Two ideas fix that generally, not per diagram type: + * + * ROLE — what a node IS in the information hierarchy: a masthead, a section heading, a + * key number, fine print. The model judges this well. Each role maps to a type size and + * an emphasis (filled / outlined / ghost), not to any colour. + * + * GROUP — which semantic zone a node belongs to: remote vs local, one poster section vs + * another. Each distinct group name gets one HUE RAMP — a light tint, a mid stroke, a + * dark text colour — assigned in order of first appearance. + * + * `themedStyle(role, hue, kind)` composes the two by rule. A heading container in group 2 + * gets that hue's tint as its panel and the dark step for its title; a metric in the same + * group gets the mid step as a heavy border. Nothing is enumerated per combination, so a + * new diagram kind gets full theming by tagging its nodes — there is no table to extend. + * The model never sees a hex value; the same declaration always renders the same way. + */ + +/** What a node is, in the information hierarchy of the diagram. */ +export type Role = + | "banner" // the masthead: large type on the theme's one dark field + | "heading" // a section title / titled panel + | "body" // ordinary content (the default look) + | "callout" // something the reader must not miss + | "good" // a positive verdict (always green, group or not) + | "bad" // a negative verdict or warning (always red) + | "metric" // the key number + | "muted" // fine print + +export const ROLES: readonly Role[] = [ + "banner", + "heading", + "body", + "callout", + "good", + "bad", + "metric", + "muted", +] + +export function isRole(v: string | null | undefined): v is Role { + return ROLES.includes(v as Role) +} + +// ---- tokens ---- + +/** One hue, three steps: a field to sit on, a line to draw with, a colour to write in. */ +export interface HueRamp { + tint: string + base: string + dark: string +} + +/** + * The hue ramps groups draw from, in assignment order. + * + * Tint/base pairs are draw.io's classic palette, so themed output looks native to the + * editor; the dark step is the same hue pulled down far enough for 4.5:1 text on white. + */ +export const HUES: readonly HueRamp[] = [ + { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }, // blue + { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }, // green + { tint: "#FFE6CC", base: "#D79B00", dark: "#8A5A00" }, // orange + { tint: "#E1D5E7", base: "#9673A6", dark: "#4A2E5E" }, // purple + { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }, // red + { tint: "#FFF2CC", base: "#D6B656", dark: "#7A5C00" }, // yellow +] + +/** The neutral ramp, for ungrouped nodes: today's grey-on-white look. */ +export const NEUTRAL: HueRamp = { + tint: "#F5F8FB", + base: "#5A6B7B", + dark: "#1A1A1A", +} + +/** Semantic verdict hues: good is green and bad is red no matter what group says. */ +const GOOD: HueRamp = { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" } +const BAD: HueRamp = { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" } +/** The callout field: a warm highlight distinct from every group tint. */ +const CALLOUT: HueRamp = { tint: "#FFF9C4", base: "#B8860B", dark: "#6D4C00" } + +/** Type scale, px. One scale for every diagram kind. */ +export const TYPE = { xs: 9, sm: 11, md: 13, lg: 15, xl: 20 } as const + +/** The hue ramp for the n-th distinct group. Wraps: a 7th group reuses the 1st hue. */ +export function hueOf(index: number): HueRamp { + return HUES[index % HUES.length] +} + +// ---- the composition rule ---- + +/** How a role renders, independent of colour. */ +interface RoleSpec { + size: number + bold: boolean + /** filled: dark field, light text. tinted: hue field. outlined: white field, hue border. + * ghost: no field, no border — pure text. */ + emphasis: "filled" | "tinted" | "outlined" | "ghost" + /** Overrides the group hue; verdicts stay green/red whatever zone they sit in. */ + hue?: HueRamp + /** Fill the container's cross axis, the way a masthead spans its page. */ + stretch?: boolean + /** Minimum cell height. */ + minH: number + /** Character width relative to 11px type, for the measure pass. */ + charScale: number +} + +/** The masthead field when no group says otherwise: the deep navy of the first hue. */ +const BANNER: HueRamp = { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" } + +const ROLE_SPECS: Record = { + banner: { + size: TYPE.xl, + bold: true, + emphasis: "filled", + hue: BANNER, + stretch: true, + minH: 64, + charScale: 1.8, + }, + heading: { + size: TYPE.lg, + bold: true, + emphasis: "ghost", + stretch: true, + minH: 32, + charScale: 1.35, + }, + body: { + size: TYPE.sm, + bold: false, + emphasis: "outlined", + minH: 44, + charScale: 1, + }, + callout: { + size: TYPE.sm, + bold: true, + emphasis: "tinted", + hue: CALLOUT, + minH: 44, + charScale: 1, + }, + good: { + size: TYPE.sm, + bold: false, + emphasis: "tinted", + hue: GOOD, + minH: 44, + charScale: 1, + }, + bad: { + size: TYPE.sm, + bold: false, + emphasis: "tinted", + hue: BAD, + minH: 44, + charScale: 1, + }, + metric: { + size: TYPE.xl, + bold: true, + emphasis: "outlined", + minH: 56, + charScale: 1.8, + }, + muted: { + size: TYPE.xs, + bold: false, + emphasis: "ghost", + minH: 24, + charScale: 0.82, + }, +} + +/** Metrics the measure pass needs, so layout reserves what render will draw. */ +export function roleMetrics(role: Role | undefined): { + fontSize: number + minH: number + charScale: number + stretch: boolean +} { + const s = ROLE_SPECS[role ?? "body"] + return { + fontSize: s.size, + minH: s.minH, + charScale: s.charScale, + stretch: s.stretch === true, + } +} + +/** + * The style tokens for one node: the whole theme in a single rule. + * + * `hue` is the node's group ramp (or the neutral ramp); a role with a semantic hue + * (good/bad/callout) overrides it. `kind` softens the treatment for containers — a + * section panel is a field its children sit on, so it takes the tint at panel weight + * rather than a leaf's full treatment. + */ +export function themedStyle( + role: Role, + hue: HueRamp, + kind: "leaf" | "container", +): string { + const spec = ROLE_SPECS[role] + const ramp = spec.hue ?? hue + const size = kind === "container" && role === "banner" ? TYPE.lg : spec.size + const font = `fontSize=${size};${spec.bold || kind === "container" ? "fontStyle=1;" : ""}` + + if (spec.emphasis === "filled") + return `fillColor=${ramp.dark};strokeColor=none;fontColor=#FFFFFF;${font}rounded=1;arcSize=6;` + + if (kind === "container") { + // A panel: the tint as a quiet field, the dark step for its title, the base for + // its border. This is where "every section gets its own colour" comes from — + // a heading container plus a group, no extra mechanism. + return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${font}verticalAlign=top;align=left;spacingLeft=10;spacingTop=6;` + } + + if (spec.emphasis === "ghost") + return `fillColor=none;strokeColor=none;fontColor=${ramp.dark};${font}align=left;` + + if (spec.emphasis === "tinted") { + // A callout keeps a heavy left bar, the editor's convention for "note well". + const bar = role === "callout" ? `strokeWidth=2;` : "" + return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${bar}${font}` + } + + // outlined: the hue carried by the border and text. A grouped ordinary node takes its + // zone's tint as the field — colour-as-grouping is the whole point of naming a zone — + // while an ungrouped one stays white. A metric stays white either way, so its number + // sits on the page's calmest field with the hue in a heavy border. + const weight = role === "metric" ? "strokeWidth=2;" : "" + const field = role === "body" && ramp !== NEUTRAL ? ramp.tint : "#FFFFFF" + return `fillColor=${field};strokeColor=${ramp.base};fontColor=${ramp.dark};${weight}${font}` +} diff --git a/lib/diagram-engine/types.ts b/lib/diagram-engine/types.ts index 155229d..64082d8 100644 --- a/lib/diagram-engine/types.ts +++ b/lib/diagram-engine/types.ts @@ -9,8 +9,10 @@ */ import type { Direction } from "./markers" +import type { Role } from "./theme" export type { Direction } from "./markers" +export type { Role } from "./theme" /** * Which cell of a swimlane pool a node sits in. @@ -67,6 +69,10 @@ export interface BoxNode { fill?: string stroke?: string bold?: boolean + /** What this node IS in the information hierarchy; the theme decides how that looks. */ + role?: Role + /** Semantic zone name; every node sharing a group gets the same hue ramp. */ + group?: string /** Flowchart outline. Absent means a plain rectangle. */ shape?: BoxShape style?: string @@ -99,6 +105,10 @@ export interface GroupNode { children: DiagramNode[] fill?: string stroke?: string + /** Section role; a themed panel for its children. */ + role?: Role + /** Semantic zone name; the panel takes this hue's tint. */ + group?: string style?: string pinned?: boolean rect?: Rect diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index 44823bc..082b07f 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -57,7 +57,7 @@ parameters: { } ---Tool5--- tool name: restructure_diagram -description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings. +description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Boxes and containers accept a role (banner/heading/callout/good/bad/metric/muted) for visual hierarchy — the engine's theme styles each role consistently. Never pass coordinates, XML or style strings. parameters: { operations: Array // add_icon | add_box | add_container | add_grid | add_pool | add_sequence | add_radial | remove | move | set_label | set_dir | set_gap | link | unlink | set_title } @@ -103,9 +103,16 @@ Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed f - Mind maps and org charts: add_radial, one add_box per node, then link parent to child. 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. + Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout would be wrong rather than merely different: - UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts, illustrations, + UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts, Gantt charts, anything where the exact position of each element is the content. - Use edit_diagram for: small changes to a diagram that was made with display_diagram. diff --git a/tests/unit/diagram-engine-theme.test.ts b/tests/unit/diagram-engine-theme.test.ts new file mode 100644 index 0000000..933e465 --- /dev/null +++ b/tests/unit/diagram-engine-theme.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest" +import { drawGraph, restructureDiagram } from "@/lib/diagram-engine" +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) + }) + + it("draw_graph nodes accept roles too", () => { + const r = drawGraph( + [ + { id: "t", label: "Pipeline", role: "heading" }, + { id: "a", label: "Build" }, + { id: "warn", label: "Flaky stage", role: "bad" }, + ], + [ + { source: "t", target: "a" }, + { source: "a", target: "warn" }, + ], + ) + 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() + for (const m of xml.matchAll( + /]*vertex="1" parent="([^"]+)"/g, + )) + parentOfId.set(m[1], m[2]) + const byParent = new Map() + 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([]) + }) +})