Phase 1

The Debt and the Monolith

01 — Three articles, three bugs

Every interactive article on this blog has a diagram. An architecture map. An execution flow. A dependency graph. Each started as a hand-crafted SVG component built specifically for the article it lived in — thrown together under deadline pressure, abandoned the moment the article shipped.

Look at the demo panel. Three diagrams from three different articles, all attempting the same thing: show a system architecture with labeled nodes and directed edges.

Before you toggle anything — just look. What's wrong? Not the visual inconsistency. Ignore the different radii, font stacks, color palettes. That's surface. Look deeper. Find the text that doesn't fit its box. Find the edge that lies about the topology. Find the node that promises interaction and delivers nothing.

Got your guesses? Toggle Highlight Issues in the demo panel.

02 — The text clipping problem

The first diagram clips its “Configuration Manager” label. The text is longer than the node box, and instead of wrapping, abbreviating, or expanding the box, it just vanishes mid-word.

Toggle Fix clipping in the Bug Fix Lab panel. Watch the node expand to fit the full label — the rect auto-sizes based on text length. Toggle it off to see the clipping return. That fix costs 15 lines of code.

This happens because of how SVG text measurement works. In HTML, a <div> grows to fit its content by default. In SVG, a <rect> has a fixed width and height. Text inside it does not cause the rect to resize. If the text is wider than the rect, it overflows — and if you have a clipPath (which you do, because overlapping text on neighboring nodes looks worse than clipped text), the overflow is silently amputated.

4 annotations
TSX
1// Article 1 — hand-crafted SVG, hardcoded widths
2const NODE_W = 72; // fits "Session", "Sandbox", "Cache"
3const NODE_H = 24;
4
5function renderNode(node: { label: string; x: number; y: number }) {
6 return (
7 <g>
8 <clipPath id={`clip-${node.label}`}>
9 <rect x={node.x - NODE_W / 2} y={node.y - NODE_H / 2}
10 width={NODE_W} height={NODE_H} />
11 </clipPath>
12 <rect x={node.x - NODE_W / 2} y={node.y - NODE_H / 2}
13 width={NODE_W} height={NODE_H} rx={2}
14 fill="var(--color-surface)" stroke="#888" />
15 <text x={node.x} y={node.y + 3} textAnchor="middle"
16 fontSize={9} clipPath={`url(#clip-${node.label})`}>
17 {node.label}
18 </text>
19 </g>
20 );
21}

The fix is straightforward: measure the text before rendering the box, or set the box width based on the label length. But when you are building a one-off SVG for an article about agent architecture, you are thinking about agent architecture, not about text measurement. The SVG is a means to an end. You hardcode a width that looks right for “Session” and “Sandbox” and forget that “Configuration Manager” exists until the article is live and someone screenshots the bug on Twitter.

This is the first pattern of bespoke component debt: the fix is known, but the incentive to apply it is misaligned. The article is the product. The diagram is infrastructure. And infrastructure built under article deadlines inherits article-quality engineering.

03 — The edge routing problem

The second diagram has an edge from Client to Notify that passes directly through the Auth node. The line connects the right endpoints — it starts at Client and ends at Notify — but the path is a straight line, and Auth sits between them.

3 annotations
TSX
1// Article 2 — straight-line edges, no obstacle avoidance
2function renderEdge(from: Node, to: Node) {
3 return (
4 <line
5 x1={from.x}
6 y1={from.y + from.h / 2}
7 x2={to.x}
8 y2={to.y - to.h / 2}
9 stroke="oklch(50% 0.1 180)"
10 strokeWidth={1.2}
11 />
12 );
13}
14
15// The edge from Client (40,22) to Notify (40,145)
16// passes straight through Auth at (40,72).
17// A human sees the overlap. The code does not.

A straight line from (x1, y1) to (x2, y2) is the simplest edge implementation. And for most diagrams it works — nodes are positioned so edges don't collide. But the moment a third node sits between two connected nodes, straight lines fail. You need either orthogonal routing (edges that make right-angle turns to avoid obstacles) or curved paths that arc around intermediate nodes.

Both are solved problems. ELK handles full edge routing; Dagre and d3-dag provide node layout with basic edge support. But including a layout library for a one-off SVG in a blog article feels like bringing a cannon to a knife fight. So you manually adjust node positions until the overlap is “acceptable” — which means “not visible at the default viewport width.” Resize the window and the overlap reappears.

This is the second pattern: the solution exists but the cost of integration exceeds the perceived value of the fix. A layout library is worth it for a reusable component. It is not worth it for a throwaway SVG that ships once.

04 — The interaction gap

The third diagram uses cursor: pointer on every node. It looks interactive — the cursor changes when you hover. But nothing happens when you click. No detail panel. No highlight. No state change. On mobile, there is no hover at all, so the nodes appear completely static.

The problem is that interaction requires state management. A “click to select” feature needs selectedId state, a click handler, visual differentiation between selected and unselected nodes, and an accessibility layer (keyboard navigation, focus management, ARIA attributes). For a single diagram, that's 40-60 lines of boilerplate that has nothing to do with the content of the article.

4 annotations
TSX
1// Article 3 — nodes look clickable but aren't
2<rect
3 x={node.x - NODE_W / 2}
4 y={node.y - NODE_H / 2}
5 width={NODE_W} height={NODE_H}
6 rx={12}
7 cursor="pointer" // ← promises interaction
8 // No onClick handler // ← doesn't deliver
9 // No onKeyDown // ← keyboard users ignored
10 // No role="button" // ← screen readers see a rect
11 // No tabIndex // ← can't focus with Tab
12 // No aria-label // ← no text alternative
13/>
14
15// What FlowDiagram does instead:
16<FlowNodeHitArea
17 node={node}
18 onSelect={select}
19 minTarget={44} // 44×44px touch target
20 role="button"
21 tabIndex={0}
22 aria-label={`${node.label}: ${node.brief}`}
23 onKeyDown={(e) => {
24 if (e.key === "Enter" || e.key === " ") select(node.id);
25 }}
26/>

Compare that to what the real FlowDiagram component does: FlowNodeHitArea wraps each node with a 44×44px minimum touch target, keyboard handlers for Enter and Space, focus management, and variant-driven styling. The consumer writes zero interaction code — they declare nodes, and the system handles selection, hover, dimming, and detail panels.

But this system did not exist when these articles were written. Each article's SVG was its own island. The interaction gap between “looks interactive” and “is interactive” was 60 lines of code that nobody wrote because the article was about something else.

This is the third pattern: interaction is infrastructure, and infrastructure is invisible until it breaks. The diagram in Article 3 “works” in the sense that it renders. It fails silently because nobody clicks it in review — they read the labels and move on. The missing interaction is discovered by readers who try to engage with something that visually promises engagement but doesn't deliver.

