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

A Tour of 43 Pure Machines

Parts II and III built the type-level infrastructure: EventDef<N, D> for compile-time event contracts and @FiniteStateMachine for metadata that the build pipeline reads. But type systems and decorators are means, not ends. The end is the machines themselves — 43 pure functions that manage every interactive behavior on this site without touching the DOM, without importing each other, and without sharing mutable state.

This part tours representative machines from each domain. For each one: the state set, the transitions, the guards, the bus type (if any), the events it emits and listens to, its feature link, and the key code that makes it work. The goal is not to exhaustively document all 43 — that is the machine catalog at the end — but to illustrate the patterns that recur across the codebase: toggle, timer, compound boolean, generation counter, barrier gate, navigation classifier, dual-mode detector, and diff-aware animator.

Six Domains, 43 Machines

The 43 machines cluster into six functional domains. Each domain is a self-contained concern — page lifecycle, sidebar navigation, scroll tracking, visual appearance, guided tour, and the interactive explorer. Within each domain, machines are composed by coordinators (Part V) or wired by adapters (app-static.ts, app-shared.ts).

Domain Machine Count Representative Key Pattern
Page lifecycle 3 page-load-state, app-readiness-state, spa-nav-state Generation counter, barrier gate, navigation classifier
Table of contents 8 toc-breadcrumb-state, toc-scroll-state, toc-expand-state Diff-aware typewriter, 8-state scroll lifecycle
Scroll & navigation 5 scroll-spy-machine, keyboard-nav-state, search-ui-state Dual detection mode, modal priority
Theme & appearance 7 accent-palette-state, terminal-dots-state, diagram-mode-state Toggle, compound boolean, render cycle
Tour 5 tour-state, tour-coordinator, tour-button-demo-state Step sequencer, scoped coordination
Explorer 7 explorer-filter-state, explorer-selection-state, fsm-simulator-state Selection lock, step-through playback
Shared utilities 8 copy-feedback-state, overlay-state, sidebar-resize-state Timer reset, drag tracking, modal stack

The numbers add up to 43. Some machines participate in the event topology (they emit or listen to custom events via the typed bus); most do not. The event-participating machines are concentrated in the page-lifecycle and TOC domains — exactly the domains where cross-module coordination is heaviest.

The rest of this part dives into eight machines, ordered from simplest to most complex.

Simple Machines: Toggle and Timer

Before the deep dives, two machines illustrate the simplest patterns: a two-state toggle and a four-state timer.

AccentPaletteState — The Toggle

accent-palette-state manages whether the accent color picker is open or closed. Two states, two transitions, no guards, no events. It is the minimal viable state machine:

type AccentPaletteState = 'closed' | 'open';

function createAccentPaletteMachine(callbacks: AccentPaletteCallbacks) {
  let state: AccentPaletteState = 'closed';

  function open(): void {
    if (state === 'open') return;  // guard: no-op if already open
    state = 'open';
    callbacks.onStateChange('open', 'closed');
    callbacks.onOpen();
  }

  function close(): void {
    if (state === 'closed') return;
    state = 'closed';
    callbacks.onStateChange('closed', 'open');
    callbacks.onClose();
  }

  return { open, close, getState: () => state };
}

The pattern is universal: closure state, guard against redundant transitions, callback delegation. Every machine in the codebase follows this shape. The only variation is complexity — more states, more transitions, richer guards.

Bus type: None. The accent palette is wired directly by ThemeCoordinator — it never touches the event bus.

Feature link: FEATURE-ACCENT-PALETTE — accent color customization.

CopyFeedbackMachine — The Timer

copy-feedback-state manages the "Copied!" tooltip that appears after clicking a code block's copy button. Four states: idlecopyingshowingfadingidle. The showingfading transition is timer-driven — after 1500ms, the tooltip begins to fade.

type CopyFeedbackState = 'idle' | 'copying' | 'showing' | 'fading';

function createCopyFeedbackMachine(callbacks: CopyFeedbackCallbacks) {
  let state: CopyFeedbackState = 'idle';
  let timerId: ReturnType<typeof setTimeout> | null = null;

  function copy(): void {
    clearTimer();
    state = 'copying';
    callbacks.onStateChange('copying', 'idle');
    // Transition immediately to showing after clipboard write
    state = 'showing';
    callbacks.onStateChange('showing', 'copying');
    timerId = setTimeout(() => {
      state = 'fading';
      callbacks.onStateChange('fading', 'showing');
      timerId = setTimeout(() => {
        state = 'idle';
        callbacks.onStateChange('idle', 'fading');
      }, 300); // fade duration
    }, 1500); // show duration
  }

  function clearTimer(): void {
    if (timerId !== null) { clearTimeout(timerId); timerId = null; }
  }

  return { copy, reset: () => { clearTimer(); state = 'idle'; }, getState: () => state };
}

The timer is the only impurity — setTimeout is a side effect. But the machine isolates it: the timer is a private implementation detail, not exposed through the return interface. Tests can inject a fake setTimeout (or use vi.useFakeTimers()) to control time deterministically.

