Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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:

Diagram

The machine never calls setTimeout. It uses timer injection:

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();
});

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,
  };
}

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,
  };
}

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;
}

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);
  }
  // ...
}

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' };
}

The return type is ToggleResult:

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);
}

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');
}

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.

Diagram

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
}

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);

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));
}

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;
}

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);
  }
}

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();
  });
});

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');
});

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:

  1. Scroll the active item into view (it might be off-screen)
  2. Wait for the scroll animation to finish
  3. Expand the headings panel (children)
  4. Wait for the expansion animation to finish
  5. Check if the expanded children are clipped below the viewport
  6. 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:

Diagram

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...
  }
}

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');
  }
}

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');
  }
}

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');
}

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;
}

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;
}

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 });

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';
}

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:

Diagram

The connections:

  1. Hover → TocTooltipMachine: pointer enter/leave events drive the tooltip lifecycle.
  2. Scroll → TocBreadcrumbMachine: scroll events trigger breadcrumb recomputation.
  3. Click → TocExpandMachine: clicks toggle section open/closed (or navigate).
  4. Scroll Spy change → TocExpandMachine: auto-opens the section containing the active item.
  5. Scroll Spy change → TocScrollMachine: triggers the scroll-to-item sequence.
  6. TocScrollMachine → HeadingsPanelMachine: the scroll machine's renderChildren callback opens the headings panel.
  7. 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),
  });
}

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) ...
}

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');
}

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');
}

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 directly

The 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');
});

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);
  }
}

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);
});

Properties:

  • No hardcoded delays -- waits for actual DOM events
  • User scroll interruption handled (userScrollLocked state)
  • 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 the data-path attribute
  • Calls spaNav.navigate(targetPath, currentPath, null, href)

Step 2: SpaNavMachineidle → fetching

  • classifyNavigation() returns 'fullNavigation' (different page)
  • Machine transitions to fetching, calls callbacks.startFetch(targetPath)
  • Wiring layer fires fetch("content/blog/this-website.md")

Step 3: PageLoadMachineidle → loading

  • Wiring layer calls pageLoad.startLoad() → returns gen = 1

Step 4: SpaNavMachinefetching → swapping → settled

  • Fetch completes, wiring calls spaNav.fetchComplete(html)
  • callbacks.closeHeadings() returns false (no headings open) → skip closingHeadings
  • Machine transitions to swapping, calls callbacks.swapContent(html, path)
  • Wiring layer swaps innerHTML, pushes history, calls postSwap()
  • 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 to open
  • Wiring layer adds open CSS class to the section

Step 7: TocScrollMachineidle → 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: TocScrollMachinescrollingToItem → itemSettled → childrenExpanding

  • Sidebar scrollend fires → wiring calls tocScroll.scrollEnd()
  • Machine transitions to itemSettled, sees hasChildren = true
  • Calls callbacks.renderChildren() → wiring layer opens HeadingsPanelMachine

Step 9: HeadingsPanelMachineclosed → opening → open

  • headingsPanel.open(geometry) → machine transitions to opening
  • CSS transition runs (panel height animates from 0 to computed max)
  • transitionend fires → wiring calls headingsPanel.transitionEnd()
  • Machine transitions to open

Step 10: TocScrollMachinechildrenExpanding → checkingVisibility → settled

  • Wiring detects headings panel finished expanding
  • Calls tocScroll.childrenExpanded({ areChildrenClipped: false })
  • Children are visible → machine transitions through checkingVisibility to settled
  • isBusy() returns false → 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.