05 — The standardization instinct

Four articles shipped. Four SVG components abandoned the moment their article went live.

The third time I manually calculated SVG text bounding boxes, I stopped mid-function and counted the damage: text measurement — solved wrong in 2 out of 4 articles. Edge routing — solved wrong in 1, hacked around in 2. Touch targets — missing in 3 out of 4. Interaction state — implemented in 1, missing in 3. Four color palettes, three font stacks, two node shapes.

The same bugs, reincarnated across articles. We keep solving the same problem badly. Let's solve it once, well.

The instinct was to standardize. Build one component that handles everything: node shapes, edge routing, layout, interaction, animation, detail panels. Pass in definitions, get a diagram out. One component to rule them all.

Look at the demo panel — this is the configuration object for that monolith. Expand the nodes section, then edges, then layout. Watch the prop counter in the top corner. Each section you expand reveals more properties. The counter climbs.

06 — The configuration maze

Define nodes. Define edges. Define styles. The monolith renders a correct diagram. Text measurement — solved once. Edge routing — proper path computation. Touch targets — enforced. All four bespoke bugs, fixed by centralizing the rendering logic.

It works. Until you try to customize something.

Try this: In the demo panel, your goal is to change how the Session node animates. Expand the config tree and count how many levels deep you need to navigate to find the animation override. Keep count.

The path is nodeStyles → overrides → session → animationOverrides. Four levels deep.

4 annotations
TS
1// To change Session's entrance animation:
2const config = {
3 // ... 45 other properties ...
4 nodeStyles: {
5 default: {
6 fill: "var(--color-surface)",
7 stroke: "var(--color-border)",
8 // ... 8 other default props ...
9 },
10 overrides: {
11 session: { // level 2
12 animationOverrides: { // level 3
13 entrance: { // level 4
14 type: "spring",
15 stiffness: 300,
16 damping: 22,
17 },
18 // But does this conflict with transitionConfig?
19 // Does transitionConfig.defaultEasing apply first?
20 // What about transitionConfig.stagger?
21 // The answer: "it depends." ← not documented
22 },
23 },
24 },
25 },
26 transitionConfig: {
27 defaultEasing: "easeOut",
28 stagger: 0.06,
29 // Does this stagger delay apply to overridden nodes?
30 // Or only to nodes without animationOverrides?
31 // Neither the types nor the docs say.
32 },
33};

The cognitive cost of customization scales with the total surface area of the API, not with the size of your change. You wanted to tweak one node's entrance animation — now you are reading documentation about the global transition system to make sure your override doesn't conflict with transitionConfig.defaultEasing.

This is the core problem with monolithic component APIs: configurable is not composable. Configuration means “choose from the options I anticipated.” Composition means “combine pieces in ways I didn't anticipate.”

Think of it as the difference between a vending machine and a kitchen. A vending machine offers thirty choices. You press B7 and get exactly what the manufacturer put in slot B7. If you want something that's not in the machine, you are out of luck. A kitchen offers ingredients. You can make anything — including things the architect of the kitchen never imagined.

07 — The prop surface grows

Every time a consumer needed something the monolith didn't foresee, the answer was the same: add another prop.

  • Custom node shape? Add nodeStyles.overrides[id].shape with a union of supported shapes.
  • Animation on scroll instead of mount? Add nodeStyles.overrides[id].triggerMode: "scroll" | "mount".
  • Detail panel slides from the side? Add detailPanelPosition: "inline" | "side".
  • Different edge colors per connection? Add edgeStyles.overrides[edgeId].stroke.

Twenty props became thirty. Thirty became fifty. The TypeScript definition became a wall of optional properties, each interacting with three others in undocumented ways:

4 annotations
TS
1type DiagramConfig = {
2 nodes: NodeConfig[];
3 edges: EdgeConfig[];
4 layout: LayoutConfig;
5 nodeStyles: NodeStyleConfig;
6 edgeStyles: EdgeStyleConfig;
7 transitionConfig: TransitionConfig;
8 interaction: InteractionConfig;
9 accessibility: AccessibilityConfig;
10 // Added in v2:
11 timeline?: TimelineConfig;
12 // Added in v3:
13 annotations?: AnnotationConfig[];
14 // Added in v4:
15 groups?: GroupConfig[];
16 responsive?: ResponsiveConfig;
17};
18
19// Each sub-config has 5-15 properties:
20type NodeStyleConfig = {
21 default: {
22 fill?: string; stroke?: string; strokeWidth?: number;
23 rx?: number; shadow?: boolean; shape?: NodeShape;
24 fontSize?: number; fontWeight?: number; fontFamily?: string;
25 minWidth?: number; minHeight?: number;
26 };
27 overrides?: Record<string, Partial<NodeStyleConfig["default"]> & {
28 animationOverrides?: AnimationOverrides;
29 triggerMode?: "mount" | "scroll" | "visible";
30 }>;
31};

Predict: you set interaction.detailPanelPosition: "side", which moves the detail panel into a sidebar. This changes the available width for the SVG. But layout.nodeSeparation was tuned for inline panels. What happens?

The answer: “it depends.” And the documentation doesn't cover it. The props are organized by visual concern (nodes, edges, layout, interaction), but the interaction between them is combinatorial. layout.algorithm: "dagre" can conflict with manual node positions. transitionConfig.stagger can conflict with per-node animation overrides. Each interaction was added independently by a different consumer request, and nobody documented the cross- cutting combinations.

08 — The breaking point

The monolith had a high floor and a low ceiling.

High floor: simple diagrams were easy. Five nodes, five edges, done. The defaults handled the common case beautifully.

Low ceiling: the moment you needed anything beyond the defaults — a custom shape, a non-standard animation, a domain-specific interaction — you hit the ceiling hard. The path from “default” to “customized” ran through four levels of nested configuration, undocumented property interactions, and the nagging fear that your override would silently break something two sections away.

Then came the request that broke the model entirely: render a React component inside a node. Not a text label — an interactive mini-form with inputs and buttons. The monolith's rendering pipeline was data SVG rect SVG text. No insertion point for custom React. The only option was forking the component.

That failure produced the question that changed the architecture: what if the consumer doesn't configure a diagram but composes one?

Part 2.

Phase 2

The Compound Pattern

01 — Types as language

Part 1 ended with a question: what if the consumer doesn't configure a diagram but composes one?

The answer starts with types. Not TypeScript types as validation — types as a shared language between the consumer and the system. When you define a FlowNode, you're not filling out a form. You're describing an entity in a domain-specific vocabulary.

Look at the demo panel. The first layer — types — is highlighted. This is the foundation everything else builds on.

