Part IV: The TOC Machine Cluster -- When Simple Isn't Enough
The table of contents sidebar looks simple. Click an item, it highlights. Expand a section, its children appear. Hover long enough, a tooltip shows. Scroll down, breadcrumbs appear. Navigate to a new page, the sidebar scrolls to show the active item.
Behind that simplicity: five state machines, two geometry injection patterns, three timer-driven animations, and an 8-state scroll orchestration machine that handles user scroll interruption.
This is the complexity climax of the series. Parts II and III introduced machines with 2-5 states. This part reaches 8 states in a single machine (TocScrollMachine) and shows how five independent machines compose to create the sidebar experience.
We go from simple to complex: tooltip (30 lines of logic), breadcrumbs (pure functions, no machine needed), expand/collapse (Map-based tracking), headings panel (CSS animation as state), and finally the scroll orchestrator (the most complex machine in the project).
TocTooltipMachine: The Warm-Up (30 Lines of Logic)
Hover over a TOC item long enough and a tooltip appears. Move the mouse away and it vanishes. Three states:
The machine never calls setTimeout. It uses timer injection:
export interface TocTooltipCallbacks {
onShow: (data: TocTooltipData) => void;
onHide: () => void;
onSchedule: (delayMs: number) => void;
onCancel: () => void;
}export interface TocTooltipCallbacks {
onShow: (data: TocTooltipData) => void;
onHide: () => void;
onSchedule: (delayMs: number) => void;
onCancel: () => void;
}The machine says "please schedule something in 650ms" via onSchedule. The wiring layer calls setTimeout. When the timer fires, the wiring layer calls machine.show(). The machine transitions from waiting to visible and calls onShow(data).
In tests, there's no timer at all:
it('should show tooltip after delay', () => {
const { machine, callbacks } = setup();
machine.pointerEnter({ title: 'About', rect: { right: 100, top: 50, height: 20 } });
expect(machine.getState()).toBe('waiting');
expect(callbacks.onSchedule).toHaveBeenCalledWith(650);
// Simulate timer firing
machine.show();
expect(machine.getState()).toBe('visible');
expect(callbacks.onShow).toHaveBeenCalled();
});it('should show tooltip after delay', () => {
const { machine, callbacks } = setup();
machine.pointerEnter({ title: 'About', rect: { right: 100, top: 50, height: 20 } });
expect(machine.getState()).toBe('waiting');
expect(callbacks.onSchedule).toHaveBeenCalledWith(650);
// Simulate timer firing
machine.show();
expect(machine.getState()).toBe('visible');
expect(callbacks.onShow).toHaveBeenCalled();
});No timer mocking. No fake clocks. The test calls show() directly. The machine doesn't care who calls it or when.
The full factory function:
export function createTocTooltipMachine(
callbacks: TocTooltipCallbacks,
config: TocTooltipConfig = DEFAULT_TOC_TOOLTIP_CONFIG,
): TocTooltipMachine {
let state: TocTooltipState = 'hidden';
let pendingData: TocTooltipData | null = null;
function pointerEnter(data: TocTooltipData): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
pendingData = data;
state = 'waiting';
callbacks.onSchedule(config.showDelay);
}
function pointerLeave(): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
state = 'hidden';
pendingData = null;
}
function show(): void {
if (state !== 'waiting' || !pendingData) return;
state = 'visible';
callbacks.onShow(pendingData);
}
return {
pointerEnter, pointerLeave, show,
getState: () => state,
getData: () => pendingData,
};
}export function createTocTooltipMachine(
callbacks: TocTooltipCallbacks,
config: TocTooltipConfig = DEFAULT_TOC_TOOLTIP_CONFIG,
): TocTooltipMachine {
let state: TocTooltipState = 'hidden';
let pendingData: TocTooltipData | null = null;
function pointerEnter(data: TocTooltipData): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
pendingData = data;
state = 'waiting';
callbacks.onSchedule(config.showDelay);
}
function pointerLeave(): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
state = 'hidden';
pendingData = null;
}
function show(): void {
if (state !== 'waiting' || !pendingData) return;
state = 'visible';
callbacks.onShow(pendingData);
}
return {
pointerEnter, pointerLeave, show,
getState: () => state,
getData: () => pendingData,
};
}Notice how pointerEnter() handles re-hovering a different item while the tooltip is already showing: it cancels/hides the current state, stores the new data, and re-enters waiting. No special "re-hover" state needed -- the transitions handle it naturally.
A pure helper computes the tooltip position:
export function computeTooltipPosition(
targetRect: { right: number; top: number; height: number },
): { left: number; top: number } {
return {
left: targetRect.right + 10,
top: targetRect.top + targetRect.height / 2,
};
}export function computeTooltipPosition(
targetRect: { right: number; top: number; height: number },
): { left: number; top: number } {
return {
left: targetRect.right + 10,
top: targetRect.top + targetRect.height / 2,
};
}Right of the target element, vertically centered. Pure math, no DOM.
TocBreadcrumbMachine: When You Don't Need a Machine
The breadcrumb trail shows ancestor items that have scrolled out of view. When you scroll down the TOC, breadcrumbs appear at the top showing where you are in the hierarchy.
This doesn't need a state machine. It needs two pure functions:
export interface BreadcrumbLevel {
label: string;
icon: string;
scrollTargetId: string;
}
export interface BreadcrumbState {
levels: BreadcrumbLevel[];
}
export function computeBreadcrumbs(hiddenAncestors: BreadcrumbLevel[]): BreadcrumbState {
return { levels: hiddenAncestors };
}
export function breadcrumbsEqual(a: BreadcrumbState, b: BreadcrumbState): boolean {
if (a.levels.length !== b.levels.length) return false;
for (let i = 0; i < a.levels.length; i++) {
if (a.levels[i]!.label !== b.levels[i]!.label) return false;
if (a.levels[i]!.scrollTargetId !== b.levels[i]!.scrollTargetId) return false;
}
return true;
}export interface BreadcrumbLevel {
label: string;
icon: string;
scrollTargetId: string;
}
export interface BreadcrumbState {
levels: BreadcrumbLevel[];
}
export function computeBreadcrumbs(hiddenAncestors: BreadcrumbLevel[]): BreadcrumbState {
return { levels: hiddenAncestors };
}
export function breadcrumbsEqual(a: BreadcrumbState, b: BreadcrumbState): boolean {
if (a.levels.length !== b.levels.length) return false;
for (let i = 0; i < a.levels.length; i++) {
if (a.levels[i]!.label !== b.levels[i]!.label) return false;
if (a.levels[i]!.scrollTargetId !== b.levels[i]!.scrollTargetId) return false;
}
return true;
}That's the entire file. 43 lines including the JSDoc and imports.
computeBreadcrumbs() is called by the wiring layer on every TOC scroll event. The wiring layer determines which ancestor items are above the viewport, passes them in, and gets back the breadcrumb state. breadcrumbsEqual() compares old and new breadcrumb states to avoid unnecessary DOM updates.
The lesson: Not every behavior needs a state machine. If the output depends only on the current input (not on previous state), pure functions are simpler and more testable. The breadcrumbs don't have states -- they have values that are recomputed on every scroll event.
How do you decide? Ask: "Does the behavior depend on what happened before, or only on what's happening now?"
- Tooltip: depends on what happened before (pointer entered, timer started). → State machine.
- Breadcrumbs: depends only on what's happening now (which ancestors are hidden). → Pure function.
TocExpandMachine: Map-Based Section Tracking
Each section of the TOC can be independently expanded or collapsed. The TocExpandMachine tracks all sections using a Map:
export function createTocExpandMachine(sections: TocSection[]): TocExpandMachine {
const state = new Map<string, TocExpandSectionState>();
const expandItems = new Map<string, boolean>();
for (const section of sections) {
state.set(section.id, 'closed');
expandItems.set(section.id, section.hasExpandItem);
}
// ...
}export function createTocExpandMachine(sections: TocSection[]): TocExpandMachine {
const state = new Map<string, TocExpandSectionState>();
const expandItems = new Map<string, boolean>();
for (const section of sections) {
state.set(section.id, 'closed');
expandItems.set(section.id, section.hasExpandItem);
}
// ...
}Each section starts closed. The expandItems map tracks which sections have an associated "expand item" -- a link that navigates to a page when clicked, rather than toggling the section.
The toggle() method handles this distinction:
function toggle(sectionId: string): ToggleResult {
assertKnown(sectionId);
// Section with expandItem: navigate instead of toggling
if (expandItems.get(sectionId)) {
return { open: state.get(sectionId)! === 'open', navigate: sectionId };
}
// Toggle open/closed
const current = state.get(sectionId)!;
const next: TocExpandSectionState = current === 'open' ? 'closed' : 'open';
state.set(sectionId, next);
return { open: next === 'open' };
}function toggle(sectionId: string): ToggleResult {
assertKnown(sectionId);
// Section with expandItem: navigate instead of toggling
if (expandItems.get(sectionId)) {
return { open: state.get(sectionId)! === 'open', navigate: sectionId };
}
// Toggle open/closed
const current = state.get(sectionId)!;
const next: TocExpandSectionState = current === 'open' ? 'closed' : 'open';
state.set(sectionId, next);
return { open: next === 'open' };
}The return type is ToggleResult:
export interface ToggleResult {
open: boolean;
navigate?: string; // If present, navigate to this page instead of toggling
}export interface ToggleResult {
open: boolean;
navigate?: string; // If present, navigate to this page instead of toggling
}This is a tagged result pattern. The caller checks for navigate:
// In the wiring layer
const result = tocExpand.toggle(sectionId);
if (result.navigate) {
spaNav.navigate(result.navigate, currentPath, null, result.navigate);
} else {
sectionEl.classList.toggle('open', result.open);
}// In the wiring layer
const result = tocExpand.toggle(sectionId);
if (result.navigate) {
spaNav.navigate(result.navigate, currentPath, null, result.navigate);
} else {
sectionEl.classList.toggle('open', result.open);
}The machine decides what to do (navigate vs toggle). The wiring layer does it.
The activateItem() method auto-opens the section containing the active page:
function activateItem(sectionId: string): void {
assertKnown(sectionId);
state.set(sectionId, 'open');
}function activateItem(sectionId: string): void {
assertKnown(sectionId);
state.set(sectionId, 'open');
}When the user navigates to a new page, the wiring layer calls activateItem() with the section containing the new page. The section opens automatically. No DOM. No animation. The machine just sets the state. The wiring layer reads getAllOpen() to determine which sections need the open CSS class.
HeadingsPanelMachine: CSS Animation as State
When you click a TOC section, an inner panel expands to show the headings within that section. The panel has a max height constraint (it can't take more than half the TOC viewport), and it animates open and closed with CSS transitions.
The key insight: CSS animations are states, not timers. The machine doesn't set a 300ms timeout. It enters opening or closing and waits for the DOM adapter to call transitionEnd() when the CSS transition completes.
Geometry Injection
The machine needs to compute the panel's max height. But it doesn't have access to the DOM. So geometry is injected as a plain data object:
export interface PanelGeometry {
tocHeight: number; // Total height of the TOC sidebar viewport
itemHeight: number; // Height of the active TOC item (parent of the panel)
headingCount: number; // Number of headings in the panel
headingRowHeight: number; // Height of a single heading row (average)
}
export interface ActiveHeadingGeometry {
offsetTop: number; // Offset of heading from panel scroll container top
height: number; // Height of the heading element
panelScrollTop: number; // Current scrollTop of the panel
panelVisibleHeight: number; // Visible height of the panel
}export interface PanelGeometry {
tocHeight: number; // Total height of the TOC sidebar viewport
itemHeight: number; // Height of the active TOC item (parent of the panel)
headingCount: number; // Number of headings in the panel
headingRowHeight: number; // Height of a single heading row (average)
}
export interface ActiveHeadingGeometry {
offsetTop: number; // Offset of heading from panel scroll container top
height: number; // Height of the heading element
panelScrollTop: number; // Current scrollTop of the panel
panelVisibleHeight: number; // Visible height of the panel
}The wiring layer reads DOM measurements and packages them into these interfaces:
// In the wiring layer
const geometry: PanelGeometry = {
tocHeight: tocEl.clientHeight,
itemHeight: itemEl.clientHeight,
headingCount: headings.length,
headingRowHeight: 28,
};
headingsPanel.open(geometry);// In the wiring layer
const geometry: PanelGeometry = {
tocHeight: tocEl.clientHeight,
itemHeight: itemEl.clientHeight,
headingCount: headings.length,
headingRowHeight: 28,
};
headingsPanel.open(geometry);The machine receives pure data. It computes the max height using a pure function:
export function computeMaxHeight(
tocHeight: number,
itemHeight: number,
constraints: PanelConstraints = DEFAULT_CONSTRAINTS,
): number {
const available = tocHeight - itemHeight;
const maxFromFraction = tocHeight * constraints.maxHeightFraction;
return Math.max(constraints.minHeight, Math.min(available, maxFromFraction));
}export function computeMaxHeight(
tocHeight: number,
itemHeight: number,
constraints: PanelConstraints = DEFAULT_CONSTRAINTS,
): number {
const available = tocHeight - itemHeight;
const maxFromFraction = tocHeight * constraints.maxHeightFraction;
return Math.max(constraints.minHeight, Math.min(available, maxFromFraction));
}The logic: the panel can use at most half the TOC height (maxHeightFraction: 0.5), but no more than the available space below the parent item (tocHeight - itemHeight), and at least 120px (minHeight: 120). Three constraints, one Math.max/min chain. Pure computation, no DOM.
Scroll to Active Heading
When the active heading changes (scroll spy updates), the panel needs to scroll internally to keep the heading visible. The machine computes the target scroll position:
export function computeScrollToHeading(
geometry: ActiveHeadingGeometry,
scrollMargin: number = DEFAULT_CONSTRAINTS.scrollMargin,
): number | null {
const { offsetTop, height, panelScrollTop, panelVisibleHeight } = geometry;
const top = offsetTop - scrollMargin;
const bottom = offsetTop + height + scrollMargin;
// Heading is above the visible area
if (top < panelScrollTop) return top;
// Heading is below the visible area
if (bottom > panelScrollTop + panelVisibleHeight) return bottom - panelVisibleHeight;
// Already visible
return null;
}export function computeScrollToHeading(
geometry: ActiveHeadingGeometry,
scrollMargin: number = DEFAULT_CONSTRAINTS.scrollMargin,
): number | null {
const { offsetTop, height, panelScrollTop, panelVisibleHeight } = geometry;
const top = offsetTop - scrollMargin;
const bottom = offsetTop + height + scrollMargin;
// Heading is above the visible area
if (top < panelScrollTop) return top;
// Heading is below the visible area
if (bottom > panelScrollTop + panelVisibleHeight) return bottom - panelVisibleHeight;
// Already visible
return null;
}Returns null if the heading is already visible (no scroll needed). Otherwise, returns the target scrollTop -- the machine calls callbacks.scrollPanelTo(scrollTo) to request the scroll.
The activateHeading() method ties it together:
activateHeading(geometry: ActiveHeadingGeometry) {
if (state !== 'open' && state !== 'scrollingToHeading') return;
const scrollTo = computeScrollToHeading(geometry, constraints.scrollMargin);
if (scrollTo !== null) {
transition('scrollingToHeading');
callbacks.scrollPanelTo(scrollTo);
}
}activateHeading(geometry: ActiveHeadingGeometry) {
if (state !== 'open' && state !== 'scrollingToHeading') return;
const scrollTo = computeScrollToHeading(geometry, constraints.scrollMargin);
if (scrollTo !== null) {
transition('scrollingToHeading');
callbacks.scrollPanelTo(scrollTo);
}
}Guard: only works when the panel is open or already scrollingToHeading. If the heading is visible, nothing happens. If it's clipped, the machine enters scrollingToHeading and asks the DOM to scroll. When the scroll finishes, the wiring layer calls scrollEnd() and the machine returns to open.
This is geometry injection in action. The machine never reads the DOM. The wiring layer measures the DOM, packages the measurements as a data object, and passes them in. The machine computes what to do and asks the wiring layer to do it. The same pattern appears in TocScrollMachine, but more complex.
Testing the HeadingsPanelMachine
The geometry injection pattern makes testing trivial. No DOM measurements needed -- just pass in numbers:
describe('computeMaxHeight', () => {
it('should cap at maxHeightFraction of TOC', () => {
// TOC is 600px, item is 40px
expect(computeMaxHeight(600, 40)).toBe(300);
// 600 * 0.5 = 300 < 600 - 40 = 560, so fraction wins
});
it('should cap at available space when fraction is larger', () => {
// TOC is 200px, item is 140px → only 60px available
// But minHeight is 120, so min wins
expect(computeMaxHeight(200, 140)).toBe(120);
});
it('should respect minimum height', () => {
expect(computeMaxHeight(100, 80)).toBe(120);
// available = 20, fraction = 50, min = 120 → min wins
});
});
describe('computeScrollToHeading', () => {
it('should scroll up when heading is above viewport', () => {
expect(computeScrollToHeading({
offsetTop: 20,
height: 24,
panelScrollTop: 100,
panelVisibleHeight: 200,
})).toBe(12); // 20 - 8 (scrollMargin)
});
it('should return null when heading is visible', () => {
expect(computeScrollToHeading({
offsetTop: 120,
height: 24,
panelScrollTop: 100,
panelVisibleHeight: 200,
})).toBeNull();
});
});describe('computeMaxHeight', () => {
it('should cap at maxHeightFraction of TOC', () => {
// TOC is 600px, item is 40px
expect(computeMaxHeight(600, 40)).toBe(300);
// 600 * 0.5 = 300 < 600 - 40 = 560, so fraction wins
});
it('should cap at available space when fraction is larger', () => {
// TOC is 200px, item is 140px → only 60px available
// But minHeight is 120, so min wins
expect(computeMaxHeight(200, 140)).toBe(120);
});
it('should respect minimum height', () => {
expect(computeMaxHeight(100, 80)).toBe(120);
// available = 20, fraction = 50, min = 120 → min wins
});
});
describe('computeScrollToHeading', () => {
it('should scroll up when heading is above viewport', () => {
expect(computeScrollToHeading({
offsetTop: 20,
height: 24,
panelScrollTop: 100,
panelVisibleHeight: 200,
})).toBe(12); // 20 - 8 (scrollMargin)
});
it('should return null when heading is visible', () => {
expect(computeScrollToHeading({
offsetTop: 120,
height: 24,
panelScrollTop: 100,
panelVisibleHeight: 200,
})).toBeNull();
});
});Pure functions, pure data, pure assertions. No DOM fixtures, no mock elements, no getBoundingClientRect stubs. The geometry interface is the testing interface.
The Full HeadingsPanelMachine Lifecycle
it('should walk through closed → opening → open → scrollingToHeading → open', () => {
const { machine, callbacks } = setup();
// Start closed
expect(machine.getState()).toBe('closed');
// Open the panel
machine.open({ tocHeight: 600, itemHeight: 40, headingCount: 5, headingRowHeight: 28 });
expect(machine.getState()).toBe('opening');
// CSS transition ends
machine.transitionEnd();
expect(machine.getState()).toBe('open');
// Active heading changes — heading is below visible area
machine.activateHeading({
offsetTop: 400,
height: 24,
panelScrollTop: 0,
panelVisibleHeight: 200,
});
expect(machine.getState()).toBe('scrollingToHeading');
expect(callbacks.scrollPanelTo).toHaveBeenCalled();
// Scroll finishes
machine.scrollEnd();
expect(machine.getState()).toBe('open');
});it('should walk through closed → opening → open → scrollingToHeading → open', () => {
const { machine, callbacks } = setup();
// Start closed
expect(machine.getState()).toBe('closed');
// Open the panel
machine.open({ tocHeight: 600, itemHeight: 40, headingCount: 5, headingRowHeight: 28 });
expect(machine.getState()).toBe('opening');
// CSS transition ends
machine.transitionEnd();
expect(machine.getState()).toBe('open');
// Active heading changes — heading is below visible area
machine.activateHeading({
offsetTop: 400,
height: 24,
panelScrollTop: 0,
panelVisibleHeight: 200,
});
expect(machine.getState()).toBe('scrollingToHeading');
expect(callbacks.scrollPanelTo).toHaveBeenCalled();
// Scroll finishes
machine.scrollEnd();
expect(machine.getState()).toBe('open');
});Five states, one test, 25 lines. Every transition is explicit. Every side effect is verified through the mock callback.
TocScrollMachine: The Most Complex Machine
When scroll spy detects a new active page in the TOC, the sidebar may need to:
- Scroll the active item into view (it might be off-screen)
- Wait for the scroll animation to finish
- Expand the headings panel (children)
- Wait for the expansion animation to finish
- Check if the expanded children are clipped below the viewport
- Scroll again to reveal the children
All of these steps are asynchronous. Any of them might be unnecessary. And at any point, the user might manually scroll the sidebar, which should cancel the programmatic scrolling.
This is why TocScrollMachine has 8 states:
Let's walk through each state with a concrete scenario.
The Scenario
The user is reading a blog post deep in the TOC. They scroll through the content and scroll spy detects a new active heading that corresponds to a TOC item below the fold.
Step 1: activate()
function activate(geometry: Pick<TocScrollGeometry, 'isItemVisible' | 'hasChildren'>): void {
pendingChildren = geometry.hasChildren;
if (!geometry.isItemVisible) {
transition('scrollingToItem');
callbacks.scrollToItem();
} else {
transition('itemSettled');
// Continue to children handling...
}
}function activate(geometry: Pick<TocScrollGeometry, 'isItemVisible' | 'hasChildren'>): void {
pendingChildren = geometry.hasChildren;
if (!geometry.isItemVisible) {
transition('scrollingToItem');
callbacks.scrollToItem();
} else {
transition('itemSettled');
// Continue to children handling...
}
}The wiring layer calls activate() with geometry: "is the item visible in the TOC viewport?" and "does this item have children (headings)?" If the item is off-screen, the machine asks the wiring layer to scroll it into view.
Step 2: scrollEnd()
function scrollEnd(): void {
if (state === 'scrollingToItem') {
transition('itemSettled');
if (pendingChildren) {
callbacks.renderChildren();
transition('childrenExpanding');
} else {
transition('settled');
}
} else if (state === 'scrollingToChildren') {
transition('settled');
}
}function scrollEnd(): void {
if (state === 'scrollingToItem') {
transition('itemSettled');
if (pendingChildren) {
callbacks.renderChildren();
transition('childrenExpanding');
} else {
transition('settled');
}
} else if (state === 'scrollingToChildren') {
transition('settled');
}
}The sidebar scroll animation completes. The wiring layer fires scrollEnd(). The machine moves to itemSettled. If there are pending children (headings to show), it asks the wiring layer to render them and enters childrenExpanding.
Step 3: childrenExpanded()
function childrenExpanded(geometry: Pick<TocScrollGeometry, 'areChildrenClipped'>): void {
if (state !== 'childrenExpanding') return;
transition('checkingVisibility');
if (geometry.areChildrenClipped) {
transition('scrollingToChildren');
callbacks.scrollToChildren();
} else {
transition('settled');
}
}function childrenExpanded(geometry: Pick<TocScrollGeometry, 'areChildrenClipped'>): void {
if (state !== 'childrenExpanding') return;
transition('checkingVisibility');
if (geometry.areChildrenClipped) {
transition('scrollingToChildren');
callbacks.scrollToChildren();
} else {
transition('settled');
}
}The children panel finishes expanding (CSS transition end). The wiring layer measures whether the children are clipped below the TOC viewport and calls childrenExpanded({ areChildrenClipped: true/false }). If clipped, the machine asks for another scroll. If visible, we're done.
User Scroll Lock
At any point during this sequence, the user might grab the scrollbar and manually scroll the TOC. This should immediately stop the programmatic scrolling:
function userScroll(): void {
if (state === 'idle' || state === 'settled') return;
transition('userScrollLocked');
}function userScroll(): void {
if (state === 'idle' || state === 'settled') return;
transition('userScrollLocked');
}Any state except idle and settled can be interrupted by userScroll(). The machine enters userScrollLocked and stays there until the next activate() call. This means:
- No more programmatic scrolling until a new active item is detected
- The user's scroll position is respected
- The next activation resets the lock
The unlock happens in activate():
if (state === 'userScrollLocked') {
if (pendingChildren) {
transition('itemSettled');
callbacks.renderChildren();
transition('childrenExpanding');
return;
}
transition('settled');
return;
}if (state === 'userScrollLocked') {
if (pendingChildren) {
transition('itemSettled');
callbacks.renderChildren();
transition('childrenExpanding');
return;
}
transition('settled');
return;
}When a new item activates while locked, the machine still renders children (if any) but doesn't scroll. The user is in control.
The Geometry Interface
export interface TocScrollGeometry {
isItemVisible: boolean;
hasChildren: boolean;
areChildrenClipped: boolean;
}export interface TocScrollGeometry {
isItemVisible: boolean;
hasChildren: boolean;
areChildrenClipped: boolean;
}Three booleans. That's all the machine needs from the DOM. The wiring layer computes them:
// In the wiring layer
const itemRect = itemEl.getBoundingClientRect();
const tocRect = tocEl.getBoundingClientRect();
const isItemVisible = itemRect.top >= tocRect.top && itemRect.bottom <= tocRect.bottom;
const hasChildren = itemEl.querySelector('.headings-panel') !== null;
tocScrollMachine.activate({ isItemVisible, hasChildren });// In the wiring layer
const itemRect = itemEl.getBoundingClientRect();
const tocRect = tocEl.getBoundingClientRect();
const isItemVisible = itemRect.top >= tocRect.top && itemRect.bottom <= tocRect.bottom;
const hasChildren = itemEl.querySelector('.headings-panel') !== null;
tocScrollMachine.activate({ isItemVisible, hasChildren });The machine doesn't know about getBoundingClientRect(). It doesn't know about DOM elements. It receives booleans and makes decisions.
The isBusy() Helper
export function isBusy(state: TocScrollState): boolean {
return state !== 'idle' && state !== 'settled' && state !== 'userScrollLocked';
}export function isBusy(state: TocScrollState): boolean {
return state !== 'idle' && state !== 'settled' && state !== 'userScrollLocked';
}The wiring layer uses this to show/hide a loading affordance (a subtle spinner or pulse animation) while the scroll orchestration is in progress. It's a pure function -- no machine instance needed.
Why 8 States?
Could we simplify this? Let's consider alternatives:
Option A: Two states (idle/scrolling). Doesn't handle the "scroll to item, then expand children, then check if children are clipped, then scroll again" sequence. You'd need nested async callbacks, which is what we're trying to avoid.
Option B: A queue of actions. [scrollToItem, renderChildren, checkVisibility, scrollToChildren]. But each action depends on the result of the previous one (did the children clip?), and any action might be skipped (item was already visible). A queue with conditional skipping is a state machine with extra steps.
Option C: async/await. Write activate() as an async function that awaits each step. But scroll animations fire via DOM events (scrollend), not promises. You'd need to promisify them, and you'd need to handle cancellation (user scroll). An async function with cancellation is -- again -- a state machine.
8 states is the honest answer to the question: "what are all the possible situations the scroll orchestrator can be in?" Each state has clear entry conditions, clear exit transitions, and clear meaning. The machine is 163 lines. An equivalent async/cancellation implementation would be longer and harder to test.
The Wiring Diagram: Five Machines, One Sidebar
Here's how the five TOC machines connect at the wiring layer:
The connections:
- Hover → TocTooltipMachine: pointer enter/leave events drive the tooltip lifecycle.
- Scroll → TocBreadcrumbMachine: scroll events trigger breadcrumb recomputation.
- Click → TocExpandMachine: clicks toggle section open/closed (or navigate).
- Scroll Spy change → TocExpandMachine: auto-opens the section containing the active item.
- Scroll Spy change → TocScrollMachine: triggers the scroll-to-item sequence.
- TocScrollMachine → HeadingsPanelMachine: the scroll machine's
renderChildrencallback opens the headings panel. - TocExpandMachine → TocScrollMachine: when a section opens, the scroll machine ensures it's visible.
No machine imports another machine. They connect through the wiring layer:
// In app-shared.ts (simplified)
const tocTooltip = createTocTooltipMachine({
onShow: (data) => showTooltipElement(data),
onHide: () => hideTooltipElement(),
onSchedule: (ms) => { tooltipTimer = setTimeout(() => tocTooltip.show(), ms); },
onCancel: () => clearTimeout(tooltipTimer),
});
const tocExpand = createTocExpandMachine(sections);
const headingsPanel = createHeadingsPanelMachine({
onStateChange: (state) => panelEl.className = `headings-panel headings-${state}`,
scrollPanelTo: (top) => panelEl.scrollTo({ top, behavior: 'smooth' }),
});
const tocScroll = createTocScrollMachine({
scrollToItem: () => scrollItemIntoView(activeItemEl),
scrollToChildren: () => scrollChildrenIntoView(),
renderChildren: () => {
headingsPanel.open(getPanelGeometry());
},
onStateChange: (state) => {
tocEl.classList.toggle('toc-busy', isBusy(state));
},
});
// Scroll spy fires → activate both expand and scroll machines
function onScrollSpyChange(newPath: string, sectionId: string) {
tocExpand.activateItem(sectionId);
const itemEl = tocEl.querySelector(`[data-path="${newPath}"]`);
tocScroll.activate({
isItemVisible: isElementVisible(itemEl, tocEl),
hasChildren: hasHeadingsPanel(newPath),
});
}// In app-shared.ts (simplified)
const tocTooltip = createTocTooltipMachine({
onShow: (data) => showTooltipElement(data),
onHide: () => hideTooltipElement(),
onSchedule: (ms) => { tooltipTimer = setTimeout(() => tocTooltip.show(), ms); },
onCancel: () => clearTimeout(tooltipTimer),
});
const tocExpand = createTocExpandMachine(sections);
const headingsPanel = createHeadingsPanelMachine({
onStateChange: (state) => panelEl.className = `headings-panel headings-${state}`,
scrollPanelTo: (top) => panelEl.scrollTo({ top, behavior: 'smooth' }),
});
const tocScroll = createTocScrollMachine({
scrollToItem: () => scrollItemIntoView(activeItemEl),
scrollToChildren: () => scrollChildrenIntoView(),
renderChildren: () => {
headingsPanel.open(getPanelGeometry());
},
onStateChange: (state) => {
tocEl.classList.toggle('toc-busy', isBusy(state));
},
});
// Scroll spy fires → activate both expand and scroll machines
function onScrollSpyChange(newPath: string, sectionId: string) {
tocExpand.activateItem(sectionId);
const itemEl = tocEl.querySelector(`[data-path="${newPath}"]`);
tocScroll.activate({
isItemVisible: isElementVisible(itemEl, tocEl),
hasChildren: hasHeadingsPanel(newPath),
});
}Each machine is created independently with its own callbacks. The onScrollSpyChange() function is the composition point -- it reads the scroll spy result and drives both tocExpand and tocScroll. The renderChildren callback in tocScroll drives headingsPanel. The connections are explicit, traceable, and testable.
1. Geometry Injection
When a state machine needs DOM measurements, don't give it DOM access. Package the measurements as a typed data object and pass them in.
// ✗ Machine reads DOM
function activate() {
const rect = this.element.getBoundingClientRect();
if (rect.top < this.container.getBoundingClientRect().top) ...
}
// ✓ Machine receives geometry
function activate(geometry: { isItemVisible: boolean }) {
if (!geometry.isItemVisible) ...
}// ✗ Machine reads DOM
function activate() {
const rect = this.element.getBoundingClientRect();
if (rect.top < this.container.getBoundingClientRect().top) ...
}
// ✓ Machine receives geometry
function activate(geometry: { isItemVisible: boolean }) {
if (!geometry.isItemVisible) ...
}Benefits:
- Machine is testable without a DOM
- Geometry interface documents exactly what measurements are needed
- Different wiring layers can provide geometry differently (real DOM, virtual DOM, mock)
2. CSS Animation as State
Don't use timers to model CSS animations. Use states:
// ✗ Timer-based
open() {
element.classList.add('opening');
setTimeout(() => {
element.classList.replace('opening', 'open');
}, 300); // Hope this matches the CSS
}
// ✓ State-based
open() {
transition('opening');
// Wiring layer listens for transitionend, calls machine.transitionEnd()
}
transitionEnd() {
if (state === 'opening') transition('open');
}// ✗ Timer-based
open() {
element.classList.add('opening');
setTimeout(() => {
element.classList.replace('opening', 'open');
}, 300); // Hope this matches the CSS
}
// ✓ State-based
open() {
transition('opening');
// Wiring layer listens for transitionend, calls machine.transitionEnd()
}
transitionEnd() {
if (state === 'opening') transition('open');
}The state-based approach is:
- Resilient to CSS duration changes (no magic numbers)
- Correct when animations are interrupted (rapidly clicking open/close)
- Testable without timers (call
transitionEnd()directly)
3. When NOT to Use a State Machine
TocBreadcrumbMachine is two pure functions. No factory, no state, no callbacks. Because breadcrumbs have no persistent state -- they're recomputed from scratch on every scroll event.
The decision rule: "Does the output depend on previous events?"
| Machine | Depends on previous events? | Pattern |
|---|---|---|
| TocTooltipMachine | Yes (pointer entered, timer started) | Factory + closure |
| TocBreadcrumbMachine | No (recomputed from current scroll position) | Pure functions |
| TocExpandMachine | Yes (which sections are open) | Factory + closure |
| HeadingsPanelMachine | Yes (animation in progress) | Factory + closure |
| TocScrollMachine | Yes (which scroll step are we on) | Factory + closure |
4. Decomposition Tames Complexity
The TocScrollMachine has 8 states. If we tried to put all 5 machines into one, it would have ~40 states (8 scroll states x 5 panel states x open/closed x tooltip). That's unmanageable. By decomposing into 5 independent machines composed at the wiring layer, each machine stays small enough to reason about.
The cost is the wiring layer: it has to connect the machines. But the wiring layer is straightforward glue code -- it reads events, measures DOM geometry, and calls machine methods. No complex logic. The complexity lives in the machines where it's testable, not in the wiring layer where it's not.
5. The User Always Wins
The userScrollLocked state in TocScrollMachine encodes a fundamental UX principle: if the user is manually scrolling, the machine should stop fighting them. Programmatic scroll is a suggestion. User scroll is a command.
This is hard to implement without a state machine. With async/await, you'd need a cancellation token checked between every step. With a queue, you'd need to drain the queue. With a state machine, it's one state and one transition:
function userScroll(): void {
if (state === 'idle' || state === 'settled') return;
transition('userScrollLocked');
}function userScroll(): void {
if (state === 'idle' || state === 'settled') return;
transition('userScrollLocked');
}Any busy state → locked. Next activation → unlocked. Clean, explicit, testable.
6. The Callback Chain Is the Sequence Diagram
Compare the original setTimeout chain from Part I with the TocScrollMachine's callback flow:
Original (setTimeout):
scrollIntoView() → setTimeout(500) → expand panel → setTimeout(350) → check clipped → scrollIntoView()
Problems:
- Hardcoded delays that may not match actual durations
- No cancellation on user scroll
- No way to test without real timers
TocScrollMachine (callbacks):
activate() → scrollToItem() → [DOM fires scrollEnd()] → renderChildren() →
[DOM fires childrenExpanded()] → checkingVisibility → scrollToChildren() →
[DOM fires scrollEnd()] → settled
Guarantees:
- Every step waits for the actual event, not a guessed timer
- userScroll() cancels at any point
- Each step is testable by calling the method directlyOriginal (setTimeout):
scrollIntoView() → setTimeout(500) → expand panel → setTimeout(350) → check clipped → scrollIntoView()
Problems:
- Hardcoded delays that may not match actual durations
- No cancellation on user scroll
- No way to test without real timers
TocScrollMachine (callbacks):
activate() → scrollToItem() → [DOM fires scrollEnd()] → renderChildren() →
[DOM fires childrenExpanded()] → checkingVisibility → scrollToChildren() →
[DOM fires scrollEnd()] → settled
Guarantees:
- Every step waits for the actual event, not a guessed timer
- userScroll() cancels at any point
- Each step is testable by calling the method directlyThe state machine replaces guessed durations with actual events. The DOM tells the machine when the scroll finished and when the animation completed. The machine doesn't need to know how long anything takes.
A Complete TocScrollMachine Test
To show how all of this comes together, here's how a full scroll sequence is tested:
it('should complete full scroll → expand → check → scroll sequence', () => {
const { machine, callbacks } = setup();
// 1. Activate with off-screen item that has children
machine.activate({ isItemVisible: false, hasChildren: true });
expect(machine.getState()).toBe('scrollingToItem');
expect(callbacks.scrollToItem).toHaveBeenCalled();
// 2. Scroll finishes → children should render
machine.scrollEnd();
expect(machine.getState()).toBe('childrenExpanding');
expect(callbacks.renderChildren).toHaveBeenCalled();
// 3. Children finish expanding → check if clipped
machine.childrenExpanded({ areChildrenClipped: true });
expect(machine.getState()).toBe('scrollingToChildren');
expect(callbacks.scrollToChildren).toHaveBeenCalled();
// 4. Second scroll finishes → settled
machine.scrollEnd();
expect(machine.getState()).toBe('settled');
});
it('should handle user scroll interruption', () => {
const { machine, callbacks } = setup();
machine.activate({ isItemVisible: false, hasChildren: true });
expect(machine.getState()).toBe('scrollingToItem');
// User grabs the scrollbar
machine.userScroll();
expect(machine.getState()).toBe('userScrollLocked');
// Next scroll end is ignored (we're locked)
machine.scrollEnd();
expect(machine.getState()).toBe('userScrollLocked');
// New activation resets the lock
machine.activate({ isItemVisible: true, hasChildren: false });
expect(machine.getState()).toBe('settled');
});it('should complete full scroll → expand → check → scroll sequence', () => {
const { machine, callbacks } = setup();
// 1. Activate with off-screen item that has children
machine.activate({ isItemVisible: false, hasChildren: true });
expect(machine.getState()).toBe('scrollingToItem');
expect(callbacks.scrollToItem).toHaveBeenCalled();
// 2. Scroll finishes → children should render
machine.scrollEnd();
expect(machine.getState()).toBe('childrenExpanding');
expect(callbacks.renderChildren).toHaveBeenCalled();
// 3. Children finish expanding → check if clipped
machine.childrenExpanded({ areChildrenClipped: true });
expect(machine.getState()).toBe('scrollingToChildren');
expect(callbacks.scrollToChildren).toHaveBeenCalled();
// 4. Second scroll finishes → settled
machine.scrollEnd();
expect(machine.getState()).toBe('settled');
});
it('should handle user scroll interruption', () => {
const { machine, callbacks } = setup();
machine.activate({ isItemVisible: false, hasChildren: true });
expect(machine.getState()).toBe('scrollingToItem');
// User grabs the scrollbar
machine.userScroll();
expect(machine.getState()).toBe('userScrollLocked');
// Next scroll end is ignored (we're locked)
machine.scrollEnd();
expect(machine.getState()).toBe('userScrollLocked');
// New activation resets the lock
machine.activate({ isItemVisible: true, hasChildren: false });
expect(machine.getState()).toBe('settled');
});8 states. 163 lines of implementation. 20 lines per test case. No DOM, no timers, no animations. The machine is tested by driving it through its states and verifying the callback sequence. That's the payoff of the architecture: the most complex behavior on the site is the simplest code to test.
The Complete Machine Inventory
All five TOC machines at a glance:
| Machine | States | Lines | Pattern | Key concept |
|---|---|---|---|---|
| TocTooltipMachine | 3 | 101 | Factory + closure | Timer injection |
| TocBreadcrumbMachine | — | 43 | Pure functions | No machine needed |
| TocExpandMachine | 2 per section | 111 | Factory + closure | Map-based tracking |
| HeadingsPanelMachine | 5 | 151 | Factory + closure | CSS animation as state, geometry injection |
| TocScrollMachine | 8 | 163 | Factory + closure | Scroll orchestration, user scroll lock |
163 lines for the most complex machine. 43 lines for the simplest. Same pattern. Same structure. The complexity is in the number of states and transitions, not in the code structure.
Comparing Approaches: setTimeout vs State Machine
To drive the point home, let's put the two implementations side by side for the complete scroll-to-active scenario:
The setTimeout Approach (from Part I)
function scrollTocToActive(activeItem) {
const tocRect = tocEl.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
if (itemRect.top < tocRect.top || itemRect.bottom > tocRect.bottom) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
const headingsPanel = activeItem.querySelector('.headings');
if (headingsPanel) {
headingsPanel.classList.add('open');
setTimeout(() => {
const panelRect = headingsPanel.getBoundingClientRect();
if (panelRect.bottom > tocRect.bottom) {
headingsPanel.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}, 350);
}
}, 500);
}
}function scrollTocToActive(activeItem) {
const tocRect = tocEl.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
if (itemRect.top < tocRect.top || itemRect.bottom > tocRect.bottom) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
const headingsPanel = activeItem.querySelector('.headings');
if (headingsPanel) {
headingsPanel.classList.add('open');
setTimeout(() => {
const panelRect = headingsPanel.getBoundingClientRect();
if (panelRect.bottom > tocRect.bottom) {
headingsPanel.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}, 350);
}
}, 500);
}
}Problems:
- Hardcoded 500ms and 350ms delays that may not match actual scroll/animation durations
- No handling of user scroll interruption
- No cancellation if a new item activates during the sequence
- No way to test without real timers and DOM
- No way to know if the sequence is "busy" (for loading affordances)
- 15 lines, untestable
The State Machine Approach
// TocScrollMachine: 163 lines, fully testable
const tocScroll = createTocScrollMachine({
scrollToItem: () => activeItemEl.scrollIntoView({ behavior: 'smooth' }),
scrollToChildren: () => childrenEl.scrollIntoView({ behavior: 'smooth', block: 'end' }),
renderChildren: () => headingsPanel.open(getPanelGeometry()),
onStateChange: (state) => tocEl.classList.toggle('toc-busy', isBusy(state)),
});
// Wiring: DOM events → machine events
tocEl.addEventListener('scrollend', () => tocScroll.scrollEnd());
tocEl.addEventListener('scroll', () => {
if (isUserScrolling) tocScroll.userScroll();
});
headingsPanelEl.addEventListener('transitionend', () => {
const geo = { areChildrenClipped: isClipped(headingsPanelEl, tocEl) };
tocScroll.childrenExpanded(geo);
});// TocScrollMachine: 163 lines, fully testable
const tocScroll = createTocScrollMachine({
scrollToItem: () => activeItemEl.scrollIntoView({ behavior: 'smooth' }),
scrollToChildren: () => childrenEl.scrollIntoView({ behavior: 'smooth', block: 'end' }),
renderChildren: () => headingsPanel.open(getPanelGeometry()),
onStateChange: (state) => tocEl.classList.toggle('toc-busy', isBusy(state)),
});
// Wiring: DOM events → machine events
tocEl.addEventListener('scrollend', () => tocScroll.scrollEnd());
tocEl.addEventListener('scroll', () => {
if (isUserScrolling) tocScroll.userScroll();
});
headingsPanelEl.addEventListener('transitionend', () => {
const geo = { areChildrenClipped: isClipped(headingsPanelEl, tocEl) };
tocScroll.childrenExpanded(geo);
});Properties:
- No hardcoded delays -- waits for actual DOM events
- User scroll interruption handled (
userScrollLockedstate) - New activation cancels in-progress sequence (resets state)
- Fully testable without DOM (163 lines of pure logic + 20 lines of wiring)
isBusy()helper enables loading affordances- 163 + 20 = 183 lines, every path tested
The state machine is 12x more code but handles every edge case that the setTimeout approach ignores. And every line of the machine is exercised by unit tests that run in milliseconds.
The Sidebar Experience: What the User Sees
Let's trace through the complete user experience when navigating to a new page, showing which machines fire and in what order:
Scenario: The user is reading the "About" page. They click "Blog > This Website" in the TOC sidebar. The "This Website" entry is below the visible area of the TOC.
Step 1: Click event (wiring layer)
- The click handler extracts
targetPath = "content/blog/this-website.md"from thedata-pathattribute - Calls
spaNav.navigate(targetPath, currentPath, null, href)
Step 2: SpaNavMachine — idle → fetching
classifyNavigation()returns'fullNavigation'(different page)- Machine transitions to
fetching, callscallbacks.startFetch(targetPath) - Wiring layer fires
fetch("content/blog/this-website.md")
Step 3: PageLoadMachine — idle → loading
- Wiring layer calls
pageLoad.startLoad()→ returnsgen = 1
Step 4: SpaNavMachine — fetching → swapping → settled
- Fetch completes, wiring calls
spaNav.fetchComplete(html) callbacks.closeHeadings()returnsfalse(no headings open) → skipclosingHeadings- Machine transitions to
swapping, callscallbacks.swapContent(html, path) - Wiring layer swaps
innerHTML, pushes history, callspostSwap() - Machine transitions to
settled
Step 5: Post-swap (wiring layer)
- PageLoadMachine:
loading → rendering → postProcessing → done - Syntax highlighting, mermaid rendering, copy button wiring
- Scroll spy reset for the new page
Step 6: TocExpandMachine — section opens
tocExpand.activateItem("blog")→ sets blog section toopen- Wiring layer adds
openCSS class to the section
Step 7: TocScrollMachine — idle → scrollingToItem
tocScroll.activate({ isItemVisible: false, hasChildren: true })- Item is below the TOC viewport → machine transitions to
scrollingToItem callbacks.scrollToItem()→ wiring layer scrolls the TOC sidebar
Step 8: TocScrollMachine — scrollingToItem → itemSettled → childrenExpanding
- Sidebar
scrollendfires → wiring callstocScroll.scrollEnd() - Machine transitions to
itemSettled, seeshasChildren = true - Calls
callbacks.renderChildren()→ wiring layer opens HeadingsPanelMachine
Step 9: HeadingsPanelMachine — closed → opening → open
headingsPanel.open(geometry)→ machine transitions toopening- CSS transition runs (panel height animates from 0 to computed max)
transitionendfires → wiring callsheadingsPanel.transitionEnd()- Machine transitions to
open
Step 10: TocScrollMachine — childrenExpanding → checkingVisibility → settled
- Wiring detects headings panel finished expanding
- Calls
tocScroll.childrenExpanded({ areChildrenClipped: false }) - Children are visible → machine transitions through
checkingVisibilitytosettled isBusy()returnsfalse→ wiring removes loading affordance
Step 11: TocBreadcrumbMachine — recomputes
- TOC scroll position changed → wiring calls
computeBreadcrumbs(hiddenAncestors) - Returns new breadcrumb state → wiring updates breadcrumb bar DOM
Result: The user sees the content swap, the TOC scrolls to show the active item, the headings panel expands with an animation, and breadcrumbs update. Six machines fired in sequence. None of them knew about the others. The wiring layer orchestrated everything.
Total time: ~800ms (dominated by the fetch and CSS animations). The state machine transitions are sub-millisecond.
What's Next
We've seen all 15 state machines now. Parts II-IV covered the behavior architecture. The remaining two parts cover the infrastructure that makes it work:
Part V: From Vanilla JS to TypeScript covers the migration that made the interfaces explicit -- tsconfig choices, esbuild for browser bundles, tsx for scripts, and real bugs that strict mode caught.
Part VI: Testing Pure State Machines covers the testing strategy: Vitest unit tests at 98%+ coverage, property-based tests with fast-check, Playwright E2E tests, and the @FeatureTest decorator system.