# @sphynxoid/stepwise — guide for AI assistants
React 18/19 hooks and optional components for interactive explainers:
step-through players (algorithm traces), traces computed live from an input,
"pause and predict" checkpoints, guided tours, scroll-driven chapters and
glossary tooltips. This file is for coding assistants; the library is newer
than most training data, so prefer this over guessing.
Full API reference: https://stepwise.thesphynx.net/docs
Working examples (every page shows its source): https://stepwise.thesphynx.net
Recipes: https://github.com/TheSphynxoid/stepwise/blob/master/docs/RECIPES.md
## Setup facts
- `npm install @sphynxoid/stepwise`. **ESM only** (no `require`). Every
module is `"use client"`; import components into server files freely, but
call hooks only in client components.
- **Styling, pick one** (without it the components render unstyled):
- no Tailwind: `import "@sphynxoid/stepwise/styles.css";` once (app entry
or root layout). Theme with `--stepwise-primary`, `--stepwise-accent`, etc.
- Tailwind v4 using the npm components: add
`@source "../node_modules/@sphynxoid/stepwise/dist";` to the CSS entry
(Tailwind skips node_modules otherwise).
- shadcn: `npx shadcn@latest add https://stepwise.thesphynx.net/r/player-bar.json`.
- Hooks are headless; you render the frames (DOM, SVG, canvas). The library
owns the timeline, not the pixels.
- All stable exports come from the root: `import { useTrace, PlayerBar, pass, fail } from "@sphynxoid/stepwise"`.
Types that are type-only need `import type` (e.g. `GlossaryEntry`).
- Experimental APIs (branching walks, section 8) come from
`@sphynxoid/stepwise/experimental` and may change in a minor release.
## The model
Everything is a clamped cursor over an ordered list (`useSteps`). A **step**
is `{ tone, label, code?, note?, key? } & YourData`, where `tone` is exactly
`"neutral" | "accent" | "good" | "bad" | "write" | "idle"`.
## 1. A fixed trace: `useTrace` + `PlayerBar`
```tsx
"use client";
import { createTrace, useTrace, PlayerBar } from "@sphynxoid/stepwise";
// Build traces at module scope, never inside the component.
const frames = createTrace<{ arr: number[] }>()
.step({ tone: "neutral", label: "Start", arr: [3, 1, 2] })
.step({ tone: "write", label: "Swap 3 and 1", arr: [1, 3, 2] })
.step({ tone: "good", label: "Sorted", arr: [1, 2, 3] })
.build();
export function SortDemo() {
const { step, player, toneAt, titleAt } = useTrace(frames, { autoPlay: true, syncHash: "frame" });
return (
<>
{step!.arr.join(" ")}: {step!.label}
>
);
}
```
- `step` is frame 0 before playback starts (never undefined for a non-empty
trace). `player.index` is -1 before start: don't index with it, use `step`.
- `autoPlay` respects `prefers-reduced-motion` (stays paused on frame 0).
- `syncHash: "frame"` puts `#frame=N` in the URL and restores it on load.
## 2. A trace computed from an input: `useSimulation`
```tsx
import { useState } from "react";
import { useSimulation, PlayerBar, CodeBlock, type Simulation, type StepwiseStep } from "@sphynxoid/stepwise";
type Frame = StepwiseStep<{ arr: number[] }>;
// Module scope. Yield copies (snapshots), and give EVERY step a key naming
// its moment, so the playhead keeps its place when the input changes.
const insertionSort: Simulation = function* (input) {
const a = [...input];
yield { key: "start", tone: "neutral", label: "start", arr: [...a] };
for (let i = 1; i < a.length; i++)
for (let j = i; j > 0 && a[j - 1] > a[j]; j--) {
[a[j - 1], a[j]] = [a[j], a[j - 1]];
yield { key: `swap ${i}.${j}`, tone: "write", label: `swap at ${j}`, arr: [...a], code: [`swap(${j - 1}, ${j})`] };
}
};
export function Explorer() {
const [input, setInput] = useState([5, 2, 8, 1]);
const sim = useSimulation(insertionSort, input, { autoPlay: true });
// Editing `input` mid-playback recomputes the trace and keeps playing from the same step.
return (
<>
{sim.step!.arr.join(" ")}
>
);
}
```
- Randomness: use the seeded `ctx.rng` (`function* (input, { rng })`, with
`rng.int(min, max)`, `rng.shuffle(items)`, `rng.pick(items)`), never
`Math.random`. Pass `seed` in the options.
- **Object inputs must keep their identity**: keep them in state or `useMemo`.
`useSimulation(sim, { arr, target })` inline recomputes every render
(development builds warn).
- `runSimulation(sim, input).steps` is the pure runner: use it in unit tests.
## 3. Shareable links (input + frame in the URL)
```tsx
const sim = useSimulation(insertionSort, input, {
syncHash: "frame",
syncInput: {
key: "in",
serialize: (a) => a.join("."),
parse: (s) => { const a = s.split(".").map(Number); return a.every(Number.isInteger) ? a : null; },
onRestore: setInput, // the library hands you the linked input on mount
},
});
```
Don't restore the input from the URL by hand: the player reads `#frame=`
before your effect runs, against the default input's trace, so the frame
lands wrong. `syncInput` (0.4+) orders it correctly. For other state in the
URL use `readHashParam(hash, key)` / `writeHashParam(hash, key, value)`.
## 4. Checkpoints and grading ("pause and predict")
```tsx
const sim = useSimulation(sort, input, { autoPlay: true, holdAt: (_, step) => step?.kind === "compare" });
const { player } = sim;
const truth = player.held !== null ? sim.steps[player.held + 1]?.kind : undefined; // the next frame
const [guess, setGuess] = useState(null);
const answer = useEvaluator((g: string | null) => (g === truth ? pass("Right!") : fail("Look again.")), guess, { mode: "manual" });
const { reset } = answer;
useEffect(() => { setGuess(null); reset(); }, [player.held, reset]); // new checkpoint, new question
// While player.held !== null: options → setGuess; Check → answer.check(); Continue/Skip → player.release()
```
- A held player **does not resume by itself**: call `player.release()`.
- Manual mode: the verdict clears when `ctx` (the guess) changes; `attempts`
is kept until `reset()`.
- Make every possible next frame an answer option.
## 5. Timelines that grow while watched
`useTrace(events, { autoPlay: true, follow: true })` plays appended frames
when the reader is at the edge; a reader who scrubbed back stays put.
`player.live`, `player.goLive()`, and `` for a "Go live"
badge.
## 6. Guided tours
```tsx
import { useGuidedTour, TourSpotlight, TourCard, type TourStepInput } from "@sphynxoid/stepwise";
const steps: TourStepInput[] = [
{ id: "bars", title: "The frame", body: "Each frame is yours to render.", target: "bars" }, // element id or [data-tour]
{ id: "try", title: "Your turn", body: "Press play.", target: "player", gate: () => played || fail("Press play first.") },
];
const tour = useGuidedTour(steps);
//
{tour.active && tour.current && tour.index !== null && (
<>
>
)}
```
Steps can also have `onEnter` (change app state), `when` (skip if false) and
`next` (jump to another step id). `useDiscoveries()` remembers which tours
a user has seen.
## 7. Scroll chapters and glossary
- `const c = useChapters([{ id: "intro", label: "Intro" }, ...])`; sections
are found by DOM id; `c.active` is the chapter at the viewport center;
``.
- Glossary: wrap the page in ``
(definitions are ReactNode and may contain ``); mark prose with
`TLS`; `` lists the entries.
**`Term` and `GlossaryAppendix` must be inside the provider.**
## 8. Branching walks (experimental)
A walk is a simulation that branches on the reader's answers. A step with
`await: true` is a **choice point**: that `yield` returns the choice.
The path taken is still a plain array, so `PlayerBar`, links and
re-anchoring work as usual. Import from `@sphynxoid/stepwise/experimental`.
```tsx
import { PlayerBar } from "@sphynxoid/stepwise";
import { useWalk, type WalkSimulation, type WalkStep } from "@sphynxoid/stepwise/experimental";
type Pick = "first" | "median";
type S = WalkStep & { arr: number[] };
const pivotLesson: WalkSimulation = function* (input) {
const arr = [...input];
yield { key: "start", tone: "neutral", label: "Unsorted", arr: [...arr] };
const pick = yield { key: "fork", tone: "accent", label: "Which pivot?", arr: [...arr], await: true };
if (pick === "first") yield { key: "first", tone: "bad", label: "First element: worst case on sorted input", arr: [...arr] };
else yield { key: "median", tone: "good", label: "Median of three: balanced splits", arr: [...arr] };
};
const walk = useWalk(pivotLesson, input, { autoPlay: true, syncHash: "frame",
syncChoices: { key: "path", serialize: (c) => c, parse: (v) => (v === "first" || v === "median" ? v : null) } });
const asking = walk.choicePoints.includes(walk.frame);
// {asking && ["first", "median"].map((c) => )}
//
```
- Playback holds on the unanswered choice point at the end; `choose()`
continues the walk and resumes. On an earlier choice point a *different*
answer cuts off the old future and replays from there.
- A walk is (simulation, input, seed, choices): `syncChoices` puts the
choices in the URL, so a link replays the same path.
- Automata: `fromGraph({ start, nodes, edges: [{ from, on, to }], accept, otherwise })`
gives `.simulation` (run it over a word: one step per symbol) and
`.graph` (draw it). Use `useWalk(dfa.simulation, word, { anchor: "index" })`
so editing the word keeps the position.
- `walk.lastChange` (`diffTraces`) says which steps an edit changed.
## Pitfalls checklist
1. Unstyled components → missing `styles.css` import or Tailwind `@source`.
2. Traces built inside a component, or referencing later consts → build at module scope.
3. Rendering `steps[player.index]` → -1 before start; use `step` from `useTrace`/`useSimulation`.
4. Simulation steps without `key` → edits snap back to the same index instead of the same moment.
5. Mutating and yielding the same array → every frame shows the final state; yield copies.
6. Inline object input to `useSimulation` → recompute every render; memoize.
7. Hand-rolled URL restore of the input → use `syncInput`.
8. A checkpoint that never continues → wire `player.release()`.
9. `StepTone` values outside the six listed → type error; there is no `"contrast"`.
10. Next.js App Router + MDX: `mdx-components.tsx` at the root is required (else
"createContext is not a function"); pass remark plugins by name under Turbopack.
11. CommonJS (`require`) → not supported; use ESM or `await import()`.
12. Browser tests: prefer `data-testid`; emulate reduced motion for linked-URL tests.
13. A choice point without `await: true` → the walk never pauses for the answer; the `yield` gets `undefined`.
---
# API reference
## Core
The one primitive. Everything else is a policy layered over this cursor.
### useSteps
```ts
useSteps(items: readonly T[] | number, opts?: UseStepsOptions): StepsCore
```
A clamped cursor over an ordered sequence. Re-clamps when the count shrinks; all callbacks are referentially stable.
UseStepsOptions:
- `initialIndex`: `number` (default 0). Initial focus. Clamped.
- `onChange`: `(index: number) => void`. Fired whenever the focus index changes.
StepsCore (returned):
- `count`: `number`. Number of steps in the sequence.
- `index`: `number`. Clamped focus: 0..count-1, or -1 when the sequence is empty.
- `atStart / atEnd`: `boolean`. Position flags.
- `canNext / canPrev`: `boolean`. Whether a move in that direction would change the index.
- `go`: `(index: number) => void`. Clamped jump. No-op on an empty sequence.
- `next / prev`: `() => void`. Clamped single-step moves.
- `first`: `() => void`. Jump to the first step.
## Policies
Hooks that drive the cursor from a timer, a tour, the scroll position or a ledger.
### useStepPlayer
```ts
useStepPlayer(opts: StepPlayerOptions): StepPlayer
```
Player policy: timer, speed, seek, optional #hash deep link and an engine port for GSAP or scroll. index === -1 means not started.
StepPlayerOptions:
- `total`: `number`. Frames in the current timeline (0 = no timeline loaded). Required.
- `baseDelayMs`: `number` (default 900). Delay per frame at 1x speed (built-in timer engine).
- `autoPlay`: `boolean` (default false). Start playing immediately when a timeline exists on first render.
- `engine`: `PlayerEngine`. Alternate frame source replacing the timer. Memoize per timeline - identity changes reconnect it.
- `syncHash`: `string`. Deep-link the frame into the URL hash as #=. Other hash parts are kept, including a bare #anchor, which becomes #anchor&frame=3: the page still works, but a shared copy of that URL no longer scrolls to the anchor. Read on mount and on hashchange.
- `onEnd`: `() => void`. Fired once when playback runs to the final frame on its own. Cleared by any cursor movement.
- `respectReducedMotion`: `boolean` (default true). With autoPlay, land on frame 0 but stay paused under prefers-reduced-motion.
- `holdAt`: `(frame: number) => boolean`. Checkpoints: autoplay pauses on these frames and reports held. It doesn't resume on its own: call release() (a Continue button) or press play. Manual steps and seeks are never held; each checkpoint holds once per playthrough.
- `follow`: `boolean` (default false). Follow the live edge of a growing timeline: when frames are appended while the reader is live (playing, or parked on the last frame), playback continues into them. A reader who scrubbed back or paused mid-trace stays put. With autoPlay, a player that mounts empty starts on the first frames. Checkpoints still hold; ignored with an engine.
StepPlayer (returned):
- `total`: `number`. Total frames in the current timeline.
- `frame`: `number`. Raw frame counter; -1 means no timeline / before the first frame.
- `index`: `number`. Frame clamped into [0, total-1]; -1 when there is nothing to show.
- `playing / speed`: `boolean / number`. Playback state.
- `atEnd`: `boolean`. Parked on the final frame (a seek counts too).
- `ended`: `boolean`. Playback ran to the end on its own and nothing has moved since. toggle() at ended replays.
- `held`: `number | null`. The checkpoint frame playback is paused on (see holdAt).
- `release`: `() => void`. Release the checkpoint and resume playing.
- `jump`: `(frame: number) => void`. Move the cursor without touching play state - for re-anchoring after the timeline changes. User-facing moves should seek.
- `live`: `boolean`. With follow: the reader is at the live edge, so appended frames will play. Always false without follow.
- `goLive`: `() => void`. Move to the last frame (paused); with follow, new frames play from there.
- `start`: `() => void`. Jump to frame 0 and play.
- `reset`: `() => void`. Clear the player (frame = -1, paused).
- `seek`: `(frame: number) => void`. Jump to a frame.
- `next`: `(onEmpty?: () => void) => void`. Step forward; pauses first. onEmpty fires when there is no timeline.
- `prev`: `() => void`. Step backward; pauses first.
- `toggle`: `(onEmpty?: () => void) => void`. Play/pause; replays from 0 at the end.
- `setPlaying / setSpeed`: `(v) => void`. Direct setters.
Example: https://stepwise.thesphynx.net/minimal
### useTrace
```ts
useTrace(steps: readonly S[], opts?: TraceOptions): TracePlayer
```
A player bound to a typed trace. Resolves the frame to render (frame 0 before start) and returns stable scrubber accessors. steps may change: keyed steps re-anchor, so the playhead stays on the same logical step and playback continues.
TraceOptions (plus every StepPlayerOptions field except total):
- `keyOf`: `(step: S, index: number) => string | undefined` (default step.key). Step identity for re-anchoring when steps changes. Unkeyed traces keep the same index, clamped.
- `holdAt`: `(frame: number, step: S | undefined) => boolean`. Checkpoints, with the step from the current trace.
TracePlayer (returned):
- `player`: `StepPlayer`. The underlying player - pass it to PlayerBar.
- `step`: `S | undefined`. The frame to render. Frame 0 before the player starts; undefined only for an empty trace.
- `frame`: `number`. Index of step in the trace (0 before start; -1 for an empty trace).
- `toneAt`: `(i: number) => StepTone`. Stable accessor for PlayerBar / StepScrubber.
- `titleAt`: `(i: number) => string`. Stable accessor for PlayerBar / StepScrubber.
Example: https://stepwise.thesphynx.net/player
### useSyncedTracks / alignTracks
```ts
useSyncedTracks(tracks: T, opts?: SyncedTracksOptions): SyncedTracks
```
Comparison mode: several traces of the same input under one player. Shorter tracks hold their last frame, or steps align on shared sync keys. alignTracks is the pure alignment, usable on the server.
SyncedTracksOptions (plus every StepPlayerOptions field except total):
- `key`: `(step, index, track) => string | null | undefined`. Sync-point key. Matching keys share a frame; the n-th occurrence pairs with the n-th. Omit to hold shorter tracks.
SyncedTracks (returned):
- `player`: `StepPlayer`. One player over the aligned timeline - pass it to PlayerBar.
- `frame / total`: `number`. Aligned frame to render (0 before start, -1 when all tracks are empty) and frame count.
- `tracks`: `{ [K]: TrackView }`. Per track: step, index, held (did not advance this frame), done, length.
- `rows`: `readonly AlignedRow[]`. The full alignment table.
- `toneAt / titleAt`: `(i: number) => ...`. Stable scrubber accessors: most severe tone, and track: label for every track that moved.
Example: https://stepwise.thesphynx.net/compare
### createTrace
```ts
createTrace(): TraceBuilder
```
Typed trace builder: run your algorithm at module scope and record frames as you go. Pure data - no React, safe on the server.
TraceBuilder:
- `step`: `(step: StepwiseStep) => TraceBuilder`. Append a frame; chains.
- `length`: `number`. Frames appended so far.
- `last`: `() => StepwiseStep | undefined`. Most recent frame.
- `build`: `() => StepwiseStep[]`. Snapshot - a fresh array each call.
Example: https://stepwise.thesphynx.net/compare
### useSimulation
```ts
useSimulation(simulation: Simulation, input: Input, opts?: SimulationOptions): SimulationPlayer
```
A live trace: runs a generator on the input and plays the result. Change the input or seed and the trace is recomputed while keyed steps keep the playhead in place. Define the simulation at module scope, and keep object inputs in state or useMemo: an inline { arr, target } is a new object every render, so the trace would be recomputed every render (development builds warn about it).
SimulationOptions (plus every TraceOptions field):
- `seed`: `number | string` (default 0). Seed for ctx.rng. Same simulation + input + seed = same trace.
- `maxSteps`: `number` (default 10000). Stop collecting after this many steps and report truncated.
- `syncInput`: `SyncInputOptions`. Keep the input in the URL next to the frame, so a link replays the exact run (see below). Since 0.4.
SyncInputOptions:
- `key`: `string`. Hash key for the input (#in=...). Must differ from syncHash.
- `serialize`: `(input: Input) => string`. Input to a URL-safe string ([5, 2, 8] to "5.2.8").
- `parse`: `(value: string) => Input | null`. Back to an input; return null to ignore a malformed or hostile value.
- `onRestore`: `(input: Input) => void`. Gets the linked input once on mount; pass your setInput. The linked syncHash frame is then applied to the trace of that input, not the default one. Edits are written back to the URL.
SimulationPlayer (returned: every TracePlayer field, plus):
- `steps`: `readonly S[]`. The computed trace.
- `truncated`: `boolean`. maxSteps cut the run short.
- `error`: `unknown`. What the simulation threw; steps holds what it yielded before.
- `seed`: `number`. Numeric seed used (strings are hashed).
Example: https://stepwise.thesphynx.net/simulate
### runSimulation / createRng
```ts
runSimulation(simulation, input, opts?: { seed?, maxSteps? }): SimulationResult · createRng(seed?: number | string): Rng
```
The pure pieces: run a simulation to completion (safe at module scope and on the server), and a seeded mulberry32 generator - the only randomness a simulation should use, so runs replay exactly.
Simulation:
- `(input, ctx) => Iterable`: `generator or array`. Yield one step per moment worth showing. Yield snapshots (copies), and give steps a key.
- `ctx.rng`: `Rng`. Seeded randomness: next(), int(min, max), pick(items), shuffle(items), seed.
Example: https://stepwise.thesphynx.net/simulate
### reanchor
```ts
reanchor(oldSteps, oldIndex, newSteps, keyOf): number
```
The re-anchoring rule useTrace applies: walk back from the old position to the latest step whose key both traces share, and land on it. No shared key returns 0; an empty trace returns -1. Pure - use it in custom players.
### useGuidedTour
```ts
useGuidedTour(steps: TourStepInput[], opts?: GuidedTourOptions): GuidedTour
```
Tour policy: onEnter effects over your state, conditional steps (when), guards (gate), branching edges (next) and an optional imperative driver.
TourStepInput:
- `id`: `string`. Stable id for gotoId and next edges.
- `title / body`: `string / ReactNode`. Card content.
- `target`: `string`. Element to spotlight: DOM id first, then [data-tour="..."].
- `onEnter`: `() => void`. Effect run when the step becomes active - drive your app state here.
- `waitHint`: `string`. Pulsing hint shown on the card.
- `when`: `() => boolean`. Conditional step: false skips it in the direction of travel. Evaluated on arrival.
- `gate`: `() => boolean | Verdict`. Guard: until it passes, next() is a no-op and the tour reports blocked. Return a Verdict to explain what's missing (tour.feedback).
- `next`: `() => string | null`. Edge override: advance to this step id instead of index+1.
GuidedTourOptions:
- `driver`: `TourDriver`. Imperative presenter (driver.js, Shepherd...). When set, don't render TourSpotlight/TourCard.
GuidedTour (returned):
- `index`: `number | null`. Active step index, or null when the tour is not running.
- `current`: `TourStepInput | null`. The active step.
- `active`: `boolean`. Whether the tour is running.
- `blocked`: `boolean`. The active step's gate is closed; pair with TourCard nextDisabled.
- `feedback`: `ReactNode`. Feedback from the active gate's Verdict; pair with TourCard feedback.
- `start / end`: `() => void`. Run from the first eligible step / stop.
- `next / back`: `() => void`. Move along edges, honoring gates and skips.
- `goto`: `(index: number) => void`. Programmatic jump (ignores gates).
- `gotoId`: `(id: string) => void`. Jump to a step by id. No-op when unknown.
Example: https://stepwise.thesphynx.net/tour
### useChapters
```ts
useChapters(chapters: Chapter[]): ChaptersApi
```
Scroll-tracked chapters. Active = the section containing the viewport center. Sections are resolved by DOM id; give them scroll-mt to clear sticky headers.
Chapter:
- `id`: `string`. DOM id of the chapter's section.
- `label / hint`: `string`. Nav label and tooltip.
- `children`: `Chapter[]`. Nested sub-chapters, any depth.
- `numbered`: `boolean` (default true). Show the ordinal chip in ChapterNav.
ChaptersApi (returned):
- `active`: `number`. Index of the chapter in view (flattened order).
- `goTo`: `(index: number) => void`. Smooth-scroll to a chapter.
- `next / prev`: `() => void`. Neighbouring chapter.
Example: https://stepwise.thesphynx.net/scroll
### useLessonStream / useFadeIn
```ts
useLessonStream(count: number, opts?: LessonStreamOptions): LessonStream
```
Headless notebook reader: mounts a window of lessons around the seed, loads neighbours on approach, compensates the scroll for content prepended above, and tracks the active lesson by viewport center. Composes with ChapterNav and LessonPager. useFadeIn(initiallyVisible) fades streamed blocks in.
LessonStreamOptions:
- `seed`: `number` (default 0). Lesson to open on (deep link). Clamped.
- `radius`: `number` (default 1). Lessons mounted on each side of the seed initially.
- `approachBelow / approachAbove`: `number` (default 600 / 300). Pixel thresholds for loading the next / previous lesson.
- `scrollToSeed`: `boolean` (default true). Scroll the seed into view before first paint.
- `onActiveChange`: `(index: number) => void`. Scroll-tracked active lesson changed - sync the URL or title here.
LessonStream (returned):
- `active / seed`: `number`. Lesson at the viewport center; the seed lesson.
- `mounted`: `number[]`. Indices to render, in order.
- `complete`: `boolean`. The window reaches the last lesson.
- `refFor`: `(i: number) => RefCallback`. Stable ref for lesson i's block.
- `goTo / next / prev`: `...`. Mount through a lesson and scroll to it.
- `reducedMotion`: `boolean`. The user prefers reduced motion.
### useDiscoveries
```ts
useDiscoveries(opts?: DiscoveriesOptions): Discoveries
```
A persisted set of discovered ids, hydrated after mount. Pair with useGuidedTour for show-once feature tutorials.
DiscoveriesOptions:
- `store`: `DiscoveryStore` (default localStorageStore()). Storage seam: localStorageStore, cookieStore, memoryStore, or your own { read, write }.
- `key`: `string` (default "stepwise.discoveries"). Storage key used by the built-in stores.
Discoveries (returned):
- `discovered`: `Set`. Discovered ids. Empty until hydration.
- `ready`: `boolean`. False during SSR and before the first client pass - gate auto-starts on it.
- `isDiscovered`: `(id: string) => boolean`. Ref-backed check for event handlers.
- `markDiscovered`: `(id: string | string[]) => void`. Record ids and persist the merged set.
- `reset`: `() => void`. Forget everything.
Example: https://stepwise.thesphynx.net/discovery
### useGlobalKeys
```ts
useGlobalKeys(map: KeyMap, opts?: GlobalKeysOptions): void
```
Global keyboard bindings matched on event.key (exact, then lowercase). Handlers fire with preventDefault; Escape always fires.
GlobalKeysOptions:
- `ignoreTags`: `string[]` (default INPUT, TEXTAREA, SELECT, BUTTON). Tags that swallow keys (except Escape).
- `enabled`: `boolean | (() => boolean)` (default true). Gate, checked per event.
Example: https://stepwise.thesphynx.net/player
### useFocusTrap
```ts
useFocusTrap(active: boolean): RefObject
```
Traps Tab inside the ref'd container while active, focuses it on activation and restores focus on release. Attach to a tabIndex={-1} element.
### usePrefersReducedMotion
```ts
usePrefersReducedMotion(): boolean
```
SSR-safe prefers-reduced-motion query (false on the server, corrected after mount).
## Evaluators
One verdict shape for every "did the reader get it?" question: tour gates, player checkpoints, exercises. Booleans are accepted everywhere.
### useEvaluator
```ts
useEvaluator(evaluator: Evaluator, ctx: Ctx, opts?: { mode?: "auto" | "manual" }): EvaluatorState
```
Keeps an evaluator's verdict as state. Auto re-judges on every context change (gates). Manual judges only on check() (quizzes); when ctx changes identity afterwards the verdict goes back to idle, but attempts are kept (same question), so call reset() when the question itself changes, e.g. in an effect on player.held. Async checks report pending, abort superseded runs and drop late results.
EvaluatorState (returned):
- `status`: `"idle" | "pass" | "fail" | "pending"`. idle until the first verdict.
- `feedback`: `ReactNode`. The verdict's feedback.
- `passed`: `boolean`. status === "pass".
- `attempts`: `number`. check() calls so far - for hints after a few tries.
- `error`: `unknown`. What the evaluator threw (status is then fail).
- `check`: `() => Promise`. Judge now; resolves null if superseded. Stable.
- `reset`: `() => void`. Back to idle, attempts to 0. Stable.
Example: https://stepwise.thesphynx.net/simulate
### Evaluator / Verdict / pass / fail / pending
```ts
type Evaluator = (ctx: Ctx, signal: AbortSignal) => boolean | Verdict | Promise
```
A Verdict is { status: "pass" | "fail" | "pending", feedback? }. pass(), fail() and pending() build one; toVerdict() normalizes a boolean. All are exported from the package root. The signal aborts when the result is no longer wanted. Grading a checkpoint: the truth is usually the next frame (sim.steps[player.held + 1]), so phrase the question so every possible next frame is an answer option.
## Components
Optional styled presentation. Every component takes a theme; the hooks work without them.
### PlayerBar
```ts
```
Transport controls plus a tone-colored scrubber for a StepPlayer.
Props:
- `player`: `StepPlayer (subset)`. total, index, playing, atEnd, next, prev, toggle, seek (+ live, goLive for the live badge).
- `toneAt / titleAt`: `(i) => StepTone / string`. Segment color and tooltip per frame.
- `leading`: `ReactNode`. Content before the controls.
- `onEmpty`: `() => void`. Fired when the user plays/steps with no timeline.
- `emptyHint`: `string`. Scrubber text when there are no frames.
- `groupOf`: `(i: number) => number`. Group consecutive segments (see StepScrubber).
- `live`: `boolean`. Show the live-edge badge for a following player: Live at the edge, Go live +N when the reader is behind.
- `labels`: `{ previous, play, pause, replay, next, live, goLive }`. Button labels / aria.
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/player
### StepScrubber
```ts
```
Keyboard-accessible slider of tone-colored segments, usable with any cursor.
Props:
- `count / index`: `number`. Segment count and the active segment.
- `onSeek`: `(i: number) => void`. Called on click or arrow keys. Required.
- `toneAt / titleAt`: `(i) => StepTone / string`. Per-segment color and title.
- `groupOf`: `(i: number) => number`. Group contiguous segments into one proportional span - a chapter fills as its children pass.
- `emptyHint`: `string`. Shown when count is 0.
- `labels`: `{ scrubber?, frame?(i) }`. aria-label and aria-valuetext.
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/scroll
### ChapterNav
```ts
```
Recursive chapter navigation; sticky by default. Sub-chapters render inline after their parent.
Props:
- `chapters`: `Chapter[]`. The same tree passed to useChapters.
- `active`: `number`. Flattened active index.
- `onNavigate`: `(i: number) => void`. Called with the flattened index.
- `hint`: `string`. Right-aligned hint line (md and up).
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/scroll
### LessonPager
```ts
...} />
```
Previous/next buttons for cross-page lesson navigation.
Props:
- `prev / next`: `{ label: string; hint?: string }`. Omit either to disable that side.
- `onNavigate`: `(dir: "prev" | "next") => void`. Called on click.
- `labels`: `{ previous?, next? }`. Eyebrow labels.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
### TourSpotlight
```ts
```
Dims the page around the target, scrolls it into view once and tracks it every frame. Waits for targets that render late.
Props:
- `target`: `string | null`. DOM id, then [data-tour] value. null hides the spotlight.
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
Example: https://stepwise.thesphynx.net/tour
### TourCard
```ts
```
Focus-trapped dialog narrating the active tour step, fixed to the bottom of the viewport.
Props:
- `step`: `TourStepInput`. The active step.
- `index / total`: `number`. Position, shown in the header.
- `onNext / onBack / onEnd`: `() => void`. Wire to tour.next, tour.back, tour.end.
- `nextDisabled`: `boolean` (default false). Disable Next/Finish - pair with tour.blocked.
- `feedback`: `ReactNode`. Gate feedback, announced politely - pair with tour.feedback.
- `labels`: `{ title, back, next, finish, end }`. Text overrides.
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/tour
### ShortcutsDialog
```ts
```
Modal list of keyboard shortcuts with kbd chips; closes on Escape or backdrop click.
Props:
- `open`: `boolean`. Visibility.
- `onClose`: `() => void`. Called on Escape, backdrop or close button.
- `items`: `ShortcutItem[]`. { keys, action } pairs.
- `title`: `string`. Dialog heading.
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
### CodeBlock
```ts
```
Monospace lines with // comments dimmed. This site's View source panels use it.
Props:
- `lines`: `readonly string[]` (default []). One entry per line. Optional, so a step's optional code passes as is: .
- `theme`: `StepwiseTheme` (default defaultTheme). Styling tokens. Pass your own object to restyle without forking.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/player
## Glossary
Inline term tooltips that can nest, plus an appendix that collects itself.
### GlossaryProvider
```ts
...
```
Declares the entries once. Definitions are ReactNode and may contain further Terms. Every Term and GlossaryAppendix must be inside it (in MDX: wrap the rendered article, or put it in the layout). The entry type is type-only: import type { GlossaryEntry } from "@sphynxoid/stepwise".
Props:
- `entries`: `GlossaryEntry[]`. { id, term, definition } - definition may embed .
- `theme`: `StepwiseTheme` (default defaultTheme). Styling for tooltip cards.
- `children`: `ReactNode`. The page.
Example: https://stepwise.thesphynx.net/glossary
### Term
```ts
TLS
```
A focusable inline reference. Hover or Enter opens a tooltip; nested terms stack outward, depth-capped at 4. Inside the appendix it becomes an anchor link.
Props:
- `id`: `string`. Entry id.
- `children`: `ReactNode`. Link text; defaults to the entry's term.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/glossary
### GlossaryAppendix
```ts
```
Renders the provider's entries in declaration order. Every part is overridable.
Props:
- `entries`: `GlossaryEntry[]`. Override the row list; defaults to the provider's entries.
- `title`: `string` (default "Glossary"). Section heading.
- `intro / footer`: `ReactNode`. Rendered before / after the grid.
- `columns`: `1 | 2` (default 2). Grid columns.
- `bare`: `boolean` (default false). Omit the section chrome.
- `entryClassName`: `string`. Classes for each row.
- `renderEntry`: `(entry) => ReactNode`. Replace the row renderer entirely.
- `className`: `string`. Extra classes, merged last (tailwind-merge).
Example: https://stepwise.thesphynx.net/scroll
## Utilities, theme and ports
Helpers, the theme contract, and the inversion points adapters plug into.
### flattenChapters
```ts
flattenChapters(chapters: Chapter[]): FlatChapter[]
```
Depth-first flattening - reading order for prev/next and scroll tracking.
FlatChapter:
- `chapter`: `Chapter`. The original node.
- `depth`: `number`. 0 for top level.
- `rootIndex`: `number`. Flat index of the top-level ancestor - use as StepScrubber groupOf.
- `parentIndex`: `number`. Flat index of the parent; -1 at depth 0.
### readHashParam / writeHashParam
```ts
readHashParam(hash: string, key: string): string | null
writeHashParam(hash: string, key: string, value: string): string
```
The fragment format syncHash and syncInput use: &-separated parts, key=value parts are params, anything else (a bare #anchor) is kept verbatim. writeHashParam replaces the key in place or appends it, and returns the new hash; apply it with history.replaceState. For a simulation's input, prefer useSimulation's syncInput.
Example: https://stepwise.thesphynx.net/simulate
### TONE_CHIP_CLASS / TONE_SEGMENT_CLASS
```ts
TONE_CHIP_CLASS[step.tone] // "text-accent border-accent/40 ..."
```
The default theme's per-tone classes as plain records, for your own chips and badges that should match the scrubber. Styled by styles.css or your Tailwind build like the components.
Example: https://stepwise.thesphynx.net/simulate
### localStorageStore / cookieStore / memoryStore
```ts
localStorageStore(key?) | cookieStore(key?) | memoryStore(seed?): DiscoveryStore
```
Built-in DiscoveryStore implementations: { read(): string[]; write(ids: string[]): void }.
### StepwiseTheme / defaultTheme
```ts
const theme: StepwiseTheme = { ...defaultTheme, surface: "..." }
```
Every non-CSS-variable class the components use. TONE_SEGMENT_CLASS and TONE_CHIP_CLASS re-export the defaults. Without Tailwind, import "@sphynxoid/stepwise/styles.css" (precompiled, scoped to [data-stepwise], --stepwise-* tokens); or copy the components in via the shadcn registry at /r/*.json.
Tokens:
- `toneSegment / toneChip`: `Record`. Scrubber segment and chip colors per tone.
- `buttonBase / buttonVariants`: `string / Record`. Internal buttons.
- `surface`: `string`. Panel chrome for nav bars, cards, dialogs and tooltips.
- `overlay`: `string`. Dimmed backdrop behind modals.
- `codeBlock / codeMuted`: `string`. Code panel frame and comment lines.
- `kbd`: `string`. Keyboard chip.
- `spotlight`: `string`. Ring around the tour target.
- `muted`: `string`. Counters, hints, empty states.
Example: https://stepwise.thesphynx.net/minimal
### StepwiseStep / StepTone
```ts
type StepwiseStep = { tone: StepTone; label: string; code?: string[]; note?: string; key?: string } & Data
```
One frame of a trace, carrying your domain payload. StepTone is exactly "neutral" | "accent" | "good" | "bad" | "write" | "idle". key names the moment ("compare at i=3, j=2") so the playhead keeps its place when the trace is recomputed; give every step of a simulation one.
### PlayerEngine / TourDriver
```ts
PlayerEngine.connect(api) => handle | TourDriver.show(step, ctx) / hide()
```
Ports for alternate frame sources and tour presenters. Adapters ship on subpaths: @sphynxoid/stepwise/adapters/gsap, /driver-js and /scroll.
PlayerEngine:
- `connect(api)`: `(api: PlayerEngineApi) => PlayerEngineHandle`. api: total, onFrame(frame), onEnd(). handle: play, pause, seek, setRate, disconnect.
TourDriver:
- `show(step, ctx)`: `(TourStepInput, TourContext) => void`. ctx: index, total, next(), back(), end().
- `hide()`: `() => void`. Called on end and unmount.
## Experimental: transitions
From @sphynxoid/stepwise/experimental: may change in a minor release. A trace becomes a graph: choice points branch on the reader's answers, and the path taken is still a plain array, so the player, links and re-anchoring work unchanged.
### useWalk
```ts
useWalk(simulation: WalkSimulation, input: Input, opts?: WalkOptions): WalkPlayer
```
useSimulation plus choices. A walk is replayed from (input, seed, choices) on every change, so it stays pure. The unanswered choice point at the end of the path holds playback until choose().
WalkOptions (plus every SimulationOptions field):
- `choices`: `readonly Choice[]`. Choices to start with (read once, on mount).
- `anchor`: `"key" | "index"` (default "key"). Where the playhead stays when the input changes: on the step with the same key, or at the same index (for walks where step i means "after i inputs", like fromGraph).
- `syncChoices`: `{ key, serialize, parse }`. Keep the choices in the URL (#p=a.b.a). Restored together with syncInput, before the linked frame. A choice parse rejects drops the whole linked path.
WalkPlayer (returned: every SimulationPlayer field, plus):
- `choose`: `(choice: Choice) => void`. Answer the choice point the playhead is on. At the end of the path the walk continues; on an earlier choice point a different answer cuts off the old future (the same answer keeps it). The playhead moves past the choice point; held playback resumes. Off a choice point: no-op (dev warning).
- `choices`: `readonly Choice[]`. The choices made so far. With the input, they are the walk.
- `choicePoints`: `readonly number[]`. Indices of the choice points on the path.
- `awaiting`: `boolean`. The path ends on a choice point waiting for an answer.
- `choiceAt`: `(frame: number) => Choice | undefined`. The answer given at frame, if it's an answered choice point.
- `lastChange`: `TraceDiff | null`. What the last re-run changed (see diffTraces), paired the way the playhead anchors.
Example: https://stepwise.thesphynx.net/automaton
### runWalk / WalkSimulation
```ts
runWalk(simulation, input, opts?: { seed?, maxSteps?, choices? }): WalkResult
```
A walk simulation is a generator; a step with await: true is a choice point, and that yield returns the reader's choice: const pick = yield { key: "fork", await: true, … }. runWalk replays it purely, stopping at the first unanswered choice point. Linear simulations are walks without choice points.
WalkResult:
- `steps`: `S[]`. The path, up to the end or the first unanswered choice point.
- `choicePoints`: `number[]`. Indices of the choice points on the path.
- `awaiting`: `boolean`. The path ends on an unanswered choice point.
- `used`: `number`. How many choices were consumed; extra ones are ignored.
- `truncated / error / seed`: `boolean / unknown / number`. As in runSimulation.
### fromGraph
```ts
fromGraph(spec: { start, nodes, edges: { from, on, to }[], accept?, otherwise? }): StepGraph
```
A declared automaton: nodes, labeled edges, accept states. Throws on an inconsistent graph. A missing edge goes to otherwise (a dead state) or ends the run with an error.
StepGraph (returned):
- `simulation`: `GraphSimulation`. Runs the graph over a word (a string or any iterable): one step per symbol read, keyed by position. Use with useSimulation or useWalk (anchor: "index").
- `graph`: `{ start, nodes, edges, accept: Set, otherwise }`. The declared graph, to draw it.
- `delta`: `(from: string, on: Sym) => string | null`. The transition function, otherwise included.
- `stepAt`: `(read, node, from?, on?) => GraphStep`. The step for a position, for custom walks over the same graph.
GraphStep (plus StepwiseStep):
- `node / from / on`: `string / string | null / Sym | null`. Where the walk is, where it came from, and the symbol read (null on the start step).
- `read`: `number`. Symbols read so far.
- `accepted`: `boolean`. The node is an accepting one (tone good).
Example: https://stepwise.thesphynx.net/automaton
### diffTraces
```ts
diffTraces(oldSteps, newSteps, opts?: { keyOf?, equals? }): TraceDiff
```
What an edit changed between two runs, to mark the changed steps instead of only saying that something did. useWalk exposes it as lastChange.
TraceDiff:
- `divergedAt`: `number`. First index where the runs differ position by position; -1 when equal. Everything before it is the same.
- `changed`: `ReadonlySet`. Indices in the new run that are new or differ from their counterpart (by key with keyOf, else by index).
Options:
- `keyOf`: `(step, index) => string | undefined`. Pair steps by key instead of index.
- `equals`: `(a, b) => boolean` (default structuralEqual). Step equality. The default compares plain data structurally; anything else by identity.