4 annotations
TS
1export type NodeShape =
2 | "rect"
3 | "pill"
4 | "diamond"
5 | "cylinder"
6 | "circle"
7 | "hexagon";
8
9export type NodeRole = "protagonist" | "supporting" | "context";
10
11export type FlowNode = {
12 id: string;
13 x: number;
14 y: number;
15 w?: number;
16 h?: number;
17 shape?: NodeShape;
18 role?: NodeRole;
19 label: string;
20 sublabel?: string;
21 brief?: string;
22
23 /** Replace the default SVG label with a full React component. */
24 render?: (props: NodeRenderProps) => ReactNode;
25
26 description?: string;
27 detail?: ReactNode;
28};

The monolith's types were configuration objects: property bags with optional fields that interacted in undocumented ways. The compound system's types are domain models: they describe what exists (nodes, edges, groups), what properties those things have (shape, role, label), and what escape hatches are available (the render prop). The types don't encode rendering decisions — they encode the vocabulary of the domain.

The NodeShape union is an example: six shapes, each a string literal. The consumer writes shape: "diamond". They don't write SVG path data. They don't write coordinate math. They name a concept, and the geometry layer translates it into math.

02 — Geometry as pure math

Here's a question: can you unit-test an SVG diagram without rendering anything? No DOM, no browser, no React — just inputs and assertions?

You can, if you isolate the math. The geometry layer is a module of pure functions — no React, no hooks, no DOM, no state. It takes coordinates and returns SVG path strings. In the demo panel, the geometry layer is now active. Those edges between nodes? Each one is the output of a function you could test in a plain .test.ts file.

4 annotations
TS
1function rectExit(
2 cx: number, cy: number,
3 hw: number, hh: number,
4 tx: number, ty: number,
5 gap: number,
6) {
7 const dx = tx - cx;
8 const dy = ty - cy;
9 if (dx === 0 && dy === 0) return { x: cx, y: cy };
10 const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
11 const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
12 const s = Math.min(sx, sy);
13 const d = Math.sqrt(dx * dx + dy * dy);
14 return {
15 x: cx + dx * s + (dx / d) * gap,
16 y: cy + dy * s + (dy / d) * gap,
17 };
18}
19
20export function nodeExit(n: FlowNode, tx: number, ty: number, gap: number) {
21 const shape = n.shape ?? "rect";
22 switch (shape) {
23 case "diamond": return diamondExit(n.x, n.y, w/2, h/2, tx, ty, gap);
24 case "circle": return circleExit(n.x, n.y, r, tx, ty, gap);
25 case "hexagon": return hexagonExit(n.x, n.y, w/2, h/2, tx, ty, gap);
26 default: return rectExit(n.x, n.y, w/2, h/2, tx, ty, gap);
27 }
28}

The key function is nodeExit: given a node's center point and a target point, compute where a line from center toward target exits the node boundary. This sounds simple for rectangles — it's ray-box intersection. But diamonds have angled edges. Circles have curved boundaries. Hexagons are close enough to circles that a radius approximation works, but each shape still needs its own exit-point dispatch.

The geometry module handles all of this behind a single dispatch function. The edge path generator calls nodeExit(from, to.x, to.y, gap) without knowing what shape the source node is. The shape-polymorphic dispatch is the first concrete benefit of the compound architecture: the edge system and the node system are decoupled through a pure function interface.

In the monolith, edge paths were computed inline in the render method, entangled with React state, animation timing, and DOM refs. Here, you can unit-test edge paths with nothing but numbers.

03 — Edge routing solved once

Remember the edge from Part 1 that passed straight through the Auth node? The bespoke fix was manual node repositioning — nudging pixels until the overlap wasn't visible at one viewport width. The monolith's fix was a layout algorithm config option buried four levels deep. The compound system's fix is a pure function.

4 annotations
TS
1function orthogonalPath(from: FlowNode, to: FlowNode): string {
2 const p1 = nodeExit(from, to.x, to.y, DEFAULTS.edgeGap);
3 const p2 = nodeExit(to, from.x, from.y, DEFAULTS.edgeGap);
4
5 const dx = Math.abs(p2.x - p1.x);
6 const dy = Math.abs(p2.y - p1.y);
7
8 if (dy >= dx) {
9 const midY = (p1.y + p2.y) / 2;
10 return `M ${p1.x} ${p1.y} L ${p1.x} ${midY} L ${p2.x} ${midY} L ${p2.x} ${p2.y}`;
11 }
12 const midX = (p1.x + p2.x) / 2;
13 return `M ${p1.x} ${p1.y} L ${midX} ${p1.y} L ${midX} ${p2.y} L ${p2.x} ${p2.y}`;
14}
15
16export function computeEdgePath(
17 from: FlowNode, to: FlowNode, route: EdgeRoute,
18): string {
19 switch (route) {
20 case "orthogonal": return orthogonalPath(from, to);
21 case "curved": return curvedPath(from, to);
22 case "arc": return arcPath(from, to);
23 default: return straightPath(from, to);
24 }
25}

Predict: for two vertically stacked nodes, does the orthogonal path go downacrossdown or acrossdownacross?

orthogonalPath takes two nodes and returns an SVG path with right-angle turns. The midpoint computation (midY = (p1.y + p2.y) / 2) places the bend at the vertical center between the two endpoints. Vertical stack: downacrossdown. Horizontal: acrossdownacross.

This isn't a layout algorithm — it's a path computation. The consumer still positions nodes manually. But the edges between them route cleanly without manual SVG path strings. The geometry module solves the overlap problem that bespoke Article 2 couldn't because it centralizes the math that was previously scattered across four independent implementations.

Try this: drag any node in the diagram. Every edge recalculates live — the exit-point math runs per frame. Open the Geometry Lab panel (below the diagram) and switch an edge between line and elbow routing to see the path recompute.

Then scroll to Node shapes — change Session from pill to diamond or circle. Watch the edge exit points jump to match the new boundary. That's nodeExit dispatching per-shape math: ray-box for rects, |x/hw|+|y/hh|=1 for diamonds, radius-based for circles.

Four edge route types — straight, orthogonal, curved, arc — share one entry point: computeEdgePath. The consumer writes route: "orthogonal" and gets clean paths. The geometry module handles the trig. (This demo shows straight and orthogonal — curved and arc are available in the full library.)

04 — Primitives as atoms

Above the geometry layer sit the rendering primitives: small, focused React components that each do one thing.

The demo panel now shows the primitives layer. The nodes are interactive — hover over them and you'll see the cursor change.

Try this: each node has an invisible hit area for touch accessibility. Can you spot them? Look for the faint dashed outlines around each node. Those 44×44px rects are FlowNodeHitArea in action — normally invisible, made visible here so you can see the interaction surface.