Bus type: None.

Feature link: FEATURE-CODE-COPY — one-click code copy with visual feedback.

These two machines — toggle and timer — account for roughly half of the 43. The remaining patterns are more interesting.

Compound State: TerminalDots

Most machines use a union type for state: 'idle' | 'loading' | 'done'. terminal-dots-state is different. Its state is a product type — two booleans — with an invariant that constrains the product space.

The Problem

The site's window chrome has three "terminal dots" (close, minimize, maximize) that control two independent behaviors:

  1. Sidebar masked — the sidebar slides out, giving the content area full width.
  2. Focus mode — the content area expands to fill the viewport, hiding both sidebar and topbar.

These are not independent: focus mode implies sidebar masked. You cannot be in focus mode with the sidebar visible — the layout does not support it. But sidebar masked does not imply focus mode — you can hide the sidebar without entering focus mode.

An enum approach would enumerate the valid combinations:

type TerminalDotsEnum = 'default' | 'sidebarMasked' | 'focusMode';

This works but loses the boolean semantics. Code that needs to check "is the sidebar masked?" must test state === 'sidebarMasked' || state === 'focusMode'. The compound boolean approach preserves each dimension independently.

The State Type

export interface TerminalDotsState {
  sidebarMasked: boolean;
  focusMode: boolean;
}

The invariant: focusMode === true implies sidebarMasked === true. The combination { sidebarMasked: false, focusMode: true } is illegal.

The Truth Table

Diagram
TerminalDots truth table — three valid states out of four possible boolean combinations.

The Machine

The minimize and maximize buttons each have distinct behavior depending on the current state:

export function createTerminalDots(callbacks: TerminalDotsCallbacks): TerminalDotsMachine {
  let state: TerminalDotsState = { sidebarMasked: false, focusMode: false };

  function setState(next: TerminalDotsState): void {
    state = next;
    callbacks.onStateChange(next);
  }

  function minimize(): void {
    if (state.focusMode) {
      // Exit focus mode entirely — return to default
      callbacks.setFocusMode(false);
      callbacks.setSidebarMasked(false);
      setState({ sidebarMasked: false, focusMode: false });
      return;
    }
    // Toggle sidebar mask
    const nextMasked = !state.sidebarMasked;
    callbacks.setSidebarMasked(nextMasked);
    setState({ sidebarMasked: nextMasked, focusMode: false });
  }

  function maximize(): void {
    if (state.focusMode) {
      // Exit focus mode — return to default
      callbacks.setFocusMode(false);
      callbacks.setSidebarMasked(false);
      setState({ sidebarMasked: false, focusMode: false });
      return;
    }
    // Enter focus mode (which implies sidebar masked)
    callbacks.setFocusMode(true);
    callbacks.setSidebarMasked(true);
    setState({ sidebarMasked: true, focusMode: true });
  }

  return {
    minimize,
    maximize,
    getState: () => ({ ...state }),
  };
}

Three observations:

  1. Both buttons are "toggle off" in focus mode. Whether the user clicks minimize or maximize while in focus mode, the machine exits focus mode and returns to default. This is intentional UX: focus mode is a "special" state, and any dot click exits it.

  2. The invariant is enforced by the machine, not by the type system. TypeScript cannot express "if focusMode then sidebarMasked" as a type constraint (without a discriminated union). The machine enforces it procedurally — every code path that sets focusMode: true also sets sidebarMasked: true.

  3. The setSidebarMasked callback fires the sidebar-mask-change event in the adapter layer. This is how TerminalDots participates in the event topology without touching the bus directly — the callback is wired by the adapter, and the adapter dispatches the typed event.

States: 3 valid (default, sidebarMasked, focusMode) Transitions: 6 (see truth table above) Guards: focusMode check on both minimize and maximize Bus type: None (event participation via adapter callback) Emits: sidebar-mask-change (via adapter) Listens: None Feature link: FEATURE-TERMINAL-DOTS — window chrome sidebar/focus controls

When Two Booleans Beat an Enum

The compound boolean pattern is appropriate when:

  • The state space is the product of a small number of independent dimensions (here, 2).
  • There is an invariant that eliminates some combinations (here, 1 of 4).
  • Consumer code needs to check individual dimensions frequently (if (state.sidebarMasked) is cleaner than if (state === 'sidebarMasked' || state === 'focusMode')).
  • The number of valid states is small enough that the invariant can be enforced procedurally without risk.

If the state space had three booleans with multiple invariants, an enum or discriminated union would be safer. At two booleans with one invariant, the compound approach is cleaner.

The Generation Counter: PageLoadState

page-load-state manages the lifecycle of a page load: fetching the content, rendering it, running post-processing (syntax highlighting, mermaid diagrams, scroll restoration), and settling into the done state. The lifecycle is straightforward — five sequential states plus an error state:

Diagram
PageLoadState — a linear lifecycle with a generation counter that detects stale loads.

What makes this machine interesting is not the state graph — it is the generation counter.

The Rapid Navigation Problem

