feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels

The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:

- Block layout inside a box: nested containers already existed, but there was
  no way to split space by weight, pin a child to an edge, or tighten padding.
  Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
  glue), align (start/center/end/stretch on the cross axis) and pad
  (per-group interior padding). All three round-trip via dai_grow/dai_align/
  dai_pad markers.

- Inline rich text: labels already render HTML (html=1 on every style, esc()
  entities decode back), but the measure pass counted markup as text. The
  visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
  line, other inline tags are invisible.

Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.

Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 20:55:12 +09:00
parent e78322ca52
commit db9db1ff4e
9 changed files with 432 additions and 23 deletions

View File

@@ -98,6 +98,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
),
grow: z
.number()
.optional()
.describe(
"Flex-grow weight: this box takes that share of the parent's leftover space along its stacking axis. Omit for natural size",
),
align: z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe(
"Cross-axis position in the parent: start/end pin to an edge, stretch fills the axis (a divider or highlight bar spanning its card). Default center",
),
lane: z
.number()
.optional()
@@ -148,6 +160,24 @@ export const OperationSchema = z.discriminatedUnion("op", [
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
),
gap: z.number().optional(),
pad: z
.number()
.optional()
.describe(
"Interior padding px (default 24). Small values make tight cards; nest containers for internal structure",
),
grow: z
.number()
.optional()
.describe(
"Flex-grow weight: this container takes that share of the parent's leftover space. E.g. two columns with grow 2 and 1 split the width 2:1",
),
align: z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe(
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center",
),
after: z.string().optional(),
}),
z.object({
@@ -414,6 +444,10 @@ export function applyOperations(
...(op.stroke ? { stroke: op.stroke } : {}),
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...cellOf(op),
}
else if (op.op === "add_container")
@@ -427,6 +461,11 @@ export function applyOperations(
children: [],
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...(op.pad != null ? { pad: Math.max(0, op.pad) } : {}),
}
else if (op.op === "add_grid")
node = {