4 annotations
TSX
1export function FlowNodeHitArea({
2 node, onSelect, onHover, index, diagramId, children,
3}: {
4 node: ResolvedNode;
5 onSelect: (id: string) => void;
6 onHover: (id: string | null) => void;
7 index: number;
8 diagramId: string;
9 children: ReactNode;
10}) {
11 return (
12 <motion.g
13 initial={{ opacity: 0, y: 4 }}
14 animate={{ opacity: node.opacity, y: 0 }}
15 transition={{ ...SPRING.gentle, delay: index * STAGGER.fast }}
16 >
17 <g
18 onClick={(ev) => { ev.stopPropagation(); onSelect(node.id); }}
19 onMouseEnter={() => onHover(node.id)}
20 onMouseLeave={() => onHover(null)}
21 role="button"
22 tabIndex={node.interactive ? 0 : -1}
23 aria-label={`${node.label}: ${node.description ?? ""}`}
24 onKeyDown={(ev) => {
25 if (ev.key === "Enter" || ev.key === " ") onSelect(node.id);
26 }}
27 >
28 {/* Invisible 44×44 touch target */}
29 <rect
30 x={node.x - 22} y={node.y - 22}
31 width={44} height={44}
32 fill="transparent" stroke="none"
33 />
34 {children}
35 </g>
36 </motion.g>
37 );
38}

FlowNodeHitArea solves the interaction gap from bespoke Article 3. It wraps any child content (the shape, the label, whatever you put inside it) with:

  • A click handler that fires onSelect
  • Mouse enter/leave handlers for hover state
  • A role="button" and tabIndex for keyboard users
  • An aria-label composed from the node's label and description
  • A transparent 44×44px rect for WCAG-compliant touch targets
  • Staggered entrance animation that follows the semantic reading order

The consumer writes zero interaction code. They compose FlowNodeHitArea > FlowNodeShape > FlowNodeLabel and get a fully interactive, accessible, animated node. The 60 lines of boilerplate from Part 1's interaction gap — written once, tested once, applied everywhere.

05 — The primitive inventory

FlowNodeHitArea is one of eleven primitives. Each handles exactly one concern:

PrimitiveConcern
FlowMarkerDefsSVG arrow markers (normal, lit, error)
FlowNodeShapeShape rendering (rect, pill, diamond, cylinder, circle, hexagon)
FlowNodeLabelText or React content inside a node
FlowNodeHitAreaInteraction wrapper with accessibility
FlowArcIndicatorNumbered dot showing reading order
FlowEdgePathEdge path with variant-driven styling
FlowGroupBoxVisual containment boundary
FlowAnnotationTextFreeform text labels
FlowTokenAnimated position indicator for timelines
FlowDetailPanelNode detail display (outside SVG)
FlowTimelineControlsPlay/pause/step for timeline mode

Each primitive receives resolved state — it never computes visibility, variant, or dimensions itself. FlowNodeShape receives a ResolvedNode with variant, resolvedW, resolvedH, isProtagonist already computed. The primitive's job is pure rendering: turn resolved state into SVG elements.

This separation — hooks compute, primitives render — is what makes the system composable. You can replace any primitive without affecting the others. You can skip primitives you don't need. You can add new primitives that consume the same resolved state.

06 — Composition over configuration

The demo panel now shows all four layers together. Before you look at the code comparison below, make a prediction: which approach uses more lines of JSX — the monolith config object or the compound composition?

4 annotations
TSX
1// MONOLITH: Configure everything through one prop bag
2<DiagramMonolith
3 nodes={nodes}
4 edges={edges}
5 nodeStyles={{ default: { fill: "..." }, overrides: { session: { ... } } }}
6 layout={{ algorithm: "dagre", direction: "TB" }}
7 interaction={{ selectable: true, detailPanelPosition: "inline" }}
8 transitionConfig={{ defaultEasing: "easeOut", stagger: 0.06 }}
9/>
10
11// COMPOUND: Compose primitives freely
12<svg viewBox={viewBox}>
13 <FlowMarkerDefs id={id} />
14 {groups.map(g => <FlowGroupBox key={g.id} group={g} />)}
15 {edges.map(e => <FlowEdgePath key={e.key} edge={e} />)}
16 {nodes.map(n => (
17 <FlowNodeHitArea key={n.id} node={n} onSelect={select}>
18 <FlowNodeShape node={n} />
19 <FlowNodeLabel node={n} />
20 </FlowNodeHitArea>
21 ))}
22</svg>
23<FlowDetailPanel selectedNode={selectedNode} />

The monolith takes six top-level config objects with 80+ leaf properties. The compound approach renders eleven primitives in explicit order inside an SVG element. More JSX, yes — but the z-order is visible: groups (back) annotations edges nodes (front). The monolith hides this order inside implementation details. The compound version makes it part of the authoring surface.

Try this: remember the monolith's interaction.detailPanelPosition: "inline" | "side" prop? How would you achieve the same thing in the compound system? You wouldn't use a prop — you'd make a layout decision. Want the detail panel on the side? Render it in a sidebar <div>. Want it inline? Render it below the SVG. Want to skip it entirely? Don't render FlowDetailPanel.

The compound system doesn't prevent customization — it enables it by not having opinions about layout. The primitives render SVG elements. Where those elements live, how they're arranged, what surrounds them — that's the consumer's concern.

07 — The escape hatch

The monolith hit its breaking point when a consumer wanted to render a React component inside a node. The rendering pipeline was data SVG rect SVG text. No insertion point for custom React.

The compound system has render on FlowNode:

TS
1{
2 id: "metrics",
3 label: "Metrics",
4 x: 200, y: 100,
5 render: ({ node, variant }) => (
6 <div style={{ padding: 4, fontSize: 10 }}>
7 <strong>{node.label}</strong>
8 <MiniSparkline data={recentMetrics} />
9 </div>
10 ),
11}

FlowNodeLabel checks for the render prop. If present, it creates a foreignObject in the SVG and renders the React component inside it. The hit area, shape, animation, and accessibility layer still work — they wrap the foreignObject the same way they wrap SVG text.

This is the vending machine vs. kitchen distinction. The monolith offered thirty predefined node appearances. The compound system offers ingredients: a shape, a label renderer, a hit area. The consumer combines them. When the ingredients don't cover a case, the render prop is the escape hatch into full React rendering.

The compound system didn't anticipate sparklines inside nodes. It anticipated that it couldn't anticipate everything.

08 — What composition buys you

The compound architecture costs more upfront. Eleven primitives instead of one component. A geometry module. A type system. The consumer writes more JSX than a single <DiagramMonolith {...config} /> call.

But the tradeoffs favor composition at scale:

  1. No configuration ceiling. The monolith's ceiling was “what the props anticipate.” The compound system's ceiling is “what SVG and React can render.” There is no case that requires forking the system.

  2. Independent testing. Geometry is tested with numbers. Primitives are tested with resolved state objects. The hook is tested with definitions and assertions on resolved output. No end-to-end rendering needed for unit tests.

  3. Incremental adoption. A consumer can start with the composed FlowDiagram (Part 4) and drop down to primitives only when they need to. The convenience wrapper and the primitive layer coexist.

  4. Independent evolution. Adding a new node shape means adding a case to nodeExit in geometry and a case to FlowNodeShape in primitives. Nothing else changes. Adding a new edge animation means touching FlowEdgePath. The blast radius of every change is one layer.