In a single-page application, the user can click a navigation link before the current page has finished loading. If the user clicks Link A and then immediately clicks Link B, two fetches are in flight simultaneously. When Link A's fetch resolves, the response handler tries to render Page A — but the user has already navigated to Page B. If the handler proceeds, it overwrites Page B's content with Page A's content.

This is the stale load problem: a completed fetch that is no longer relevant because a newer navigation has superseded it.

The classic solution is an AbortController that cancels the in-flight fetch. But cancellation only handles the fetch stage. What about rendering? Post-processing? Each stage has its own async operations (marked rendering parses the HTML, mermaid diagrams render in Puppeteer workers, syntax highlighting walks code blocks). Cancelling all of these mid-flight is complex and error-prone.

The generation counter takes a different approach: let the stale operation complete, but ignore its result.

The Counter

export type PageLoadState = 'idle' | 'loading' | 'rendering'
  | 'postProcessing' | 'done' | 'error';

export interface PageLoadCallbacks {
  onStateChange: (state: PageLoadState, prev: PageLoadState) => void;
  onStale: (staleGen: number, currentGen: number) => void;
}

export interface PageLoadMachine {
  startLoad(): number;
  markRendering(gen: number): boolean;
  markPostProcessing(gen: number): boolean;
  markDone(gen: number): boolean;
  markError(gen: number, error: unknown): boolean;
  getState(): { state: PageLoadState; generation: number };
  getGeneration(): number;
}

export function isStale(gen: number, currentGen: number): boolean {
  return gen !== currentGen;
}

Every call to startLoad() increments the generation counter and returns the new generation number. Every subsequent transition method — markRendering, markPostProcessing, markDone, markError — takes a generation parameter. If the generation does not match the current generation, the transition is rejected and the onStale callback fires.

The Implementation

export function createPageLoadMachine(
  callbacks: PageLoadCallbacks
): PageLoadMachine {
  let state: PageLoadState = 'idle';
  let generation = 0;

  function transition(next: PageLoadState): void {
    const prev = state;
    state = next;
    callbacks.onStateChange(next, prev);
  }

  function checkStale(gen: number): boolean {
    if (isStale(gen, generation)) {
      callbacks.onStale(gen, generation);
      return true;
    }
    return false;
  }

  function startLoad(): number {
    generation++;
    transition('loading');
    return generation;
  }

  function markRendering(gen: number): boolean {
    if (checkStale(gen)) return false;
    transition('rendering');
    return true;
  }

  function markPostProcessing(gen: number): boolean {
    if (checkStale(gen)) return false;
    transition('postProcessing');
    return true;
  }

  function markDone(gen: number): boolean {
    if (checkStale(gen)) return false;
    transition('done');
    return true;
  }

  function markError(gen: number, error: unknown): boolean {
    if (checkStale(gen)) return false;
    transition('error');
    return true;
  }

  return {
    startLoad,
    markRendering,
    markPostProcessing,
    markDone,
    markError,
    getState: () => ({ state, generation }),
    getGeneration: () => generation,
  };
}

How It Works in Practice

The adapter code looks like this:

// In the SPA navigation adapter:
async function navigateTo(path: string): Promise<void> {
  const gen = pageLoadMachine.startLoad();

  const html = await fetchPage(path);

  // If the user navigated again while we were fetching,
  // gen !== machine.getGeneration(). markRendering returns false.
  if (!pageLoadMachine.markRendering(gen)) return;

  renderContent(html);

  if (!pageLoadMachine.markPostProcessing(gen)) return;

  await postProcess();  // syntax highlight, mermaid, etc.

  if (!pageLoadMachine.markDone(gen)) return;

  // Only reaches here if this is still the current navigation.
  dispatchRouteReady();
}

Each if (!pageLoadMachine.markXxx(gen)) return; is an early exit. If a newer startLoad() has fired, the generation has incremented, and the stale operation bails out at the next checkpoint. No cancellation, no AbortController, no cleanup. The stale operation simply stops progressing.

The isStale Guard

The isStale function is exported as a standalone pure function:

export function isStale(gen: number, currentGen: number): boolean {
  return gen !== currentGen;
}

This seems trivial — why not inline gen !== currentGen? Two reasons:

  1. Testability. Tests import isStale and verify its truth table directly, independent of the machine.
  2. Naming. isStale(gen, currentGen) communicates intent more clearly than gen !== currentGen at every call site.

States: 6 (idle, loading, rendering, postProcessing, done, error) Transitions: 8 (see statechart) Guards: Generation check on every transition except startLoad Bus type: PageLoadBus (emits app-route-ready, listens to none) Emits: app-route-ready (via adapter, after markDone) Listens: None Feature link: FEATURE-SPA-NAV — single-page navigation with stale-load detection

Why Not AbortController?

AbortController is the standard solution for cancelling in-flight fetches. The generation counter does not replace it — in production, the adapter uses both. The fetch itself is aborted via AbortController (to avoid wasting bandwidth), and the generation counter guards the post-fetch stages (rendering, post-processing) where there is no network request to abort.

