feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge

The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:

- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
  matching perimeter — required or edges connect to the bounding box; a
  text-scale factor verified in the real editor — the same sentence overflows
  a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
  figures). Any other token passes through verbatim: draw.io degrades unknown
  shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
  rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
  distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
  rectangular node forever. set_shape/set_role/set_group operations make the
  fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
  displace each other) instead of string concatenation. This is what makes
  shape and theme composable by rule — shape owns geometry keys, theme owns
  colour/type keys, and an overlap resolves by order instead of emitting
  contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
  appearance-based reverse mapping cannot distinguish aliases (diamond vs
  decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
  boxes no longer freeze the first layout's numbers, so changing a label
  re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
  schema-drift problem the review predicted).

14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 21:56:09 +09:00
parent db9db1ff4e
commit 50826c0ac8
11 changed files with 644 additions and 75 deletions

View File

@@ -86,17 +86,10 @@ export const OperationSchema = z.discriminatedUnion("op", [
.optional()
.describe("Border colour; pair it with fill"),
shape: z
.enum([
"box",
"decision",
"terminator",
"round",
"data",
"document",
])
.string()
.optional()
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
"What the node IS, drawn as its conventional outline. Catalog: decision/diamond, terminator (start/end), round, data (input/output), document, cylinder (database), queue, person (actor/user), cloud (external system), hexagon (service), ellipse (concept), callout (note), step (pipeline stage), note, card, process, tape, cube. Any other draw.io shape token also works verbatim. Omit for a plain rectangle",
),
grow: z
.number()
@@ -261,6 +254,36 @@ export const OperationSchema = z.discriminatedUnion("op", [
id: z.string(),
label: z.string(),
}),
z.object({
op: z.literal("set_shape"),
id: z.string(),
shape: z
.string()
.describe("New shape token; 'box' resets to a plain rectangle"),
}),
z.object({
op: z.literal("set_role"),
id: z.string(),
role: z
.enum([
"banner",
"heading",
"body",
"callout",
"good",
"bad",
"metric",
"muted",
])
.describe("New information role; 'body' resets to the default"),
}),
z.object({
op: z.literal("set_group"),
id: z.string(),
group: z
.string()
.describe("New semantic zone; empty string removes the zone"),
}),
z.object({
op: z.literal("set_dir"),
id: z.string().describe("Container to re-orient"),
@@ -579,6 +602,50 @@ export function applyOperations(
break
}
case "set_shape": {
const node = findNode(tree, op.id)
if (!node || node.kind !== "box") {
errors.push(`set_shape: "${op.id}" is not a box`)
break
}
// The verbatim style is last render's composition with the OLD shape
// baked in; keeping it would override the new declaration entirely.
if (op.shape === "box") delete node.shape
else node.shape = op.shape
delete node.style
delete node.w
delete node.h
break
}
case "set_role": {
const node = findNode(tree, op.id)
if (!node || (node.kind !== "box" && node.kind !== "group")) {
errors.push(
`set_role: "${op.id}" is not a box or container`,
)
break
}
if (op.role === "body") delete node.role
else node.role = op.role
delete node.style
break
}
case "set_group": {
const node = findNode(tree, op.id)
if (!node || (node.kind !== "box" && node.kind !== "group")) {
errors.push(
`set_group: "${op.id}" is not a box or container`,
)
break
}
if (op.group === "") delete node.group
else node.group = op.group
delete node.style
break
}
case "set_dir": {
const node = findNode(tree, op.id)
if (!node || !isContainer(node)) {