But try this thought experiment: strip away every semantic enrichment — the protagonist emphasis, the reading order, the thesis, the relationship descriptions. Leave only coordinates, connections, and labels. You get a correct diagram. Five rectangles, five edges, accurate topology.

You also get something no reader will learn from. Correctness is table stakes. What turns a correct diagram into a teaching tool is the semantic layer.

Part 3.

Phase 3

The Semantic Layer

01 — The craft gap

Part 2 built a compound architecture: types, geometry, primitives. These layers produce structurally correct diagrams — nodes in the right places, edges between the right endpoints, shapes rendered accurately.

But correctness isn't craft. A diagram with five same-sized rectangles connected by unnamed arrows is technically correct. It renders without bugs. It's also useless as a teaching tool — there's no hierarchy, no reading order, no story. The reader's eye wanders across five equally-demanding elements with no guidance about where to start or what matters.

The gap between “structurally correct” and “teaches something” is the semantic layer.

02 — Syntactic vs. semantic

A syntactic diagram describes WHERE things go: coordinates, connections, labels. A semantic diagram describes WHAT things mean: which node is the protagonist, what the connections represent, what order to read them in.

The demo panel shows a bare diagram — syntactic data only. Five nodes, five edges, no hierarchy, no reading order.

Try this: look at the bare diagram. Before reading further, decide: which node should be the protagonist? What reading order would you choose? What information is missing from the edges? Hold those answers — by step 6, you'll see if the semantic layer agrees with your instincts.

The distinction matters because the visual treatment of a diagram should be derived from its meaning, not manually configured per-node. The monolith from Part 1 made you specify fill: "oklch(30% 0.08 260)" and strokeWidth: 1.8 on the protagonist. The semantic system makes you write role: "protagonist" and derives the visual treatment automatically.

This inversion — declare meaning, derive aesthetics — is what separates a design system from a styling library.

03 — Intent: why the diagram exists

Before you define a single node, answer this: why does this diagram exist?

Not “what does it show” — why. What should the reader understand after seeing it that they didn't understand before? If you can't answer in one sentence, the diagram is trying to do too much.

The intent dimension materializes this answer. Look at the demo panel — a thesis now appears above the diagram.

4 annotations
TS
1export type FlowDiagramDef = {
2 id: string;
3 title: string;
4 viewBox: string;
5
6 nodes: FlowNode[];
7 edges: FlowEdge[];
8 groups?: FlowGroup[];
9 annotations?: FlowAnnotation[];
10
11 // ── Semantic dimensions ──────────────────────
12 thesis: string; // Intent: why this diagram exists
13 protagonist?: string; // Hierarchy: what matters most
14 tension?: string; // Intent: the interesting question
15 arc?: string[]; // Path: reading order
16};

The thesis field is the only required semantic field on FlowDiagramDef. It forces the hardest question first.

The thesis is rendered as visible text — it's an advance organizer (Ausubel's learning theory). The reader sees the framework before they encounter details. Without it, they have no scaffold to hang new information on. With it, every click and hover reinforces a mental model they've already started building.

The optional tension field adds the interesting question: “Single coordination point — bottleneck or feature?” Tension gives the reader a reason to engage. A diagram without tension is information without a point.

04 — Hierarchy: what matters more

Watch the Session node in the demo panel. It just grew. Thicker stroke. Accent fill. Slightly larger text. Five small changes that happened simultaneously — and now your eye goes there first.

That's hierarchy. A role system — protagonist, supporting, context — creates a gradient of visual weight. Not all nodes are equally important, and the visual treatment should reflect that without the author touching a single pixel value.

4 annotations
TS
1export const DEFAULTS = {
2 role: {
3 protagonist: { scale: 1.15, strokeMultiplier: 1.8, fontMultiplier: 1.12 },
4 supporting: { scale: 1.0, strokeMultiplier: 1.0, fontMultiplier: 1.0 },
5 context: { scale: 0.9, strokeMultiplier: 0.8, fontMultiplier: 0.9 },
6 },
7};
8
9// In the hook:
10const effectiveRole = sceneContext.roleMap.get(n.id) ?? "supporting";
11const isProtagonist = n.id === sceneContext.protagonistId;
12const roleScale = DEFAULTS.role[effectiveRole].scale;
13
14return {
15 ...n,
16 resolvedW: resolveW(n, defaultW) * roleScale,
17 resolvedH: resolveH(n, defaultH) * roleScale,
18 isProtagonist,
19 effectiveRole,
20};

The role scale factors are small: protagonist at 1.15×, context at 0.9×. But these differences compound across every visual dimension simultaneously. The protagonist is 15% wider, has 80% thicker strokes, gets accent-colored fills, has 12% larger font, and receives a glow filter on hover. Five small differences that together create an unmistakable focal point.

The author never tweaks w: 92 vs w: 72. They write role: "protagonist" and the system applies the visual hierarchy. This means the hierarchy is consistent across every diagram in the system. Every protagonist looks like a protagonist. Every context node recedes the same way.

Typography taught this principle: headline size tells you what matters before you read a word. The same principle applies to node size.

Open the Hierarchy Lab below the dimension cards. Reassign any node's role — make Sandbox the protagonist, make Session context — and watch the visual weight redistribute. Slide protagonist scale to 1.5× for an exaggerated effect.

05 — Relationships: what the connections mean

Nodes are characters. Edges are the plot. A diagram with unlabeled arrows is a cast list with no script — you know who's involved but not what happens between them.

Relationships turn edges into narrative. The demo panel now shows labels on the edges. Click any node — the detail panel below composes a relationship view from its connections.

4 annotations
TS
1export type FlowEdge = {
2 from: string;
3 to: string;
4 label?: string; // visual: shown on the edge path
5 verb?: string; // semantic: "loads", "spawns", "executes"
6 description?: string; // narrative: human explanation
7 route?: EdgeRoute;
8 animate?: EdgeAnimate;
9 problem?: boolean;
10};
11
12// Auto-composed in the detail panel:
13const connections = edges
14 .filter(e => e.from === node.id || e.to === node.id)
15 .filter(e => e.description)
16 .map(e => ({
17 edge: e,
18 other: nodeMap[e.from === node.id ? e.to : e.from],
19 isOutgoing: e.from === node.id,
20 }));
21
22// Renders as:
23// → loads Skill — "loads instruction sets into the conversation"
24// → spawns Sandbox — "creates isolated execution environments"

