stepwise

API reference

@sphynxoid/stepwise

Everything exported from the package root, grouped by layer. Hooks are headless and SSR-safe; the callbacks they return are referentially stable. Adapters live on subpaths so their optional peers never become required imports.

npm i @sphynxoid/stepwise

Core

The one primitive. Everything else is a policy layered over this cursor.

useSteps

useSteps<T>(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

NameTypeDefaultDescription
initialIndexnumber0Initial focus. Clamped.
onChange(index: number) => void-Fired whenever the focus index changes.

StepsCore (returned)

NameTypeDefaultDescription
countnumber-Number of steps in the sequence.
indexnumber-Clamped focus: 0..count-1, or -1 when the sequence is empty.
atStart / atEndboolean-Position flags.
canNext / canPrevboolean-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(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

NameTypeDefaultDescription
totalnumber-Frames in the current timeline (0 = no timeline loaded). Required.
baseDelayMsnumber900Delay per frame at 1x speed (built-in timer engine).
autoPlaybooleanfalseStart playing immediately when a timeline exists on first render.
enginePlayerEngine-Alternate frame source replacing the timer. Memoize per timeline - identity changes reconnect it.
syncHashstring-Deep-link the frame into the URL hash as #<key>=<index>. 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.
respectReducedMotionbooleantrueWith 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.
followbooleanfalseFollow 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)

NameTypeDefaultDescription
totalnumber-Total frames in the current timeline.
framenumber-Raw frame counter; -1 means no timeline / before the first frame.
indexnumber-Frame clamped into [0, total-1]; -1 when there is nothing to show.
playing / speedboolean / number-Playback state.
atEndboolean-Parked on the final frame (a seek counts too).
endedboolean-Playback ran to the end on its own and nothing has moved since. toggle() at ended replays.
heldnumber | 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.
liveboolean-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.
useTrace<S extends StepwiseStep>(steps: readonly S[], opts?: TraceOptions<S>): TracePlayer<S>

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<S> (plus every StepPlayerOptions field except total)

NameTypeDefaultDescription
keyOf(step: S, index: number) => string | undefinedstep.keyStep 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<S> (returned)

NameTypeDefaultDescription
playerStepPlayer-The underlying player - pass it to PlayerBar.
stepS | undefined-The frame to render. Frame 0 before the player starts; undefined only for an empty trace.
framenumber-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.

useSyncedTracks / alignTracks

see it live: /compare
useSyncedTracks<T extends Tracks>(tracks: T, opts?: SyncedTracksOptions<T>): SyncedTracks<T>

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)

NameTypeDefaultDescription
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<T> (returned)

NameTypeDefaultDescription
playerStepPlayer-One player over the aligned timeline - pass it to PlayerBar.
frame / totalnumber-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.
rowsreadonly AlignedRow[]-The full alignment table.
toneAt / titleAt(i: number) => ...-Stable scrubber accessors: most severe tone, and track: label for every track that moved.
createTrace<Data>(): TraceBuilder<Data>

Typed trace builder: run your algorithm at module scope and record frames as you go. Pure data - no React, safe on the server.

TraceBuilder<Data>

NameTypeDefaultDescription
step(step: StepwiseStep<Data>) => TraceBuilder<Data>-Append a frame; chains.
lengthnumber-Frames appended so far.
last() => StepwiseStep<Data> | undefined-Most recent frame.
build() => StepwiseStep<Data>[]-Snapshot - a fresh array each call.
useSimulation<Input, S extends StepwiseStep>(simulation: Simulation<Input, S>, input: Input, opts?: SimulationOptions<S>): SimulationPlayer<S>

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<S> (plus every TraceOptions field)

NameTypeDefaultDescription
seednumber | string0Seed for ctx.rng. Same simulation + input + seed = same trace.
maxStepsnumber10000Stop collecting after this many steps and report truncated.
syncInputSyncInputOptions<Input>-Keep the input in the URL next to the frame, so a link replays the exact run (see below). Since 0.4.

SyncInputOptions<Input>

NameTypeDefaultDescription
keystring-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<S> (returned: every TracePlayer field, plus)

NameTypeDefaultDescription
stepsreadonly S[]-The computed trace.
truncatedboolean-maxSteps cut the run short.
errorunknown-What the simulation threw; steps holds what it yielded before.
seednumber-Numeric seed used (strings are hashed).

