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.
This commit is contained in:
dayuan.jiang
2026-08-09 19:48:01 +09:00
parent 6b5fd613f2
commit e78322ca52
11 changed files with 727 additions and 60 deletions

View File

@@ -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
}