Each edge carries three text layers:

  • label: the short form visible on the edge — .skill(), tool calls
  • verb: the action word — “loads”, “spawns”, “executes”
  • description: the human explanation — “loads instruction sets into the conversation”

The label serves the visual channel. The description serves the verbal channel. Together they achieve dual coding (Paivio) — two channels reinforcing the same message.

When a node is selected, the system auto-composes a relationship view from its connected edges. The reader sees not just “what is Session” but “how Session connects to everything else.” This creates shot/reverse shot (film editing) — understanding node A creates curiosity about node B.

The consumer writes the verb and description per edge. The system builds the relationship panel automatically. Zero custom rendering code per diagram.

06 — Path: how to read it

Where does your eye go after the protagonist? Without guidance, readers scan left-to-right, top-to-bottom — the default reading order of their language, not the conceptual order of the system. For a teaching diagram, that's a problem.

Path makes the reading order explicit. The demo panel now shows numbered indicators on each node, appearing in sequence.

An arc is a sequence of node IDs: ["session", "skill", "role", "task", "sandbox"]. When present:

  • Small numbered dots appear on nodes (the arc indicators)
  • The entrance animation staggers along the arc order
  • The reader has a visible journey to follow

Not every diagram needs an arc. Reference diagrams and comparison diagrams may work better with free exploration. But teaching diagrams — where concepts build on each other — always benefit from making the learning path explicit.

4 annotations
TS
1const sceneContext = useMemo((): SceneContext => {
2 const protagonistId = def.protagonist
3 ?? def.nodes.find(n => n.role === "protagonist")?.id
4 ?? null;
5
6 const arcMap = new Map<string, number>();
7 if (def.arc) {
8 def.arc.forEach((nodeId, idx) => arcMap.set(nodeId, idx));
9 }
10
11 const roleMap = new Map<string, NodeRole>();
12 for (const n of def.nodes) {
13 if (n.role) roleMap.set(n.id, n.role);
14 else if (n.id === protagonistId) roleMap.set(n.id, "protagonist");
15 else roleMap.set(n.id, "supporting");
16 }
17
18 // Stagger: arc first, then remaining nodes by position
19 const arcNodes = def.arc ?? [];
20 const nonArcNodes = def.nodes
21 .filter(n => !arcMap.has(n.id))
22 .sort((a, b) => a.y - b.y || a.x - b.x)
23 .map(n => n.id);
24 const staggerMap = new Map<string, number>();
25 [...arcNodes, ...nonArcNodes].forEach((id, idx) =>
26 staggerMap.set(id, idx)
27 );
28
29 return { protagonistId, arcMap, roleMap, staggerMap };
30}, [def]);

Scene resolution is where all four dimensions merge into a single computed analysis. The useMemo block computes:

  1. Protagonist — from the top-level field or the role property
  2. Arc map — positions for numbered indicators
  3. Role map — inferred from explicit roles and the protagonist
  4. Stagger map — arc nodes first, then remaining nodes by position

The stagger order is the bridge between author intent (arc) and renderer behavior (animation timing). The entrance animation tells a story: the protagonist appears first, then the supporting cast follows the reading order.

07 — Affordance: does it feel alive?

Click a node. Edges light up. The detail panel slides in. Everything else dims to 0.75 opacity. You didn't read documentation to learn this. The cursor changed to a pointer, and you tried it. The diagram invited you.

That invitation is affordance: visual and behavioral cues that tell the reader what's interactive before they try it. The demo panel is now fully alive — explore it.

4 annotations
TS
1export type NodeVariant =
2 | "idle" | "selected" | "hovered" | "dimmed"
3 | "active" | "visited" | "future" | "disabled" | "error";
4
5const VARIANT_OPACITY: Record<NodeVariant, number> = {
6 idle: 1, selected: 1, hovered: 1, dimmed: 0.75,
7 active: 1, visited: 0.7, future: 0.28, disabled: 0.2, error: 1,
8};
9
10function resolveNodeVariant(
11 nodeId: string,
12 selectedId: string | null,
13 hoveredId: string | null,
14 externalStates?: Record<string, NodeVariant>,
15): NodeVariant {
16 if (externalStates?.[nodeId]) return externalStates[nodeId];
17 if (nodeId === selectedId) return "selected";
18 if (nodeId === hoveredId) return "hovered";
19 if (selectedId !== null) return "dimmed";
20 return "idle";
21}

The variant system is the bridge between state and rendering. Nine node variants cover every interaction state: idle, selected, hovered, dimmed, active, visited, future, disabled, error. Each variant maps to an opacity, fill color, stroke color, and filter treatment. The renderer never computes these — it receives a variant and applies the lookup.

The dimmed variant is subtle but important. When anything is selected, everything else dims to 0.75 opacity. This is Shneiderman's focus-plus-context principle: the selected node is prominent, the rest recedes but remains visible. The reader can still see the graph topology while focusing on one node's details.

The future variant at 0.28 opacity is even more aggressive — used in timeline mode, where unreached nodes are suggested but not revealed. Progressive disclosure through opacity.

Affordance is mostly handled by the system's rendering defaults, not by per-diagram data. But the semantic data feeds into it: the protagonist gets the strongest hover signal, the richest edge descriptions produce the most rewarding detail panel on click.

08 — Dimensions compose

The five dimensions are not a checklist of independent features. They form a reinforcing loop:

  • Intent shapes what hierarchy should emphasize — “Session is the hub” means Session should be the focal point
  • Hierarchy shapes which elements get the strongest affordance — the protagonist gets the most inviting hover state
  • Affordance shapes how interaction reveals relationships — clicking the protagonist shows its rich connections
  • Relationships shape the natural path“Session connects to Skill” creates curiosity about Skill next
  • Path completes the intent — after following the arc, the reader understands the thesis

When every visual decision is backed by multiple dimensions pointing to the same conclusion, the result feels intentional. A node isn't big for one reason — it's big because of its role AND its arc position AND its connection count.

Try this: the colored dots in the demo header are now clickable. Toggle each dimension off one at a time. Which removal hurts the most? For most readers, turning off hierarchy (the purple dot) causes the sharpest loss — the protagonist disappears and every node competes equally for attention. Try it.

A diagram where the dimensions conflict — where the hierarchy says “look here” but the arc says “start there” — feels dissonant. The system prevents this by deriving visuals from the semantic data, not from independent manual choices.

The compound architecture (Part 2) provides the ingredients. The semantic layer provides the recipes. Part 4 shows how the hook and the composed component assemble everything — and the quality spectrum from bare-minimum to fully-semantic.

Phase 4

The Assembly

01 — The resolution step

Remember the three bugs from Part 1? The clipped text, the edge through Auth, the cursor that promised interaction and delivered nothing. Every one of those bugs came from the same root cause: rendering logic entangled with article-specific code, solved ad hoc, never reused.