The generation counter is the simpler primitive. It works for any async pipeline, not just fetch. It requires no cleanup. It composes naturally with the state machine pattern — each mark* method is already a transition, so adding a staleness guard costs one if statement per transition.

The Barrier Pattern: AppReadinessState

app-readiness-state answers a deceptively simple question: "Is the page ready?"

The Readiness Problem

"Ready" is not a binary. A page in this SPA is ready when:

  1. The navigation pane (sidebar TOC) has been painted.
  2. The markdown output has been rendered.

These two events happen independently and in unpredictable order. The TOC might paint before the content renders (if the TOC data is cached). The content might render before the TOC paints (if the content is small). Neither depends on the other.

The machine needs to wait for all signals before declaring "ready." This is the barrier pattern — also known as a join gate, a rendezvous point, or a fork-join synchronization. In concurrent programming, it is implemented with a CountDownLatch or a CyclicBarrier. In this pure state machine, it is implemented with a Set.

Diagram
AppReadinessState — a Set-based barrier gate that fires app-ready only when all required signals have been received.

The Implementation

export type AppReadinessState = 'pending' | 'ready';

export type ReadinessEvent =
  | 'navPanePainted'
  | 'markdownOutputRendered';

export const REQUIRED_EVENTS: readonly ReadinessEvent[] = [
  'navPanePainted',
  'markdownOutputRendered',
] as const;

export interface AppReadinessCallbacks {
  onStateChange: (state: AppReadinessState, prev: AppReadinessState) => void;
  onReady: () => void;
}

export interface AppReadinessMachine {
  signal(event: ReadinessEvent): void;
  reset(): void;
  getState(): AppReadinessState;
  getReceived(): Set<ReadinessEvent>;
}

The type system already provides the first safety layer: ReadinessEvent is a union of string literals, so signal('navPanePinted') (typo) is a compile error.

The machine implementation:

export function createAppReadinessMachine(
  callbacks: AppReadinessCallbacks
): AppReadinessMachine {
  let state: AppReadinessState = 'pending';
  const received = new Set<ReadinessEvent>();

  function transition(next: AppReadinessState): void {
    if (next === state) return;
    const prev = state;
    state = next;
    callbacks.onStateChange(next, prev);
  }

  function checkReady(): void {
    for (const req of REQUIRED_EVENTS) {
      if (!received.has(req)) return;
    }
    transition('ready');
    callbacks.onReady();
  }

  return {
    signal(event: ReadinessEvent): void {
      if (state === 'ready') return;  // guard: ignore signals after ready
      received.add(event);
      checkReady();
    },
    reset(): void {
      received.clear();
      transition('pending');
    },
    getState: () => state,
    getReceived: () => received,
  };
}

The Set-Based Join Gate

The key insight is checkReady():

function checkReady(): void {
  for (const req of REQUIRED_EVENTS) {
    if (!received.has(req)) return;
  }
  transition('ready');
  callbacks.onReady();
}

This iterates over the REQUIRED_EVENTS array and checks whether every required event has been received. If any is missing, the function returns early. If all are present, the machine transitions to ready and fires the onReady callback.

The Set is the state container — it accumulates signals idempotently (calling signal('navPanePainted') twice is harmless) and provides O(1) membership testing.

Extensibility

Adding a new readiness requirement is a one-line change:

export const REQUIRED_EVENTS: readonly ReadinessEvent[] = [
  'navPanePainted',
  'markdownOutputRendered',
  'syntaxHighlightComplete',  // new requirement
] as const;

The machine's logic does not change. The checkReady loop automatically waits for the new signal. This is the barrier pattern's strength — the synchronization logic is generic, parameterized by the set of required events.

Why Not a Counter?

A simpler implementation would count signals:

let count = 0;
function signal(): void {
  count++;
  if (count >= REQUIRED_EVENTS.length) transition('ready');
}

This fails because signals can fire twice (the TOC might re-render after a route change). A duplicate navPanePainted would increment the counter past the threshold prematurely. The Set is idempotent — adding an already-present element is a no-op — so duplicates are harmless.

States: 2 (pending, ready) Transitions: 3 (signal while pending, checkReady triggers ready, reset) Guards: Ignore signals after ready; require all signals for ready transition Bus type: AppReadinessBus (emits app-ready, listens to none) Emits: app-ready (via onReady callback, dispatched by adapter) Listens: None (signals arrive via direct method calls from other adapters) Feature link: FEATURE-APP-LIFECYCLE — boot sequence coordination

Navigation Classification: SpaNavState

When the user clicks a link in this SPA, three things might happen:

  1. Hash scroll — the link points to a different anchor on the same page. No fetch needed. Just scroll.
  2. Toggle headings — the link points to the same page with no hash. Toggle the heading sections.
  3. Full navigation — the link points to a different page. Fetch, render, post-process.

The distinction matters because each path involves different machines. A hash scroll touches only the scroll spy. A toggle touches the TOC expand machine. A full navigation triggers the entire page-load lifecycle.

The Pure Classifier