runSimulation / createRng

see it live: /simulate
runSimulation(simulation, input, opts?: { seed?, maxSteps? }): SimulationResult<S> · 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, S>

NameTypeDefaultDescription
(input, ctx) => Iterable<S>generator or array-Yield one step per moment worth showing. Yield snapshots (copies), and give steps a key.
ctx.rngRng-Seeded randomness: next(), int(min, max), pick(items), shuffle(items), seed.

reanchor

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

see it live: /tour
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

NameTypeDefaultDescription
idstring-Stable id for gotoId and next edges.
title / bodystring / ReactNode-Card content.
targetstring-Element to spotlight: DOM id first, then [data-tour="..."].
onEnter() => void-Effect run when the step becomes active - drive your app state here.
waitHintstring-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

NameTypeDefaultDescription
driverTourDriver-Imperative presenter (driver.js, Shepherd...). When set, don't render TourSpotlight/TourCard.

GuidedTour (returned)

NameTypeDefaultDescription
indexnumber | null-Active step index, or null when the tour is not running.
currentTourStepInput | null-The active step.
activeboolean-Whether the tour is running.
blockedboolean-The active step's gate is closed; pair with TourCard nextDisabled.
feedbackReactNode-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.
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

NameTypeDefaultDescription
idstring-DOM id of the chapter's section.
label / hintstring-Nav label and tooltip.
childrenChapter[]-Nested sub-chapters, any depth.
numberedbooleantrueShow the ordinal chip in ChapterNav.

ChaptersApi (returned)

NameTypeDefaultDescription
activenumber-Index of the chapter in view (flattened order).
goTo(index: number) => void-Smooth-scroll to a chapter.
next / prev() => void-Neighbouring chapter.

useLessonStream / useFadeIn

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

NameTypeDefaultDescription
seednumber0Lesson to open on (deep link). Clamped.
radiusnumber1Lessons mounted on each side of the seed initially.
approachBelow / approachAbovenumber600 / 300Pixel thresholds for loading the next / previous lesson.
scrollToSeedbooleantrueScroll the seed into view before first paint.
onActiveChange(index: number) => void-Scroll-tracked active lesson changed - sync the URL or title here.

LessonStream (returned)

NameTypeDefaultDescription
active / seednumber-Lesson at the viewport center; the seed lesson.
mountednumber[]-Indices to render, in order.
completeboolean-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.
reducedMotionboolean-The user prefers reduced motion.
useDiscoveries(opts?: DiscoveriesOptions): Discoveries

A persisted set of discovered ids, hydrated after mount. Pair with useGuidedTour for show-once feature tutorials.

DiscoveriesOptions

NameTypeDefaultDescription
storeDiscoveryStorelocalStorageStore()Storage seam: localStorageStore, cookieStore, memoryStore, or your own { read, write }.
keystring"stepwise.discoveries"Storage key used by the built-in stores.

Discoveries (returned)

NameTypeDefaultDescription
discoveredSet<string>-Discovered ids. Empty until hydration.
readyboolean-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.
useGlobalKeys(map: KeyMap, opts?: GlobalKeysOptions): void

Global keyboard bindings matched on event.key (exact, then lowercase). Handlers fire with preventDefault; Escape always fires.

GlobalKeysOptions

NameTypeDefaultDescription
ignoreTagsstring[]INPUT, TEXTAREA, SELECT, BUTTONTags that swallow keys (except Escape).
enabledboolean | (() => boolean)trueGate, checked per event.

useFocusTrap

useFocusTrap(active: boolean): RefObject<HTMLDivElement | null>

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

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<Ctx>(evaluator: Evaluator<Ctx>, 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)

NameTypeDefaultDescription
status"idle" | "pass" | "fail" | "pending"-idle until the first verdict.
feedbackReactNode-The verdict's feedback.
passedboolean-status === "pass".
attemptsnumber-check() calls so far - for hints after a few tries.
errorunknown-What the evaluator threw (status is then fail).
check() => Promise<Verdict | null>-Judge now; resolves null if superseded. Stable.
reset() => void-Back to idle, attempts to 0. Stable.

Evaluator / Verdict / pass / fail / pending

type Evaluator<Ctx> = (ctx: Ctx, signal: AbortSignal) => boolean | Verdict | Promise<boolean | Verdict>

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 player={...} toneAt={...} titleAt={...} />