The hook is what prevents that from ever happening again. Look at the demo panel — it starts in bare mode, syntactic data only. The diagram renders correctly (all three Part 1 bugs are structurally impossible here), but it's flat and lifeless.

The hook is the machine that turns definitions into resolved state. It takes a FlowDiagramDef (the raw data) and produces ResolvedNode[], ResolvedEdge[], and ResolvedGroup[] — arrays of objects enriched with variants, opacities, dimensions, and semantic flags. The primitives from Part 2 consume these resolved objects directly.

02 — The hook architecture

useFlowDiagram is the central coordination point. It manages three concerns that would otherwise be entangled:

  1. Selection state — controlled or uncontrolled, with hover tracking
  2. Scene context — one-time semantic analysis (protagonist, roles, arc, stagger order)
  3. Resolution — per-render transformation of raw data + state into visual output
4 annotations
TS
1export function useFlowDiagram(
2 def: FlowDiagramDef,
3 options?: UseFlowDiagramOptions,
4): UseFlowDiagramReturn {
5 // ── Selection state (controlled or uncontrolled) ──
6 const [internalSelectedId, setInternalSelectedId] = useState(null);
7 const selectedId = options?.selectedId ?? internalSelectedId;
8
9 // ── Scene context (semantic analysis) ──
10 const sceneContext = useMemo(() => /* ... */, [def]);
11
12 // ── Timeline integration ──
13 const timeline = useFlowTimeline(def.timeline ?? null);
14 const mergedNodeStates = useMemo(() => {
15 if (!timeline?.isActive) return options?.nodeStates;
16 // Merge timeline states: active, visited, future, disabled
17 }, [timeline, options?.nodeStates]);
18
19 // ── Resolution: data + state → visual output ──
20 const resolvedNodes = useMemo(() =>
21 def.nodes.map(n => ({
22 ...n,
23 variant: resolveNodeVariant(n.id, selectedId, hoveredId, mergedNodeStates),
24 resolvedW: resolveW(n) * roleScale,
25 opacity: VARIANT_OPACITY[variant],
26 isProtagonist: n.id === sceneContext.protagonistId,
27 })),
28 [def.nodes, selectedId, hoveredId, mergedNodeStates, sceneContext]);
29
30 return { selectedId, resolvedNodes, resolvedEdges, resolvedGroups, ... };
31}

The hook follows the React controlled/uncontrolled pattern. Pass selectedId and onSelect to control selection from outside — the hook becomes a pure state transformer. Omit them and the hook manages its own selection state internally. Same hook, two integration modes, zero configuration flags.

Scene context is computed once from the definition (via useMemo). It contains the protagonist ID, role map, arc map, and stagger order — the semantic analysis from Part 3 materialized as lookup tables. This computation doesn't re-run on selection changes — it only re-runs when the diagram definition changes.

Resolution happens every render: for each node, the hook resolves its variant (selected, hovered, dimmed, idle), computes its display dimensions (base size × role scale), and flags whether it's the protagonist. For each edge, it resolves the SVG path, midpoint, and variant. The output is ready for primitives to render — no computation left for the rendering layer.

03 — From bare to semantic

Try this: the three dots in the demo header are clickable — they switch between bare, semantic, and full modes. Before you click the middle dot (semantic), predict: how many visible changes will you count? Write a number.

Now click. Session swells. A thesis appears. Edges grow labels. Clicking a node reveals its relationships. How many did you count? Most people underestimate by half — the semantic layer touches more surfaces than you'd expect from “just adding some metadata.” Click the first dot to go back to bare, then the third to jump to full. Feel the spectrum.

For granular control, open the Semantic Fields panel. Toggle individual fields — Thesis, Roles, Verbs, Arc, Tension — on and off independently. Each combination reveals exactly what each semantic field contributes to the diagram's teaching quality.

Now notice what didn't change: the code. Same hook, same primitives, same component. The only difference is the data — thesis, protagonist, role, verb, description fields added to the definition. The hook reads these fields and produces richer resolved state. The primitives render it identically. They don't know whether the data is bare or semantic.

This is the core design principle: the system rewards richer data with richer rendering. A bare definition with just nodes and edges renders correctly. A semantic definition with all five dimensions renders beautifully. The quality difference is in the data, not the rendering code. No gatekeeping — just a gradient from minimal to rich.

04 — The composed component

The FlowDiagram component is a convenience wrapper. It calls useFlowDiagram, then renders every primitive in the correct order. For 80% of use cases, it's the only import you need.

4 annotations
TSX
1export function FlowDiagram({ children, ...def }: FlowDiagramProps) {
2 const flow = useFlowDiagram(def, options);
3
4 return (
5 <div className="my-8">
6 {/* Thesis — always visible orientation */}
7 {def.thesis && <div className="...">{def.thesis}</div>}
8 {def.tension && <div className="...">{def.tension}</div>}
9
10 {/* SVG canvas */}
11 <svg viewBox={def.viewBox} onClick={() => flow.clearSelection()}>
12 <FlowMarkerDefs id={def.id} />
13 {flow.resolvedGroups.map(g => <FlowGroupBox key={g.id} group={g} />)}
14 {flow.resolvedEdges.map(e => <FlowEdgePath key={e.key} edge={e} />)}
15 {flow.resolvedNodes.map(n => (
16 <FlowNodeHitArea key={n.id} node={n} onSelect={flow.select}>
17 <FlowNodeShape node={n} />
18 <FlowNodeLabel node={n} />
19 </FlowNodeHitArea>
20 ))}
21 </svg>
22
23 {/* Detail panel + Timeline controls */}
24 <FlowDetailPanel selectedNode={selectedNode} />
25 {flow.timeline && <FlowTimelineControls timeline={flow.timeline} />}
26 </div>
27 );
28}

The component is deliberately thin — roughly 70 lines of JSX that assemble the primitives. It doesn't contain logic beyond “if timeline exists, show timeline controls.” All decisions about variants, dimensions, opacity, and stagger order were made by the hook. The component is a rendering template.

This thinness is intentional. A thick composed component would re-create the monolith's problem: all rendering decisions in one place, no escape hatch for customization. By keeping the component thin and the hook rich, consumers can drop down to the hook level when the composed component doesn't fit.

The demo shows semantic quality — thesis, protagonist emphasis, edge labels, variant-driven interaction. Click the third dot in the header to preview full mode with arc indicators and tension. Every Part 1 bug is fixed regardless of quality level. Every visual decision is derived from semantic data. The consumer wrote zero rendering code.

05 — Level 0: Drop-in

The composed component supports four levels of usage, each adding more control. Level 0 is the simplest: one line, everything handled.

TS
1<FlowDiagram {...diagramDef} />

