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.
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.
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.
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.
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].shapewith 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:
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.
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.
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.
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.
Predict: for two vertically stacked nodes, does the orthogonal path go down⟶across⟶down or across⟶down⟶across?
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: down⟶across⟶down. Horizontal: across⟶down⟶across.
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.
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"andtabIndexfor keyboard users - An
aria-labelcomposed 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:
| Primitive | Concern |
|---|---|
FlowMarkerDefs | SVG arrow markers (normal, lit, error) |
FlowNodeShape | Shape rendering (rect, pill, diamond, cylinder, circle, hexagon) |
FlowNodeLabel | Text or React content inside a node |
FlowNodeHitArea | Interaction wrapper with accessibility |
FlowArcIndicator | Numbered dot showing reading order |
FlowEdgePath | Edge path with variant-driven styling |
FlowGroupBox | Visual containment boundary |
FlowAnnotationText | Freeform text labels |
FlowToken | Animated position indicator for timelines |
FlowDetailPanel | Node detail display (outside SVG) |
FlowTimelineControls | Play/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?
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:
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:
-
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.
-
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.
-
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. -
Independent evolution. Adding a new node shape means adding a case to
nodeExitin geometry and a case toFlowNodeShapein primitives. Nothing else changes. Adding a new edge animation means touchingFlowEdgePath. 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.
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.
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.
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.
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.
Scene resolution is where all four dimensions merge into a single computed analysis. The useMemo block computes:
- Protagonist — from the top-level field or the role property
- Arc map — positions for numbered indicators
- Role map — inferred from explicit roles and the protagonist
- 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.
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.
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:
- Selection state — controlled or uncontrolled, with hover tracking
- Scene context — one-time semantic analysis (protagonist, roles, arc, stagger order)
- Resolution — per-render transformation of raw data + state into visual output
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.
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.
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.
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:
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:
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:
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.
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.