Transport controls plus a tone-colored scrubber for a StepPlayer.

Props

NameTypeDefaultDescription
playerStepPlayer (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.
leadingReactNode-Content before the controls.
onEmpty() => void-Fired when the user plays/steps with no timeline.
emptyHintstring-Scrubber text when there are no frames.
groupOf(i: number) => number-Group consecutive segments (see StepScrubber).
liveboolean-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.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).
<StepScrubber count={n} index={i} onSeek={go} />

Keyboard-accessible slider of tone-colored segments, usable with any cursor.

Props

NameTypeDefaultDescription
count / indexnumber-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.
emptyHintstring-Shown when count is 0.
labels{ scrubber?, frame?(i) }-aria-label and aria-valuetext.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).
<ChapterNav chapters={CHAPTERS} active={c.active} onNavigate={c.goTo} />

Recursive chapter navigation; sticky by default. Sub-chapters render inline after their parent.

Props

NameTypeDefaultDescription
chaptersChapter[]-The same tree passed to useChapters.
activenumber-Flattened active index.
onNavigate(i: number) => void-Called with the flattened index.
hintstring-Right-aligned hint line (md and up).
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).

LessonPager

<LessonPager prev={{ label }} next={{ label }} onNavigate={dir => ...} />

Previous/next buttons for cross-page lesson navigation.

Props

NameTypeDefaultDescription
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.
classNamestring-Extra classes, merged last (tailwind-merge).

TourSpotlight

see it live: /tour
<TourSpotlight target={tour.current?.target ?? null} />

Dims the page around the target, scrolls it into view once and tracks it every frame. Waits for targets that render late.

Props

NameTypeDefaultDescription
targetstring | null-DOM id, then [data-tour] value. null hides the spotlight.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
<TourCard step={tour.current} index={tour.index} total={n} onNext onBack onEnd />

Focus-trapped dialog narrating the active tour step, fixed to the bottom of the viewport.

Props

NameTypeDefaultDescription
stepTourStepInput-The active step.
index / totalnumber-Position, shown in the header.
onNext / onBack / onEnd() => void-Wire to tour.next, tour.back, tour.end.
nextDisabledbooleanfalseDisable Next/Finish - pair with tour.blocked.
feedbackReactNode-Gate feedback, announced politely - pair with tour.feedback.
labels{ title, back, next, finish, end }-Text overrides.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).

ShortcutsDialog

<ShortcutsDialog open={open} onClose={close} items={SHORTCUTS} />

Modal list of keyboard shortcuts with kbd chips; closes on Escape or backdrop click.

Props

NameTypeDefaultDescription
openboolean-Visibility.
onClose() => void-Called on Escape, backdrop or close button.
itemsShortcutItem[]-{ keys, action } pairs.
titlestring-Dialog heading.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).
<CodeBlock lines={["const x = 1", "// comment"]} />

Monospace lines with // comments dimmed. This site's View source panels use it.

Props

NameTypeDefaultDescription
linesreadonly string[][]One entry per line. Optional, so a step's optional code passes as is: <CodeBlock lines={step.code} />.
themeStepwiseThemedefaultThemeStyling tokens. Pass your own object to restyle without forking.
classNamestring-Extra classes, merged last (tailwind-merge).

Glossary

Inline term tooltips that can nest, plus an appendix that collects itself.

GlossaryProvider

see it live: /glossary
<GlossaryProvider entries={GLOSSARY}>...</GlossaryProvider>

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

NameTypeDefaultDescription
entriesGlossaryEntry[]-{ id, term, definition } - definition may embed <Term>.
themeStepwiseThemedefaultThemeStyling for tooltip cards.
childrenReactNode-The page.
<Term id="tls">TLS</Term>

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

NameTypeDefaultDescription
idstring-Entry id.
childrenReactNode-Link text; defaults to the entry's term.
classNamestring-Extra classes, merged last (tailwind-merge).

GlossaryAppendix

see it live: /scroll
<GlossaryAppendix intro={...} footer={...} />

Renders the provider's entries in declaration order. Every part is overridable.

Props