The component renders the thesis, the SVG canvas, the detail panel, and the timeline controls (if present). Selection and hover are managed internally. The consumer provides data and gets a complete interactive diagram.

4 annotations
TSX
1// ── Level 0: Drop-in ──
2// "I just want a diagram."
3<FlowDiagram {...diagramDef} />
4
5// ── Level 1: Custom detail panel ──
6// "I want to control what appears when nodes are selected."
7<FlowDiagram {...diagramDef}>
8 {(node) => <MyCustomPanel node={node} />}
9</FlowDiagram>
10
11// ── Level 2: Controlled selection ──
12// "I want the diagram to sync with external state."
13<FlowDiagram {...diagramDef}
14 selectedId={activeNodeId}
15 onSelect={setActiveNodeId}
16/>
17
18// ── Level 3: Full custom rendering ──
19// "I want to use the hook directly."
20const flow = useFlowDiagram(diagramDef);
21return (
22 <svg viewBox={diagramDef.viewBox}>
23 {flow.resolvedEdges.map(e => <MyCustomEdge edge={e} />)}
24 {flow.resolvedNodes.map(n => <MyCustomNode node={n} />)}
25 </svg>
26);

Level 0 covers most use cases: blog diagrams, documentation illustrations, architecture maps. The consumer focuses on the data — the five semantic dimensions from Part 3 — and the system handles everything else.

Test your understanding: you're building a blog post with a simple architecture diagram. No external state, no custom detail panels, no sidebar integration. Which level? If you said Level 0, you're right — and you'd write exactly one line of JSX.

The demo panel shows the Level 0 indicator.

06 — Level 1: Custom detail panel

Level 1 adds a render prop for the detail panel:

TS
1<FlowDiagram {...diagramDef}>
2 {(node) => (
3 <div>
4 <h3>{node.label}</h3>
5 <CodeAnnotator blockId={`detail-${node.id}`} />
6 </div>
7 )}
8</FlowDiagram>

The composed component still handles the SVG, selection, and animation. But the detail panel content is yours. The child function receives a ResolvedNode with all computed state — variant, dimensions, protagonist flag. You can render anything: code blocks, charts, forms, embedded playgrounds.

This is the first customization point where the consumer writes React beyond data. The SVG rendering remains unchanged — only the detail panel is replaced. The cost of customization is proportional to the scope of the change.

07 — Level 2: Controlled selection

Level 2 lifts selection state to the parent:

TS
1const [activeId, setActiveId] = useState<string | null>(null);
2
3<FlowDiagram
4 {...diagramDef}
5 selectedId={activeId}
6 onSelect={setActiveId}
7/>
8
9{/* External controls can now drive the diagram */}
10<button onClick={() => setActiveId("session")}>Focus Session</button>

Test your understanding: your tutorial has a sidebar with “Step 1”, “Step 2”, “Step 3” buttons. Clicking a button should highlight the corresponding node in the diagram. Which level do you need? You need external state driving selection — that's Level 2.

Now the diagram's selection state is synchronized with external UI: sidebar navigation, URL parameters, search results, tutorial steps. The diagram becomes a controlled component — the same pattern as <input value={v} onChange={setV} />.

The hook handles this transparently. When selectedId is provided, internal selection state is bypassed. The resolution pipeline runs identically whether selection is internal or external. The consumer doesn't need to know which mode is active — the hook's output is the same.

08 — Level 3: Full custom

Level 3 uses the hook directly, bypassing the composed component entirely:

TS
1const flow = useFlowDiagram(diagramDef);
2
3return (
4 <svg viewBox={diagramDef.viewBox}>
5 {flow.resolvedEdges.map(e => <MyCustomEdge edge={e} />)}
6 {flow.resolvedNodes.map(n => <MyCustomNode node={n} />)}
7 </svg>
8);

You get resolved nodes and edges with variants, opacities, and dimensions computed — but render them however you want. Canvas instead of SVG. WebGL. A table view. ASCII art. The hook is the kitchen; the composed component was just one recipe.

3 annotations
TS
1// ── Bare minimum (syntactic only) ──────────────
2const bare: FlowDiagramDef = {
3 id: "basic",
4 title: "System",
5 thesis: "A basic system diagram",
6 viewBox: "0 0 400 200",
7 nodes: [
8 { id: "a", x: 80, y: 100, label: "Service A" },
9 { id: "b", x: 200, y: 100, label: "Service B" },
10 { id: "c", x: 320, y: 100, label: "Service C" },
11 ],
12 edges: [{ from: "a", to: "b" }, { from: "b", to: "c" }],
13};
14// Result: Three same-sized nodes, two unnamed edges.
15// Dev console: ⚠ No protagonist. ⚠ 2/2 edges lack verb.
16
17// ── Fully semantic ────────────────────────────────
18const rich: FlowDiagramDef = {
19 id: "architecture",
20 title: "Agent Architecture",
21 thesis: "All five primitives communicate through Session",
22 tension: "Single coordination point — bottleneck or feature?",
23 protagonist: "session",
24 arc: ["session", "skill", "role", "task", "sandbox"],
25 viewBox: "0 0 400 280",
26 nodes: [
27 { id: "session", role: "protagonist", label: "Session",
28 brief: "Message history with built-in compaction",
29 description: "Central state object managing conversation turns..." },
30 { id: "skill", role: "supporting", label: "Skill",
31 brief: "Markdown instruction sets",
32 description: "Loaded at session start from .md files..." },
33 // ... 3 more nodes with brief + description
34 ],
35 edges: [
36 { from: "session", to: "skill", label: ".skill()",
37 verb: "loads",
38 description: "loads markdown instruction sets into the conversation" },
39 // ... each edge with verb + description
40 ],
41};
42// Result: Session 15% larger with glow. Numbered indicators.
43// Rich detail panel on click. Zero dev warnings.

The quality spectrum is the thesis of the entire series.

Part 1 showed the cost of bespoke: the same bugs reincarnated across four articles because the rendering logic was entangled with the content. Part 2 decomposed the monolith into composable layers — types, geometry, primitives — so the text clipping, edge overlap, and interaction gap could never happen again. Part 3 added meaning to structure — five semantic dimensions that turn a correct diagram into a teaching tool. And this part assembled the pipeline that connects them.

The architecture's goal was never to make diagrams easier. It was to make the gap between “correct” and “good” proportional to the effort of describing meaning — not the effort of writing rendering code.

The bare minimum works. The fully semantic version teaches. And the four levels of usage ensure the cost of customization is always proportional to the scope of the change. One line for a standard diagram. A hook call for something no one anticipated.

1234
API GatewayConfiguration ManagerDatabaseCacheLogger
Article 1
ClientServerAuthStoreNotify
Article 2
EntryParserValidateTransformOutput
Article 3
issues
bug fixes
+15
+25
+45
Toggle Highlight Issues — then use the Bug Fix toggles below