export type SpaNavState = 'idle' | 'fetching' | 'closingHeadings'
  | 'swapping' | 'settled';

export type NavigationType =
  | 'hashScroll'
  | 'toggleHeadings'
  | 'fullNavigation';

export function classifyNavigation(
  targetPath: string,
  currentPath: string,
  hash: string | null
): NavigationType {
  if (targetPath === currentPath) {
    return hash ? 'hashScroll' : 'toggleHeadings';
  }
  return 'fullNavigation';
}

classifyNavigation is a pure function — no state, no side effects, no DOM access. It takes three parameters and returns one of three values. It is trivially unit-testable:

expect(classifyNavigation('/docs/install', '/docs/install', '#setup'))
  .toBe('hashScroll');
expect(classifyNavigation('/docs/install', '/docs/install', null))
  .toBe('toggleHeadings');
expect(classifyNavigation('/docs/install', '/about', null))
  .toBe('fullNavigation');

The SpaNav Lifecycle

For fullNavigation, the machine manages a five-state lifecycle:

Diagram
SpaNavState — the full navigation lifecycle with closing headings as a visual transition between old and new content.

The closingHeadings state is a visual design choice: before swapping content, the existing heading sections animate closed, creating a "closing" transition rather than an abrupt replacement. The state machine tracks this animation so that the swap does not happen until the animation completes.

Navigation Types as Discriminated Branches

The classification feeds a switch in the adapter:

const navType = classifyNavigation(targetPath, currentPath, hash);

switch (navType) {
  case 'hashScroll':
    scrollToHash(hash);
    break;
  case 'toggleHeadings':
    tocExpandMachine.toggleAll();
    break;
  case 'fullNavigation':
    const gen = pageLoadMachine.startLoad();
    await fullNavigate(targetPath, gen);
    break;
}

The NavigationType union ensures exhaustive handling. If a fourth navigation type were added, TypeScript would flag every switch that does not handle it (with exhaustive checking via a never default).

States: 5 (idle, fetching, closingHeadings, swapping, settled) Transitions: 7 (including self-transitions for hashScroll and toggleHeadings) Guards: Navigation type classification Bus type: SpaNavBus (emits app-route-ready, listens to none) Emits: app-route-ready (after settle, via adapter) Listens: None Feature link: FEATURE-SPA-NAV — single-page navigation

Mouse vs Scroll: ScrollSpyMachine

The scroll spy detects which heading is currently "active" — the one the user is reading. This drives the TOC highlight, the breadcrumb path, and the URL hash.

But "currently reading" has two meanings:

  1. Scroll-based detection — the user scrolled, and the topmost heading past a threshold is the active one.
  2. Mouse-based detection — the user is hovering over a TOC item, and the hovered item is the active one (providing instant feedback before clicking).

The machine needs both modes, and it needs to switch between them seamlessly.

Two Detection Modes

export type DetectionMode = 'mouse' | 'scroll';

The detection mode is not a state machine state — it is a configuration that affects which detection strategy runs. When the mouse enters the TOC panel, the mode switches to 'mouse'. When the mouse leaves or the user scrolls, it switches back to 'scroll'.

Pure Detection Functions

The scroll-based detection is a linear scan:

export interface HeadingPosition {
  id: string;
  top: number;
}

export function detectByScroll(
  headings: HeadingPosition[],
  threshold: number
): string | null {
  let active: string | null = null;
  for (const h of headings) {
    if (h.top <= threshold) active = h.id;
    else break;
  }
  // Fallback: if no heading is past the threshold, use the first one
  if (!active && headings.length) active = headings[0]!.id;
  return active;
}

The function scans headings sorted by vertical position. It accumulates the last heading whose top is at or above the threshold. When it hits a heading below the threshold, it stops (headings are sorted). The result is the deepest heading that the user has scrolled past.

The unified detection function selects between modes:

export function detectActiveSlug(
  headings: HeadingPosition[],
  threshold: number,
  detectionMode: DetectionMode,
  hoveredSlug: string | null
): string | null {
  if (detectionMode === 'mouse' && hoveredSlug) return hoveredSlug;
  return detectByScroll(headings, threshold);
}

If the detection mode is mouse and a slug is hovered, the hovered slug wins. Otherwise, fall back to scroll detection. This function is pure — it takes data in and returns a slug. No DOM, no event listeners, no side effects.

Resolving to a TOC Entry

The detected slug is a heading ID (e.g., 'install'). But the TOC needs more than just the ID — it needs the full path (section, category, page, heading) for the breadcrumb. The resolution is another pure function:

export function resolveToTocEntry(
  slug: string,
  tocIndex: Map<string, TocEntry>
): TocEntry | null {
  return tocIndex.get(slug) ?? null;
}

The TOC index is built once at startup from toc.json and passed to the scroll spy. The spy does not know about the TOC's structure — it only knows slugs. The resolution function bridges the gap.

Diagram
ScrollSpy — two detection modes, one pure function, one TOC resolution.

The Machine State