NameTypeDefaultDescription
entriesGlossaryEntry[]-Override the row list; defaults to the provider's entries.
titlestring"Glossary"Section heading.
intro / footerReactNode-Rendered before / after the grid.
columns1 | 22Grid columns.
barebooleanfalseOmit the section chrome.
entryClassNamestring-Classes for each row.
renderEntry(entry) => ReactNode-Replace the row renderer entirely.
classNamestring-Extra classes, merged last (tailwind-merge).

Utilities, theme and ports

Helpers, the theme contract, and the inversion points adapters plug into.

flattenChapters

flattenChapters(chapters: Chapter[]): FlatChapter[]

Depth-first flattening - reading order for prev/next and scroll tracking.

FlatChapter

NameTypeDefaultDescription
chapterChapter-The original node.
depthnumber-0 for top level.
rootIndexnumber-Flat index of the top-level ancestor - use as StepScrubber groupOf.
parentIndexnumber-Flat index of the parent; -1 at depth 0.

readHashParam / writeHashParam

see it live: /simulate
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.

TONE_CHIP_CLASS / TONE_SEGMENT_CLASS

see it live: /simulate
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.

localStorageStore / cookieStore / memoryStore

localStorageStore(key?) | cookieStore(key?) | memoryStore(seed?): DiscoveryStore

Built-in DiscoveryStore implementations: { read(): string[]; write(ids: string[]): void }.

StepwiseTheme / defaultTheme

see it live: /minimal
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

NameTypeDefaultDescription
toneSegment / toneChipRecord<StepTone, string>-Scrubber segment and chip colors per tone.
buttonBase / buttonVariantsstring / Record<ghost|outline|primary, string>-Internal buttons.
surfacestring-Panel chrome for nav bars, cards, dialogs and tooltips.
overlaystring-Dimmed backdrop behind modals.
codeBlock / codeMutedstring-Code panel frame and comment lines.
kbdstring-Keyboard chip.
spotlightstring-Ring around the tour target.
mutedstring-Counters, hints, empty states.

StepwiseStep / StepTone

type StepwiseStep<Data> = { 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

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

NameTypeDefaultDescription
connect(api)(api: PlayerEngineApi) => PlayerEngineHandle-api: total, onFrame(frame), onEnd(). handle: play, pause, seek, setRate, disconnect.

TourDriver

NameTypeDefaultDescription
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<Input, S extends WalkStep, Choice>(simulation: WalkSimulation<Input, S, Choice>, input: Input, opts?: WalkOptions<S, Input, Choice>): WalkPlayer<S, Choice>

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)

NameTypeDefaultDescription
choicesreadonly Choice[]-Choices to start with (read once, on mount).
anchor"key" | "index""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<S, Choice> (returned: every SimulationPlayer field, plus)

NameTypeDefaultDescription
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).
choicesreadonly Choice[]-The choices made so far. With the input, they are the walk.
choicePointsreadonly number[]-Indices of the choice points on the path.
awaitingboolean-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.
lastChangeTraceDiff | null-What the last re-run changed (see diffTraces), paired the way the playhead anchors.

runWalk / WalkSimulation

runWalk(simulation, input, opts?: { seed?, maxSteps?, choices? }): WalkResult<S>

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<S>

NameTypeDefaultDescription
stepsS[]-The path, up to the end or the first unanswered choice point.
choicePointsnumber[]-Indices of the choice points on the path.
awaitingboolean-The path ends on an unanswered choice point.
usednumber-How many choices were consumed; extra ones are ignored.
truncated / error / seedboolean / unknown / number-As in runSimulation.
fromGraph<Sym extends string, N>(spec: { start, nodes, edges: { from, on, to }[], accept?, otherwise? }): StepGraph<Sym, N>

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)

NameTypeDefaultDescription
simulationGraphSimulation<Sym>-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)

NameTypeDefaultDescription
node / from / onstring / string | null / Sym | null-Where the walk is, where it came from, and the symbol read (null on the start step).
readnumber-Symbols read so far.
acceptedboolean-The node is an accepting one (tone good).

diffTraces

diffTraces<S>(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

NameTypeDefaultDescription
divergedAtnumber-First index where the runs differ position by position; -1 when equal. Everything before it is the same.
changedReadonlySet<number>-Indices in the new run that are new or differ from their counterpart (by key with keyOf, else by index).

Options

NameTypeDefaultDescription
keyOf(step, index) => string | undefined-Pair steps by key instead of index.
equals(a, b) => booleanstructuralEqualStep equality. The default compares plain data structurally; anything else by identity.