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 II: The Pattern -- Factory, Closures, Callbacks

Every state machine in this project follows the same template. Learn it once on a 47-line machine, and you can read all fifteen.

The Template

Every machine in src/lib/ follows this structure:

// 1. State type — what states the machine can be in
export type XxxState = 'idle' | 'active' | 'done';

// 2. Callbacks interface — how the machine talks to the outside world
export interface XxxCallbacks {
  onStateChange: (state: XxxState, prev: XxxState) => void;
  // ... other callbacks for side effects
}

// 3. Machine interface — what you can do with the machine
export interface XxxMachine {
  doSomething(): void;
  getState(): XxxState;
}

// 4. Pure helpers — logic that doesn't need the machine's state
export function computeSomething(input: number): number {
  return Math.max(0, input);
}

// 5. Factory function — creates the machine with injected dependencies
export function createXxxMachine(callbacks: XxxCallbacks): XxxMachine {
  let state: XxxState = 'idle';  // Closure state

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

  function doSomething(): void {
    if (state !== 'idle') return;  // Guard clause
    transition('active');
  }

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

Five elements:

  1. State type — a union of string literals. Not an enum, not a class hierarchy -- just strings. TypeScript narrows them in switch statements.
  2. Callbacks interface — every side effect the machine needs. DOM mutations, timers, storage, network -- all injected here. The machine never reaches outside.
  3. Machine interface — the public API. Methods to trigger transitions and getters to inspect state.
  4. Pure helpers — functions that compute values without touching the machine's state. Exported separately so they can be tested in isolation.
  5. Factory function — creates the machine. State lives in closures, not in this. No class, no constructor, no prototype chain.

Why closures instead of classes? Three reasons:

  • Encapsulation is real. Closure variables are truly private. No _private conventions, no #private syntax, no reflection escape hatches.
  • No this binding issues. You can destructure the returned object, pass methods as callbacks, store them anywhere. { doSomething } works. this.doSomething.bind(this) is never needed.
  • Smaller surface area. The machine exposes exactly the methods in the returned object. Nothing else is accessible.

Now let's see this pattern in action, starting with the simplest machine in the project.


FontSizeManager: The Hello World (47 Lines)

This is the complete implementation. All 47 lines:

export const FONT_STEPS = [12, 13, 15, 17, 18] as const;
export type FontStep = (typeof FONT_STEPS)[number];
export const DEFAULT_FONT_SIZE: FontStep = 15;

export interface FontSizeOptions {
  getStored: () => number | null;
  setStored: (size: number) => void;
  apply: (size: number) => void;
}

export function createFontSizeManager(options: FontSizeOptions) {
  function getCurrent(): FontStep {
    const stored = options.getStored();
    if (stored !== null && (FONT_STEPS as readonly number[]).includes(stored)) {
      return stored as FontStep;
    }
    return DEFAULT_FONT_SIZE;
  }

  function applySize(size: FontStep) {
    options.setStored(size);
    options.apply(size);
  }

  function change(delta: number) {
    const current = getCurrent();
    const idx = FONT_STEPS.indexOf(current);
    const nextIdx = Math.max(0, Math.min(FONT_STEPS.length - 1, idx + delta));
    const next = FONT_STEPS[nextIdx]!;
    applySize(next);
    return next;
  }

  function increase() { return change(1); }
  function decrease() { return change(-1); }

  function isAtMin() { return getCurrent() === FONT_STEPS[0]; }
  function isAtMax() { return getCurrent() === FONT_STEPS[FONT_STEPS.length - 1]; }

  return { getCurrent, change, increase, decrease, isAtMin, isAtMax, applySize };
}

Let's trace through the pattern elements:

State: Five discrete font sizes: 12, 13, 15, 17, 18 px. Not arbitrary numbers -- a finite set of valid values, exactly like a state machine's states. The current state is the stored font size.

Callbacks (FontSizeOptions):

  • getStored() -- read from localStorage (or wherever)
  • setStored(size) -- write to localStorage
  • apply(size) -- set document.documentElement.style.fontSize

The machine never touches localStorage or the DOM. It asks the caller to do it.

Guards: change() clamps the index: Math.max(0, Math.min(FONT_STEPS.length - 1, idx + delta)). You can't go below step 0 or above step 4. The guard is arithmetic, not an if statement, but the effect is the same -- invalid transitions are silently rejected.

Boundary queries: isAtMin() and isAtMax() let the UI disable buttons at the extremes. Pure computation, no DOM.

Testing this machine requires zero infrastructure:

it('should cycle through font sizes', () => {
  let stored: number | null = null;
  let applied: number | null = null;

  const manager = createFontSizeManager({
    getStored: () => stored,
    setStored: (s) => { stored = s; },
    apply: (s) => { applied = s; },
  });

  expect(manager.getCurrent()).toBe(15);  // Default
  expect(manager.isAtMin()).toBe(false);
  expect(manager.isAtMax()).toBe(false);

  manager.increase();
  expect(stored).toBe(17);
  expect(applied).toBe(17);

  manager.increase();
  expect(stored).toBe(18);
  expect(manager.isAtMax()).toBe(true);

  manager.increase();  // Already at max — no-op
  expect(stored).toBe(18);
});

No DOM. No localStorage. No timers. Runs in 1ms. That's the payoff of callback injection.


AccentPaletteMachine: Simple Toggle with Color Selection

The next step up: two states (closed and open) and a color selection action.

Diagram

The full implementation:

export type AccentColor = 'green' | 'blue' | 'purple' | 'orange'
                        | 'red' | 'cyan' | 'pink' | 'yellow';

export const ACCENT_COLORS: AccentColor[] = [
  'green', 'blue', 'purple', 'orange', 'red', 'cyan', 'pink', 'yellow',
];

export interface PaletteState {
  open: boolean;
  activeColor: AccentColor;
}

export interface PaletteMachineOptions {
  onStateChange: (state: PaletteState, prev: PaletteState) => void;
  onColorChange: (color: AccentColor) => void;
}

export interface PaletteMachine {
  toggle(): void;
  open(): void;
  close(): void;
  selectColor(color: AccentColor): void;
  getState(): PaletteState;
}

export function isValidAccent(color: string): color is AccentColor {
  return ACCENT_COLORS.includes(color as AccentColor);
}

Notice: isValidAccent() is a type guard -- it narrows string to AccentColor. This is a pure helper that exists outside the machine. It's useful everywhere: loading from localStorage, parsing URL parameters, validating user input.

The factory function:

export function createPaletteMachine(
  initialColor: AccentColor,
  options: PaletteMachineOptions,
): PaletteMachine {
  let state: PaletteState = { open: false, activeColor: initialColor };

  function snapshot(): PaletteState {
    return { ...state };
  }

  function transition(next: PaletteState): void {
    const prev = snapshot();
    state = next;
    options.onStateChange(snapshot(), prev);
  }

  function toggle(): void {
    if (state.open) close();
    else open();
  }

  function open(): void {
    if (state.open) return;  // Guard: already open
    transition({ ...state, open: true });
  }

  function close(): void {
    if (!state.open) return;  // Guard: already closed
    transition({ ...state, open: false });
  }

  function selectColor(color: AccentColor): void {
    if (!isValidAccent(color)) return;  // Guard: invalid color
    const prev = snapshot();
    state = { open: false, activeColor: color };
    options.onStateChange(snapshot(), prev);
    if (color !== prev.activeColor) {
      options.onColorChange(color);
    }
  }

  return { toggle, open, close, selectColor, getState: snapshot };
}

New concepts introduced:

Compound state: Unlike FontSizeManager which has a single scalar state, the palette has a record: { open: boolean; activeColor: AccentColor }. The snapshot() function creates a copy for change detection.

Guard clauses: Three guards protect against invalid transitions:

  • open() when already open → no-op
  • close() when already closed → no-op
  • selectColor() with an invalid color → no-op

No exceptions. No error callbacks. Just... nothing happens. This is a deliberate design choice. Invalid transitions are silently ignored because they represent impossible user interactions, not programming errors.

Conditional callback: selectColor() only fires onColorChange if the color actually changed. Selecting the same color twice closes the palette but doesn't trigger a theme update. This is behavior logic -- and it lives in the machine, not in the DOM wiring.


CopyFeedbackMachine: Timers and Auto-Reset

Now things get more interesting. The copy button has four states and an auto-reset timer:

Diagram

The key innovation: timer injection. The machine never calls setTimeout. It asks the caller to schedule a reset:

export interface CopyFeedbackOptions {
  resetDelay: number;
  onStateChange?: (state: CopyFeedbackState, prev: CopyFeedbackState) => void;
  scheduleReset?: (delayMs: number, callback: () => void) => void;
  cancelReset?: () => void;
}

The scheduleReset callback takes a delay and a callback function. The machine says "please call this function in 2000ms." In production, the wiring layer uses setTimeout. In tests, the test calls the callback immediately -- no timer mocking needed.

The transition guard is a pure function:

function canTransition(from: CopyFeedbackState, to: CopyFeedbackState): boolean {
  switch (from) {
    case 'idle':    return to === 'copying';
    case 'copying': return to === 'success' || to === 'error';
    case 'success': return to === 'idle' || to === 'copying';
    case 'error':   return to === 'idle' || to === 'copying';
  }
}

This is an explicit transition table. Every valid transition is listed. Everything else returns false. Compare this to the original code where valid transitions were scattered across if chains in event handlers -- here, the entire transition logic fits in 6 lines.

The rapid re-click scenario that broke the original code? Handled cleanly:

function copy(): void {
  // Allow re-entry from success/error (rapid clicks) by resetting first
  if (state === 'success' || state === 'error') {
    cancelReset();            // Cancel pending timer
    const prev = state;
    state = 'idle';           // Reset to idle
    onStateChange('idle', prev);
  }
  transition('copying');      // Then start new copy
}

If you're in success and click again, the machine cancels the pending reset timer, transitions to idle, then immediately transitions to copying. No race condition. No stale timer. The state machine structure makes the fix obvious.


SidebarResizeMachine: Drag State and Pure Computation

The sidebar resize machine has two states (idle and dragging) and introduces a new concept: drag snapshots.

Diagram

The pure helper functions are exported separately:

export function clampWidth(
  width: number,
  windowWidth: number,
  config: ResizeConfig = DEFAULT_RESIZE_CONFIG,
): number {
  const max = windowWidth * config.maxWidthFraction;
  return Math.min(Math.max(config.minWidth, width), max);
}

export function computeDefaultWidth(
  windowWidth: number,
  config: ResizeConfig = DEFAULT_RESIZE_CONFIG,
): number {
  return Math.round(
    clampWidth(windowWidth * config.defaultWidthFraction, windowWidth, config)
  );
}

These functions don't need the machine at all. They're pure math: given a width and constraints, return the clamped result. Exporting them separately means they can be tested independently and reused elsewhere.

The machine itself manages the drag lifecycle:

export function createSidebarResizeMachine(
  config: ResizeConfig = DEFAULT_RESIZE_CONFIG,
): SidebarResizeMachine {
  let state: ResizeState = 'idle';
  let drag: DragSnapshot | null = null;
  let lastWidth = 0;
  let userHasResized = false;

  return {
    startDrag(clientX: number, currentWidth: number): void {
      if (state === 'dragging') return;  // Guard
      state = 'dragging';
      drag = { startX: clientX, startWidth: currentWidth };
      lastWidth = currentWidth;
    },

    moveDrag(clientX: number, windowWidth: number): number {
      if (state !== 'dragging' || !drag) return lastWidth;  // Guard
      const rawWidth = drag.startWidth + (clientX - drag.startX);
      lastWidth = clampWidth(rawWidth, windowWidth, config);
      return lastWidth;
    },

    endDrag(): number {
      if (state !== 'dragging') return lastWidth;  // Guard
      state = 'idle';
      userHasResized = true;
      drag = null;
      return lastWidth;
    },

    // ...
  };
}

New concept: DragSnapshot. When a drag starts, the machine captures the starting position (startX) and starting width (startWidth). During the drag, moveDrag() computes the new width as startWidth + (clientX - startX) -- a delta from the starting position. This is more numerically stable than accumulating deltas frame by frame.

Notice that moveDrag() returns the clamped width instead of calling a callback. This is a stylistic variation. The wiring layer calls moveDrag() on every mousemove event and applies the returned width to the DOM:

// In the wiring layer (app-shared.ts)
document.addEventListener('mousemove', (e) => {
  const width = resizeMachine.moveDrag(e.clientX, window.innerWidth);
  sidebar.style.width = `${width}px`;
});

The machine computes. The wiring renders. Clean separation.


The Configuration Pattern

Notice that SidebarResizeMachine takes a config parameter:

export interface ResizeConfig {
  minWidth: number;            // Minimum sidebar width in px
  maxWidthFraction: number;    // Maximum as fraction of window (0-1)
  defaultWidthFraction: number; // Default as fraction of window (0-1)
}

export const DEFAULT_RESIZE_CONFIG: ResizeConfig = {
  minWidth: 180,
  maxWidthFraction: 0.5,
  defaultWidthFraction: 0.22,
};

Configuration is separate from callbacks. Callbacks are behaviors (things the machine asks the outside world to do). Configuration is data (values that shape the machine's decisions). The machine can have defaults for configuration but not for callbacks -- callbacks are the contract between the machine and its environment.

CopyFeedbackMachine uses the same pattern: resetDelay: number is configuration (how long to show the success/error state). scheduleReset is a callback (how to schedule the reset timer).


Why Not xstate?

A reasonable question: if you're building state machines, why not use xstate, the de facto standard for state machines in JavaScript?

Three reasons:

1. The machines are too simple. xstate shines for complex machines with hierarchical states, parallel regions, and actor composition. Our simplest machine is 47 lines. Our most complex is 163 lines. Adding xstate's dependency (40KB minified) for machines that fit in one screenful is over-engineering.

2. No runtime dependencies. The state machines in src/lib/ have zero imports. They don't depend on any library. They're pure TypeScript with no node_modules overhead. This means they compile instantly (esbuild has nothing to resolve), test instantly (no framework initialization), and bundle predictably (no transitive dependencies).

3. The pattern is the point. This site is a portfolio. The state machines demonstrate a design approach -- factory functions, callback injection, pure helpers. Using xstate would replace the design with a framework. The machines exist to show how to build them, not just that they work.

xstate is excellent software. If you're building a complex application with actors, parallel states, and state charts, use it. For 15 small, independent machines that each fit in a single file, the factory pattern is simpler, lighter, and more educational.


Testing These Machines

Each of the four machines in this part has a corresponding test file. Here's what a complete test setup looks like for CopyFeedbackMachine:

function setup(overrides?: Partial<CopyFeedbackOptions>) {
  const options: CopyFeedbackOptions = {
    resetDelay: 2000,
    onStateChange: vi.fn(),
    scheduleReset: vi.fn(),
    cancelReset: vi.fn(),
    ...overrides,
  };
  const machine = createCopyFeedback(options);
  return { machine, options };
}

Every callback is a vi.fn() spy. The test can verify:

  • Which callbacks were called: expect(options.scheduleReset).toHaveBeenCalled()
  • With what arguments: expect(options.scheduleReset).toHaveBeenCalledWith(2000, expect.any(Function))
  • How many times: expect(options.onStateChange).toHaveBeenCalledTimes(2)
  • In what order: using stateChanges array pattern from Part VI

The entire CopyFeedbackMachine test file is 231 lines and covers:

  • Normal lifecycle: idle → copying → success → idle
  • Error path: idle → copying → error → idle
  • Rapid re-click: success → idle → copying (cancel + restart)
  • Auto-reset timer: succeed() schedules reset, reset() transitions to idle
  • Guard clauses: succeed() from idle is no-op, fail() from idle is no-op
  • Label resolution: each state maps to the correct emoji

All 231 lines run in under 10ms. No DOM. No timers. No clipboard.


Pattern Summary

Across these four machines, we've seen every element of the pattern:

Element FontSizeManager AccentPalette CopyFeedback SidebarResize
States 5 font steps open/closed + color idle/copying/success/error idle/dragging
Callbacks 3 (get/set/apply) 2 (stateChange/colorChange) 4 (stateChange/schedule/cancel/delay) 0 (returns values)
Guards Boundary clamping Already open/closed, invalid color Transition table State check
Pure helpers isValidAccent() canTransition(), getLabel() clampWidth(), computeDefaultWidth()
Config Initial color Reset delay Min/max/default widths

The pattern scales. These four machines are 47, 91, 105, and 114 lines respectively. The most complex machine in the project (TocScrollMachine, Part IV) is 163 lines. Same pattern. Same structure. Just more states and more transitions.


How They Wire to the DOM

The machines are half the story. The other half is the wiring layer -- the code in app-shared.ts and app-static.ts that connects machines to the real DOM, real timers, and real events.

Here's how the CopyFeedbackMachine wires up in practice:

// In the wiring layer
const copyMachine = createCopyFeedback({
  resetDelay: 2000,
  onStateChange: (state) => {
    button.textContent = getLabel(state);
    button.className = `copy-btn copy-${state}`;
  },
  scheduleReset: (ms, cb) => { clearTimeout(timerId); timerId = setTimeout(cb, ms); },
  cancelReset: () => { clearTimeout(timerId); },
});

button.addEventListener('click', async () => {
  copyMachine.copy();
  try {
    await navigator.clipboard.writeText(codeText);
    copyMachine.succeed();
  } catch {
    copyMachine.fail();
  }
});

The wiring layer is where DOM, clipboard API, and timers live. The machine is where state logic lives. The boundary between them is the callbacks interface. This is the same separation that makes the machine testable -- the wiring layer is tested by Playwright E2E tests, the machine by Vitest unit tests.


What's Next

These four machines are simple by design. Each has 2-5 states. Each has 1-4 callbacks. Each fits on a single screen.

Part III: Navigation and Interaction Machines raises the stakes. SpaNavMachine has 5 states and 9 callbacks. PageLoadMachine introduces the generation counter for stale-request detection. And we'll see three variations on the pattern: factory-with-closure, immutable reducer, and pure-functions-only.

The pattern stays the same. The problems get harder.


Appendix: The Original vs Extracted (Line-by-Line)

To see the extraction concretely, here's the sidebar resize — the original code from app.js and the extracted machine:

Original (in app.js, scattered across 3 event handlers)

let isResizing = false;
let resizeStartX = 0;
let resizeStartWidth = 0;
let userHasResized = false;

function handleSidebarMouseDown(e) {
  if (isResizing) return;
  isResizing = true;
  resizeStartX = e.clientX;
  resizeStartWidth = sidebar.offsetWidth;
  document.addEventListener('mousemove', handleSidebarMouseMove);
  document.addEventListener('mouseup', handleSidebarMouseUp);
  document.body.style.userSelect = 'none';
  document.body.style.cursor = 'col-resize';
}

function handleSidebarMouseMove(e) {
  if (!isResizing) return;
  const newWidth = resizeStartWidth + (e.clientX - resizeStartX);
  const clamped = Math.max(180, Math.min(window.innerWidth * 0.5, newWidth));
  sidebar.style.width = `${clamped}px`;
}

function handleSidebarMouseUp() {
  if (!isResizing) return;
  isResizing = false;
  userHasResized = true;
  localStorage.setItem('sidebarWidth', sidebar.offsetWidth);
  document.removeEventListener('mousemove', handleSidebarMouseMove);
  document.removeEventListener('mouseup', handleSidebarMouseUp);
  document.body.style.userSelect = '';
  document.body.style.cursor = '';
}

Mixed concerns: state management (isResizing, resizeStartX), DOM mutation (sidebar.style.width), event management (addEventListener/removeEventListener), persistence (localStorage), and UX (cursor, userSelect).

Extracted (SidebarResizeMachine + wiring)

Machine (src/lib/sidebar-resize-state.ts — 114 lines, zero DOM):

export function createSidebarResizeMachine(config: ResizeConfig): SidebarResizeMachine {
  let state: ResizeState = 'idle';
  let drag: DragSnapshot | null = null;
  let lastWidth = 0;
  let userHasResized = false;

  return {
    startDrag(clientX, currentWidth) {
      if (state === 'dragging') return;
      state = 'dragging';
      drag = { startX: clientX, startWidth: currentWidth };
      lastWidth = currentWidth;
    },
    moveDrag(clientX, windowWidth) {
      if (state !== 'dragging' || !drag) return lastWidth;
      lastWidth = clampWidth(drag.startWidth + (clientX - drag.startX), windowWidth, config);
      return lastWidth;
    },
    endDrag() {
      if (state !== 'dragging') return lastWidth;
      state = 'idle';
      userHasResized = true;
      drag = null;
      return lastWidth;
    },
    // ... getters
  };
}

Wiring (in app-shared.ts — 20 lines, all DOM):

const resize = createSidebarResizeMachine();

divider.addEventListener('mousedown', (e) => {
  resize.startDrag(e.clientX, sidebar.offsetWidth);
  document.body.style.userSelect = 'none';
  document.body.style.cursor = 'col-resize';
});

document.addEventListener('mousemove', (e) => {
  if (resize.getState() !== 'dragging') return;
  sidebar.style.width = `${resize.moveDrag(e.clientX, window.innerWidth)}px`;
});

document.addEventListener('mouseup', () => {
  if (resize.getState() !== 'dragging') return;
  const width = resize.endDrag();
  localStorage.setItem('sidebarWidth', String(width));
  document.body.style.userSelect = '';
  document.body.style.cursor = '';
});

Same behavior. But the machine is testable:

it('should clamp width to config bounds', () => {
  const m = createSidebarResizeMachine({ minWidth: 180, maxWidthFraction: 0.5, defaultWidthFraction: 0.22 });
  m.startDrag(100, 200);
  expect(m.moveDrag(10, 1000)).toBe(180);   // Min: 180
  expect(m.moveDrag(600, 1000)).toBe(500);   // Max: 1000 * 0.5
  expect(m.moveDrag(300, 1000)).toBe(400);   // Normal: 200 + (300-100)
});

No DOM. No events. No body.style. Pure computation, pure assertions.