Beyond the detection mode and the active slug, the scroll spy machine tracks:

  • Heading positions — recalculated after each navigation or resize.
  • Hovered slug — set by the adapter when the mouse enters a TOC item.
  • Debounce state — scroll events fire at 60fps; the machine debounces to avoid thrashing.

The machine itself is a thin coordinator around the pure functions:

function createScrollSpyMachine(callbacks: ScrollSpyCallbacks) {
  let detectionMode: DetectionMode = 'scroll';
  let headings: HeadingPosition[] = [];
  let hoveredSlug: string | null = null;
  let activeSlug: string | null = null;

  function recalculate(): void {
    const newSlug = detectActiveSlug(
      headings, callbacks.getThreshold(), detectionMode, hoveredSlug
    );
    if (newSlug !== activeSlug) {
      activeSlug = newSlug;
      callbacks.onActiveChange(newSlug);
    }
  }

  return {
    setHeadings(h: HeadingPosition[]): void {
      headings = h;
      recalculate();
    },
    setDetectionMode(mode: DetectionMode): void {
      detectionMode = mode;
      recalculate();
    },
    setHoveredSlug(slug: string | null): void {
      hoveredSlug = slug;
      if (detectionMode === 'mouse') recalculate();
    },
    onScroll(): void {
      if (detectionMode === 'scroll') recalculate();
    },
    getActiveSlug: () => activeSlug,
  };
}

Every method that might change the active slug calls recalculate(). The recalculate() function delegates to the pure detectActiveSlug, compares the result to the current slug, and fires the callback only if the slug actually changed. This prevents redundant updates — if the user scrolls within the same heading region, no callback fires.

States: Detection mode (mouse, scroll) + active slug (string) Transitions: Mode switch, heading update, scroll, hover Guards: Slug equality check (no-op if unchanged) Bus type: ScrollSpyBus (emits scrollspy-active, listens to toc-headings-rendered) Emits: scrollspy-active with { slug: string; path: string } payload Listens: toc-headings-rendered (triggers heading re-index) Feature link: FEATURE-SCROLLSPY — heading-aware navigation

Diff-Aware Typewriter: TocBreadcrumbState

The breadcrumb at the top of the TOC shows the current navigation path: Blog > State Machines > Part IV > The Generation Counter. When the user scrolls to a different heading, the breadcrumb updates — but not by replacing the text. It animates: erasing characters back to the divergence point and typing the new suffix forward.

This requires three things:

  1. A way to represent the breadcrumb as a sequence of tokens (not just a string — because some segments contain icons).
  2. A way to find the common prefix between the old and new breadcrumb.
  3. A state machine that sequences the erase-then-type animation.

Token Representation

export type BreadcrumbToken =
  | { kind: 'char'; char: string }
  | { kind: 'icon'; key: string; node: Node };

A breadcrumb like Blog > Part IV is represented as:

[icon:blog] [char:B] [char:l] [char:o] [char:g] [char: ] [char:>] [char: ]
[icon:article] [char:P] [char:a] [char:r] [char:t] [char: ] [char:I] [char:V]

The icon tokens carry a DOM node (the actual SVG element) and a key for equality comparison. Character tokens carry a single character.

Token Equality and Common Prefix

export function tokensEqual(
  a: BreadcrumbToken,
  b: BreadcrumbToken
): boolean {
  if (a.kind !== b.kind) return false;
  if (a.kind === 'char' && b.kind === 'char') return a.char === b.char;
  if (a.kind === 'icon' && b.kind === 'icon') return a.key === b.key;
  return false;
}

export function commonPrefixLength(
  a: BreadcrumbToken[],
  b: BreadcrumbToken[]
): number {
  const len = Math.min(a.length, b.length);
  for (let i = 0; i < len; i++) {
    if (!tokensEqual(a[i], b[i])) return i;
  }
  return len;
}

commonPrefixLength finds the index where the old and new token arrays diverge. If the old breadcrumb is Blog > Part III > The Decorator and the new breadcrumb is Blog > Part IV > The Generation Counter, the common prefix is Blog > Part — the divergence starts at the I vs I position (they happen to share Part but diverge at III vs IV).

The Animation State Machine

Diagram
TocBreadcrumb typewriter — erase to the divergence point, then type the new suffix.

Three states:

  1. idle — the breadcrumb is stable. No animation is running.
  2. erasing — the machine is removing tokens from the end, one per tick, until the common prefix length is reached.
  3. typing — the machine is appending tokens from the target, one per tick, until the full target is reached.

The tick() function is called by a requestAnimationFrame loop in the adapter. Each tick removes or adds one token. The result is a smooth character-by-character animation.

Interrupts

The most interesting behavior is the interrupt path. If the user scrolls to a new heading while an animation is in progress, the machine receives a new target. It does not restart from scratch — it recalculates the common prefix between the current display state (partially erased or partially typed) and the new target, then adjusts the erase/type plan accordingly.

This means the typewriter can change direction mid-animation without any visual glitch. If it is typing "Part IV" and the user scrolls back to "Part III", the machine:

  1. Calculates the common prefix between the current display and "Part III".
  2. If it has already typed past the divergence point, transitions to erasing to remove the extra characters.
  3. Once erased to the common prefix, transitions to typing to type the new suffix.

