Files
next-ai-draw-io/lib/diagram-engine/theme.ts

266 lines
9.4 KiB
TypeScript
Raw Normal View History

feat(diagram-engine): design tokens + role/group composition, engine-wide theming Paper-summary posters previously required hand-written XML: every engine box rendered identically (white, 11px), so anything whose meaning lives in visual hierarchy came out flat. This makes presentation a first-class, generalised part of the declaration - not a poster feature. Structure/presentation separation, the same split HTML and CSS settled on: - ROLE says what a node IS: banner, heading, body, callout, good, bad, metric, muted. Maps to a type scale and an emphasis (filled / tinted / outlined / ghost), never to a colour. - GROUP says which semantic zone a node belongs to. Each distinct group name gets one hue ramp (tint / base / dark), assigned in document order. Promoted from a draw_graph-only field to BoxNode and GroupNode, round-tripped via dai_group. - themedStyle(role, hue, kind) composes the two by rule - there is no per-combination table to extend, so a new diagram kind gets full theming by tagging nodes. The model never sees a hex value. A heading container plus a group yields the tinted section panel with a dark title; a grouped body box takes its zone's tint; verdict roles stay green/red regardless of zone; the banner is the page's one dark field. Also fixed, found while building the acceptance poster: - autoBoxSize only counted explicit newlines, so a long single-line label wrapped to six lines in draw.io but got a one-line-tall box, and the text overflowed the cell. - Marker stamping appended without replacing, so every render of a recovered style grew it by one duplicate dai_* token per key - unnoticed because draw.io resolves duplicates last-wins. dai_* keys are now replaced in place; mxGraph keys still append, because last-wins is load-bearing for container=1 normalisation. - Banner/heading/metric roles stretch across their container's cross axis, the way a masthead spans its page. - Prompt: a poster's banner IS its title (no set_title alongside), and sections get their colour by naming groups. 537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e tests in a real browser. Verified visually: the Transformer-paper poster renders with a navy masthead, three hue-coded section panels, metric, verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
/**
* 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<Role, RoleSpec> = {
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,
},
}
feat(diagram-engine): corners, borderless fills, shadows and strikethrough Four more Tailwind classes, all four verified against draw.io's own source in public/drawio rather than against a prose reference — which is how three earlier exclusions turned out to be wrong: rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to absolute pixels and halves it, so the same class is the same corner on every box. Previously excluded as 'percentage only' shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent params, not one flag, so Tailwind's offset+blur rungs map one to one. Previously excluded as 'six sizes collapse to one' line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both mxText.js:723 and :1040. The bitmask has four bits, not three border-none the only one of the four that adds something previously inexpressible: a fill with no outline Also fixes an edge style growing 76 characters per re-layout, without bound. The router recomputes ports on every pass, and appending them to a style recovered from the canvas — which already carried the previous pass's eight port keys — grew the string forever. draw.io resolves duplicates last-wins so the arrow always looked right; a byte-identity check is what caught it. Two traps found while wiring the readback, both the same shape: a value the THEME emits being recorded as one the model asked for. strokeColor=none from a filled or ghost role, and rounded=0 from the fallback style. Either one would outlive a set_role, since that clears style but keeps text. Deliberately not included, with reasons in tw.ts: per-side borders and per-corner radius (both would take the shape slot, and what a node IS matters more than which of its edges show), per-side padding (draw.io's keys pad the label, not the room left for children), text-shadow (a bare flag with no offset or blur), opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading (absent from draw.io — zero grep hits, not merely coarse). 615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
/**
* Does this role already draw itself with no border?
*
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
* a dark filled slab and a heading is ghost text; both are borderless because of what they
* are, not because anyone asked. Recording that as an explicit override would make it
* outlive a later role change, since `set_role` clears a node's style but keeps its text
* overrides.
*
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
*/
export function roleIsBorderless(
role: Role | undefined,
kind: "leaf" | "container",
): boolean {
if (kind === "container") return false
const e = ROLE_SPECS[role ?? "body"].emphasis
return e === "filled" || e === "ghost"
}
feat(diagram-engine): design tokens + role/group composition, engine-wide theming Paper-summary posters previously required hand-written XML: every engine box rendered identically (white, 11px), so anything whose meaning lives in visual hierarchy came out flat. This makes presentation a first-class, generalised part of the declaration - not a poster feature. Structure/presentation separation, the same split HTML and CSS settled on: - ROLE says what a node IS: banner, heading, body, callout, good, bad, metric, muted. Maps to a type scale and an emphasis (filled / tinted / outlined / ghost), never to a colour. - GROUP says which semantic zone a node belongs to. Each distinct group name gets one hue ramp (tint / base / dark), assigned in document order. Promoted from a draw_graph-only field to BoxNode and GroupNode, round-tripped via dai_group. - themedStyle(role, hue, kind) composes the two by rule - there is no per-combination table to extend, so a new diagram kind gets full theming by tagging nodes. The model never sees a hex value. A heading container plus a group yields the tinted section panel with a dark title; a grouped body box takes its zone's tint; verdict roles stay green/red regardless of zone; the banner is the page's one dark field. Also fixed, found while building the acceptance poster: - autoBoxSize only counted explicit newlines, so a long single-line label wrapped to six lines in draw.io but got a one-line-tall box, and the text overflowed the cell. - Marker stamping appended without replacing, so every render of a recovered style grew it by one duplicate dai_* token per key - unnoticed because draw.io resolves duplicates last-wins. dai_* keys are now replaced in place; mxGraph keys still append, because last-wins is load-bearing for container=1 normalisation. - Banner/heading/metric roles stretch across their container's cross axis, the way a masthead spans its page. - Prompt: a poster's banner IS its title (no set_title alongside), and sections get their colour by naming groups. 537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e tests in a real browser. Verified visually: the Transformer-paper poster renders with a navy masthead, three hue-coded section panels, metric, verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
/** 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}`
}