mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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:
256
tests/unit/diagram-engine-theme.test.ts
Normal file
256
tests/unit/diagram-engine-theme.test.ts
Normal file
@@ -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<string, string>()
|
||||
for (const m of xml.matchAll(
|
||||
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
|
||||
))
|
||||
parentOfId.set(m[1], m[2])
|
||||
const byParent = new Map<string, string[]>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user