The result is a fluid, responsive animation that never resets to the beginning.

export type BreadcrumbTypewriterState = 'idle' | 'erasing' | 'typing';

function createBreadcrumbTypewriter(callbacks: BreadcrumbCallbacks) {
  let state: BreadcrumbTypewriterState = 'idle';
  let current: BreadcrumbToken[] = [];
  let target: BreadcrumbToken[] = [];
  let prefixLen = 0;

  function setTarget(newTarget: BreadcrumbToken[]): void {
    target = newTarget;
    prefixLen = commonPrefixLength(current, target);

    if (prefixLen === current.length && prefixLen === target.length) {
      // Tokens identical — nothing to animate
      return;
    }

    if (current.length > prefixLen) {
      state = 'erasing';
    } else {
      state = 'typing';
    }
    callbacks.onStateChange(state);
    callbacks.requestTick();
  }

  function tick(): void {
    if (state === 'erasing') {
      current = current.slice(0, -1);
      callbacks.onErase();
      if (current.length <= prefixLen) {
        if (target.length > prefixLen) {
          state = 'typing';
          callbacks.onStateChange(state);
        } else {
          state = 'idle';
          callbacks.onStateChange(state);
          return;
        }
      }
      callbacks.requestTick();
    } else if (state === 'typing') {
      const nextToken = target[current.length];
      current = [...current, nextToken];
      callbacks.onType(nextToken);
      if (current.length >= target.length) {
        state = 'idle';
        callbacks.onStateChange(state);
        return;
      }
      callbacks.requestTick();
    }
  }

  return { setTarget, tick, getState: () => state, getCurrent: () => current };
}

States: 3 (idle, erasing, typing) Transitions: 7 (see statechart, including interrupt paths) Guards: Token equality check for no-op; prefix length comparison for erase vs type Bus type: None (wired by TOC adapter) Emits: None Listens: scrollspy-active (indirectly, via the TOC adapter chain) Feature link: FEATURE-TOC-BREADCRUMB — animated breadcrumb navigation path

Why Tokens Instead of Strings?

A string-based approach would use commonPrefixLength on character arrays. This works for text but fails for icons. The breadcrumb path includes section icons (a book for "Blog", a wrench for "Projects") that are SVG elements, not characters. Treating them as single tokens means the animation erases an icon in one step (not character by character through the SVG markup) and types an icon in one step.

The BreadcrumbToken discriminated union handles both cases uniformly: tokensEqual knows how to compare chars by value and icons by key. The animation logic does not need to know the difference.

The Machine Catalog

The following table catalogs all 43 machines. For each machine: the number of states, the number of defined transitions, the events it emits and listens to, its scope (module-private or shared via adapter), and its domain.

Page Lifecycle (3 machines)

Machine States Transitions Emits Listens Scope Feature
page-load-state 6 8 app-route-ready -- shared FEATURE-SPA-NAV
app-readiness-state 2 3 app-ready -- shared FEATURE-APP-LIFECYCLE
spa-nav-state 5 7 app-route-ready -- shared FEATURE-SPA-NAV

Table of Contents (8 machines)

Machine States Transitions Emits Listens Scope Feature
toc-breadcrumb-state 3 7 -- scrollspy-active module FEATURE-TOC-BREADCRUMB
toc-scroll-state 8 12 -- scrollspy-active module FEATURE-TOC-SCROLL
toc-expand-state 3 4 toc-headings-rendered -- shared FEATURE-TOC-EXPAND
toc-tooltip-state 3 5 -- -- module FEATURE-TOC-TOOLTIP
toc-category-state 2 2 -- -- module FEATURE-TOC-FILTER
toc-stagger-state 3 4 toc-animation-done -- module FEATURE-TOC-ANIMATION
toc-active-state 2 3 toc-active-ready scrollspy-active module FEATURE-TOC-ACTIVE
toc-panel-state 2 2 -- -- module FEATURE-TOC-PANEL

Scroll & Navigation (5 machines)

Machine States Transitions Emits Listens Scope Feature
scroll-spy-machine 2 modes 4 scrollspy-active toc-headings-rendered shared FEATURE-SCROLLSPY
keyboard-nav-state 3 5 -- -- shared FEATURE-KEYBOARD-NAV
search-ui-state 2 3 -- -- shared FEATURE-SEARCH
mobile-sidebar-state 2 2 sidebar-mask-change -- shared FEATURE-MOBILE-NAV
heading-collapse-state 2 2 -- -- module FEATURE-HEADING-COLLAPSE

Theme & Appearance (7 machines)

Machine States Transitions Emits Listens Scope Feature
theme-state 4 6 -- -- shared FEATURE-THEME
accent-palette-state 2 2 -- -- module FEATURE-ACCENT-PALETTE
accent-preview-state 2 3 -- -- module FEATURE-ACCENT-PREVIEW
terminal-dots-state 3 6 sidebar-mask-change -- shared FEATURE-TERMINAL-DOTS
diagram-mode-state 3 4 -- mermaid-config-ready module FEATURE-DIAGRAMS
console-font-state 2 2 -- -- module FEATURE-CONSOLE-FONT
theme-coordinator -- -- -- -- coordinator FEATURE-THEME

Tour (5 machines)

Machine States Transitions Emits Listens Scope Feature
tour-state 4 7 -- app-ready shared FEATURE-TOUR
tour-coordinator -- -- -- app-ready coordinator FEATURE-TOUR
tour-toc-orchestrator 3 5 -- -- module FEATURE-TOUR-TOC
tour-button-demo-state 3 4 -- -- module FEATURE-TOUR-DEMO
tour-affordance-state 2 3 -- -- module FEATURE-TOUR-AFFORDANCE

Explorer (7 machines)

Machine States Transitions Emits Listens Scope Feature
explorer-filter-state 2 3 -- -- module FEATURE-EXPLORER
explorer-selection-state 3 6 -- -- module FEATURE-EXPLORER
explorer-detail-state 3 5 -- -- module FEATURE-EXPLORER
explorer-coordinator -- -- -- -- coordinator FEATURE-EXPLORER
fsm-simulator-state 3 5 -- -- module FEATURE-FSM-SIMULATOR
machine-popover-state 2 3 -- -- module FEATURE-EXPLORER
explorer-layout-state 2 2 -- -- module FEATURE-EXPLORER

Shared Utilities (8 machines)

Machine States Transitions Emits Listens Scope Feature
copy-feedback-state 4 5 -- -- module FEATURE-CODE-COPY
overlay-state 2 3 -- -- shared FEATURE-OVERLAY
sidebar-resize-state 3 5 -- -- module FEATURE-SIDEBAR-RESIZE
scroll-to-top-state 2 2 -- -- module FEATURE-SCROLL-TOP
content-warning-state 2 2 -- -- module FEATURE-CONTENT-WARNING
print-state 3 4 -- -- module FEATURE-PRINT
focus-trap-state 2 3 -- -- module FEATURE-ACCESSIBILITY
hot-reload-actions 3 5 hot-reload:content -- dev-only FEATURE-DEV-HOT-RELOAD

Catalog Observations

  1. Event participation is sparse. Of 43 machines, only 14 participate in the event topology (emit or listen). The remaining 29 are fully module-private — they communicate only through injected callbacks, never through the bus.

  2. Emitters outnumber listeners. 10 machines emit events; 7 listen. This reflects the architecture: most events are lifecycle signals dispatched by one machine and consumed by several adapters.

  3. Coordinator machines have no states. theme-coordinator, tour-coordinator, explorer-coordinator are pure composition nodes — they wire other machines together without managing their own state. They appear in the catalog because they have @FiniteStateMachine decorators (for the topology graph), but their state count is zero.

  4. State counts are small. The median is 2-3 states. The largest machine (toc-scroll-state) has 8 states. No machine exceeds 12 transitions. This is a design choice: complex behavior is decomposed into multiple small machines rather than one large one.

  5. Every machine has a feature link. 40 of 43 link to a specific feature requirement. The three exceptions are the coordinators, which link to their parent feature (the feature of the machines they coordinate).

  6. The dev-only cluster is isolated. hot-reload-actions emits hot-reload:content but no production machine listens to it — only the app-dev.ts entry point. The event topology scanner verifies this isolation: if a production machine ever starts listening to a dev-only event, the scanner flags it.

Patterns Summary

The eight machines toured in this part illustrate six recurring patterns:

Pattern Example Essence
Toggle AccentPaletteState Two states, guarded transitions, callback delegation
Timer CopyFeedbackState Auto-reset after delay; timer as isolated side effect
Compound boolean TerminalDotsState Product type with invariant; truth table, not enum
Generation counter PageLoadState Monotonic counter detects stale async operations
Barrier gate AppReadinessState Set-based join; idempotent signals; all-or-nothing transition
Dual mode ScrollSpyMachine Pure detection functions selected by mode; shared output
Diff-aware animation TocBreadcrumbState Common prefix algorithm; erase-then-type with interrupt
Navigation classifier SpaNavState Pure function maps inputs to discriminated type; exhaustive switch

These patterns are not unique to this codebase. The generation counter appears in React's concurrent mode (render lanes). The barrier gate is a CountDownLatch in Java or a WaitGroup in Go. The diff-aware typewriter is a specialized application of the longest common prefix algorithm used in diff tools. The compound boolean state with invariants is any constrained product type.

What is specific to this codebase is that all of these patterns are implemented as pure factory functions with callback injection and zero DOM coupling. The machines know nothing about the browser. They know nothing about each other. They are connected by the adapter layer (Part V) and the typed event bus (Part II), and their contracts are declared by the @FiniteStateMachine decorator (Part III) and verified by the topology scanner (Part VII).

The next part examines the other side of the coin: the adapters and coordinators that wire these pure machines to the DOM and to each other.

Continue to Part V: Coordinators and the Adapter Pattern →

⬇ Download