@turing-machine-js/visuals 7.0.0-alpha.6 → 7.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this package will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [7.0.0-alpha.7] - 2026-05-30
8
+
9
+ Lockstep re-alignment with the engine 7.0.0-alpha.7 bump (engine [#213](https://github.com/mellonis/turing-machine-js/issues/213) `CallFrame` extraction + [#223](https://github.com/mellonis/turing-machine-js/issues/223) `toMermaid` framed-wrapper emit fix). No source or behavior changes in this package since alpha.6.1. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.6` → `^7.0.0-alpha.7`.
10
+
11
+ ## [7.0.0-alpha.6.1] - 2026-05-30
12
+
13
+ ### Added
14
+
15
+ - `formatStepNotation(reads, commands, blanks, matchKinds?)` — engine edge-label format primitive, matches `toMermaid` emit byte-for-byte. Per-cell encoding: literal `'X'`, blank shortcut `B`, wildcard `*='X'` (shows what `ifOtherSymbol` caught), keep-with-concrete-symbol `K='X'` / `K=B`, erase `E`. Multi-tape comma-separated within one outer bracket per role. Pass `reads === null` for the manual-Apply path (no transition fired) — output collapses to `[writes]/[moves]`. Folds in the richness machines-demo's local `format.ts` had so demo can drop the local helper and call visuals's primitive directly.
16
+ - `tokenizeStep(reads, commands, blanks, matchKinds?)` + `ReadToken` / `WriteToken` / `StepTokens` types — renderer-agnostic structured form of one step. Same input contract as `formatStepNotation`; returns discriminated-union tokens per cell (`{ kind: 'literal' | 'blank' | 'wildcard', ... }` for reads, `{ kind: 'literal' | 'erase' | 'keep', ... }` for writes). Consumers wanting custom rendering — HTML spans with CSS classes for syntax highlighting, ANSI-colored terminal output, alternative move vocabulary, clickable cells — walk the tokens themselves. `formatStepNotation` is refactored to be a thin string renderer over `tokenizeStep` (output byte-identical).
17
+ - `formatTape(tape)` — inline tape rendering with the head bracketed in place (`a[b]c`).
18
+ - `StepCommand` — plain per-tape command shape (`{ movement: 'L' | 'R' | 'S'; symbol: string | null }`) consumed by `formatStepNotation` and `tokenizeStep`. Distinct from the engine's `TapeCommand` class; matches the shape machines-demo's worker boundary exposes.
19
+
20
+ ### Compatibility
21
+
22
+ - alpha.6's `formatCommand(tapeCommand)` and `formatStep(m)` unchanged. Additive release.
23
+ - Engine + builder + library-binary-numbers + library-binary-numbers-bare stay at `7.0.0-alpha.6` — no changes there. Visuals-only follow-up patch; the workspace's lockstep convention is for coordinated peer-dep widening when engine APIs break, not for additive consumer-package enhancements.
24
+ - Peer dep `@turing-machine-js/machine: ^7.0.0-alpha.6` unchanged (semver-prerelease caret already accepts `alpha.6.1`).
25
+
7
26
  ## [7.0.0-alpha.6] - 2026-05-30
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -1,21 +1,247 @@
1
1
  # @turing-machine-js/visuals
2
2
 
3
- Pure highlight + graph-indexing logic for [`@turing-machine-js/machine`](../machine). No DOM, no Svelte, no Mermaid — consumers bring their own renderer and DOM applier.
3
+ Pure highlight + graph-indexing logic for [`@turing-machine-js/machine`](../machine) — plus a renderer-agnostic edge-label formatter and a `recordSnippet` artifact recorder for prerecorded playback (article embeds, landing-page panels, terminal tools). No DOM, no Svelte, no Mermaid — consumers bring their own renderer.
4
4
 
5
- ## Scope
5
+ ## When to reach for this
6
6
 
7
- Types and pure functions for:
8
- - Indexing an engine `Graph` for wrapper/bare lookup (`indexGraph`, `bareIdOf`, `highlightExpand`).
9
- - Applying highlight + indicator operations against a renderer-agnostic `HighlightOps` interface (`applyHighlight`, `applyIndicator`).
10
-
11
- See [`docs/graph-highlight-and-breakpoints.md`](./docs/graph-highlight-and-breakpoints.md) for the full set of rules these functions satisfy.
12
-
13
- ## Versioning
14
-
15
- Lockstep with `@turing-machine-js/machine`.
7
+ - You have an engine `Graph` (from `State.toGraph(initialState, tapeBlock)`) and want to render it with **runtime highlight cues** — `from → edge → to` triples that animate as the machine steps, breakpoint dots, frame-active subgraph marks, pulse-on-revisit. Implement a small `HighlightOps` object that wraps your renderer's DOM/canvas/ANSI primitives; `applyHighlight` decides *what* to highlight, your `HighlightOps` decides *how*.
8
+ - You want a logged step's notation to **line up byte-for-byte** with the rendered state graph's edge labels. `formatStepNotation` mirrors the engine's `toMermaid` edge-label vocabulary; same `[reads] → [writes]/[moves]`, same `'X'` / `B` / `*='X'` / `K='X'` / `E` shortcuts.
9
+ - You want to **record a machine run** as a self-contained playback artifact — for an article embed, a landing-page panel, a snapshot test, anything that needs to replay a run without booting an engine. `recordSnippet` produces a `Snippet` with one `Frame` per iter (tape state + per-tape `{ movement, read, write }` command + highlight + optional log line); a player walks frames forward AND backward without recomputing deltas.
16
10
 
17
11
  ## Install
18
12
 
19
13
  ```sh
20
14
  npm install @turing-machine-js/visuals @turing-machine-js/machine
21
15
  ```
16
+
17
+ Both prereleases on npm `next`:
18
+
19
+ ```sh
20
+ npm install @turing-machine-js/visuals@next @turing-machine-js/machine@next
21
+ ```
22
+
23
+ Peer-deps on `@turing-machine-js/machine@^7.0.0-alpha.6` — visuals follows engine v7 alphas with occasional visuals-only patches (`7.0.0-alpha.6.1` added the formatter primitives + token surface on top of the lockstep alpha.6 engine release).
24
+
25
+ ## Public API
26
+
27
+ ### Types
28
+
29
+ ```ts
30
+ // Highlight contract
31
+ type NodeKey = number | 'idle';
32
+ type HighlightClass = 'mg-highlight-from' | 'mg-highlight-to' | 'mg-highlight-strong';
33
+ interface HighlightOps {
34
+ addNodeClass(id: NodeKey, cls: HighlightClass): void;
35
+ highlightEdge(fromKey: string, toKey: string): void;
36
+ markFrameActive(frameId: number): void;
37
+ pulse(id: NodeKey): void;
38
+ scrollIntoView(id: NodeKey): void;
39
+ }
40
+ interface IndicatorOps {
41
+ setBreakpoint(id: NodeKey, on: boolean): void;
42
+ }
43
+ type GraphHighlight = {
44
+ fromId: number | 'idle';
45
+ toId: number | null;
46
+ strong: 'from' | 'to';
47
+ paused: boolean;
48
+ };
49
+ type GraphIndexes = { /* node→frame, frame→wrappers, frame→label, etc. */ };
50
+
51
+ // Recording artifact
52
+ type TapeSnapshot = { symbols: string[]; position: number };
53
+ type StepCommand = { movement: 'L' | 'R' | 'S'; symbol: string | null };
54
+ type Frame = {
55
+ step: number;
56
+ tape: TapeSnapshot[];
57
+ commands?: { movement: 'L' | 'R' | 'S'; read: string; write: string }[];
58
+ highlight: GraphHighlight | null;
59
+ log?: string;
60
+ };
61
+ type Snippet = {
62
+ version: 1;
63
+ name?: string;
64
+ graph: Graph;
65
+ alphabets: string[][];
66
+ frames: Frame[];
67
+ };
68
+
69
+ // Token surface (renderer-agnostic alternative to formatStepNotation strings)
70
+ type ReadToken =
71
+ | { kind: 'literal'; symbol: string }
72
+ | { kind: 'blank' }
73
+ | { kind: 'wildcard'; symbol: string };
74
+ type WriteToken =
75
+ | { kind: 'literal'; symbol: string }
76
+ | { kind: 'erase' }
77
+ | { kind: 'keep'; readContext?: { symbol: string; isBlank: boolean } };
78
+ type StepTokens = {
79
+ reads: readonly ReadToken[] | null; // null = manual-Apply path (no transition fired)
80
+ writes: readonly WriteToken[];
81
+ moves: readonly ('L' | 'R' | 'S')[];
82
+ };
83
+ ```
84
+
85
+ ### Functions
86
+
87
+ ```ts
88
+ indexGraph(graph): GraphIndexes
89
+ applyHighlight(highlight, graph, indexes, prev, ops): { nextPrev }
90
+ applyIndicator(breakpoints, graph, ops): void
91
+ bareIdOf(id, graph): number
92
+ highlightExpand(id, graph): number[]
93
+ equivalentIds(id, graph): number[]
94
+ recordingOps(): { highlight: HighlightOps; indicator: IndicatorOps; record: RecordedOp[] }
95
+
96
+ // Formatters
97
+ formatStepNotation(reads, commands, blanks, matchKinds?): string
98
+ tokenizeStep(reads, commands, blanks, matchKinds?): StepTokens
99
+ formatTape(tape): string
100
+ formatCommand(tapeCommand): string // alpha.6 single-command formatter; kept for back-compat
101
+ formatStep(machineState): string // alpha.6 MachineState-based formatter; kept for back-compat
102
+
103
+ // Recording
104
+ recordSnippet({ machine, initialState, graph, alphabets, name?, maxSteps?, log? }): Snippet
105
+ ```
106
+
107
+ The 16-rule contract `applyHighlight` satisfies is documented at [`docs/graph-highlight-and-breakpoints.md`](./docs/graph-highlight-and-breakpoints.md).
108
+
109
+ ## Example: applying highlight in a DOM renderer
110
+
111
+ ```ts
112
+ import {
113
+ applyHighlight,
114
+ indexGraph,
115
+ type HighlightOps,
116
+ type GraphHighlight,
117
+ } from '@turing-machine-js/visuals';
118
+ import { State } from '@turing-machine-js/machine';
119
+
120
+ // 1. Build your machine + Graph elsewhere (typical Svelte / React / vanilla code).
121
+ const graph = State.toGraph(initialState, tapeBlock);
122
+ const indexes = indexGraph(graph);
123
+
124
+ // 2. Tiny DOM applier (illustrative — your renderer probably has a richer one).
125
+ function domOps(svgRoot: SVGSVGElement): HighlightOps {
126
+ const node = (id: number | 'idle') =>
127
+ svgRoot.querySelector(`g.node[data-id="${id === 'idle' ? 'idle' : `s${id}`}"]`);
128
+ return {
129
+ addNodeClass: (id, cls) => node(id)?.classList.add(cls),
130
+ highlightEdge: (from, to) =>
131
+ svgRoot
132
+ .querySelector(`path[data-id^="L_${from}_${to}_"]`)
133
+ ?.classList.add('mg-highlight-edge'),
134
+ markFrameActive: (frameId) =>
135
+ svgRoot.querySelector(`g.cluster[data-id="w_${frameId}"]`)?.classList.add('mg-frame-active'),
136
+ pulse: (id) => node(id)?.classList.add('mg-pulse'),
137
+ scrollIntoView: (id) => node(id)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }),
138
+ };
139
+ }
140
+
141
+ // 3. On each engine pause / step: wipe previous highlight from the DOM, then apply the new one.
142
+ // HighlightOps is purely additive — the consumer is responsible for clearing
143
+ // `mg-highlight-*` classes before calling applyHighlight.
144
+ let prev = null;
145
+ function onMachineStep(highlight: GraphHighlight | null, svgRoot: SVGSVGElement) {
146
+ // Wipe.
147
+ for (const el of svgRoot.querySelectorAll(
148
+ '.mg-highlight-from, .mg-highlight-to, .mg-highlight-strong, .mg-highlight-edge, .mg-frame-active',
149
+ )) {
150
+ el.classList.remove(
151
+ 'mg-highlight-from',
152
+ 'mg-highlight-to',
153
+ 'mg-highlight-strong',
154
+ 'mg-highlight-edge',
155
+ 'mg-frame-active',
156
+ );
157
+ }
158
+ if (!highlight) return;
159
+ const result = applyHighlight(highlight, graph, indexes, prev, domOps(svgRoot));
160
+ prev = result.nextPrev;
161
+ }
162
+ ```
163
+
164
+ ## Example: rendering a step's edge-label notation
165
+
166
+ ```ts
167
+ import { formatStepNotation, type StepCommand } from '@turing-machine-js/visuals';
168
+
169
+ const commands: StepCommand[] = [
170
+ { movement: 'R', symbol: 'b' }, // write 'b', move right
171
+ { movement: 'L', symbol: null }, // keep current symbol, move left
172
+ ];
173
+ const reads = ['a', 'x'];
174
+ const blanks = [' ', ' '];
175
+ const matchKinds = ['literal', 'wildcard'] as const;
176
+
177
+ formatStepNotation(reads, commands, blanks, matchKinds);
178
+ // => "['a',*='x'] → ['b',K='x']/[R,L]"
179
+ ```
180
+
181
+ For manual-Apply rendering (no transition fired), pass `reads: null`:
182
+
183
+ ```ts
184
+ formatStepNotation(null, [{ movement: 'R', symbol: 'b' }], [' '], null);
185
+ // => "['b']/[R]"
186
+ ```
187
+
188
+ ## Example: tokenize for custom (non-string) rendering
189
+
190
+ ```ts
191
+ import { tokenizeStep } from '@turing-machine-js/visuals';
192
+
193
+ const tokens = tokenizeStep(['a'], [{ movement: 'R', symbol: 'b' }], [' '], ['literal']);
194
+ // tokens.reads: [{ kind: 'literal', symbol: 'a' }]
195
+ // tokens.writes: [{ kind: 'literal', symbol: 'b' }]
196
+ // tokens.moves: ['R']
197
+
198
+ // Now render however you want — HTML spans with CSS classes, ANSI escapes, JSON, etc.
199
+ function readToHtml(t: typeof tokens.reads[0]) {
200
+ if (t.kind === 'wildcard') return `<span class="cell-wildcard">${t.symbol}</span>`;
201
+ if (t.kind === 'blank') return `<span class="cell-blank">␣</span>`;
202
+ return `<span class="cell-literal">${t.symbol}</span>`;
203
+ }
204
+ ```
205
+
206
+ ## Example: recording a snippet
207
+
208
+ ```ts
209
+ import { recordSnippet, formatStep } from '@turing-machine-js/visuals';
210
+ import {
211
+ Alphabet, Tape, TapeBlock, TuringMachine, State, haltState, movements,
212
+ } from '@turing-machine-js/machine';
213
+
214
+ const alphabet = new Alphabet([' ', 'a', 'b']);
215
+ const tape = new Tape({ alphabet, symbols: ['a', 'a', 'a'] });
216
+ const tapeBlock = TapeBlock.fromTapes([tape]);
217
+ const machine = new TuringMachine({ tapeBlock });
218
+ const initialState = new State({
219
+ [tapeBlock.symbol(['a'])]: { command: [{ symbol: 'b', movement: movements.right }] },
220
+ [tapeBlock.symbol([' '])]: { nextState: haltState },
221
+ });
222
+
223
+ const snippet = recordSnippet({
224
+ machine,
225
+ initialState,
226
+ graph: State.toGraph(initialState, tapeBlock),
227
+ alphabets: [[' ', 'a', 'b']],
228
+ name: 'replace a with b',
229
+ log: (m) => formatStep(m),
230
+ });
231
+
232
+ // snippet.frames[0] — initial state (no commands, highlight null)
233
+ // snippet.frames[N] — post-iter snapshot with per-tape commands { movement, read, write }
234
+ // and a graph highlight ready to feed applyHighlight()
235
+ // snippet.graph — the same Graph the player should render
236
+ // snippet.alphabets — per-tape alphabet symbols (single-tape: length 1)
237
+ ```
238
+
239
+ `Frame.commands` carries both `read` and `write` per tape so a player can step forward (write `write`, move per `movement`, flash if `write !== read`) AND backward (move opposite of `movement`, restore `read`) without diffing neighbouring frames.
240
+
241
+ ## Versioning
242
+
243
+ Engine + builder + libraries bump in **lockstep** via `lerna version`. **Visuals breaks lockstep for additive consumer-package patches** (e.g., `7.0.0-alpha.6.1` added formatter primitives + the token surface without bumping the engine — there were no engine changes to justify ghost releases). Semver-prerelease caret semantics make this safe: peer `^7.0.0-alpha.6` accepts `7.0.0-alpha.6.1+` without consumers having to widen anything. When the next engine alpha needs to ship, all 5 packages bump back to lockstep.
244
+
245
+ ## License
246
+
247
+ [GPL-3.0-or-later](../../LICENSE)
package/dist/format.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import { type MachineState, type TapeCommand } from '@turing-machine-js/machine';
2
+ import type { TapeSnapshot } from './types';
3
+ /**
4
+ * Plain per-tape command shape consumed by `formatStepNotation`. Distinct
5
+ * from the engine's `TapeCommand` class: `symbol === null` means "keep
6
+ * current" (the resolved symbol equals what was already under the head),
7
+ * `movement` is the role letter (not an engine symbol). Matches the shape
8
+ * machines-demo exposes from its worker boundary.
9
+ */
10
+ export type StepCommand = {
11
+ movement: 'L' | 'R' | 'S';
12
+ symbol: string | null;
13
+ };
2
14
  export declare const MOVEMENT_LETTER: Map<symbol, "L" | "R" | "S">;
3
15
  /**
4
16
  * Render a single tape command in `WRITE/MOVE` form.
@@ -20,3 +32,106 @@ export declare function formatCommand(tapeCommand: TapeCommand): string;
20
32
  * by comparing `nextSymbols[i] === currentSymbols[i]`.
21
33
  */
22
34
  export declare function formatStep(m: MachineState): string;
35
+ /**
36
+ * Per-tape read-cell token. Discriminated union for renderer-agnostic
37
+ * consumption — UIs map each variant to their own presentation (plain
38
+ * string via `formatStepNotation`, HTML span with CSS class, ANSI color,
39
+ * clickable token, etc.). `formatStepNotation` is the default string
40
+ * renderer over these tokens.
41
+ *
42
+ * - `literal` — the engine matched this exact symbol non-wildcard.
43
+ * - `blank` — the matched symbol is the tape's blank glyph; renderers
44
+ * commonly want a `B`-style shortcut instead of `' '`.
45
+ * - `wildcard` — the engine matched via `ifOtherSymbol`. The literal
46
+ * `symbol` is preserved so renderers can show what the catch-all
47
+ * actually caught (a blank-shortcut would obscure it).
48
+ */
49
+ export type ReadToken = {
50
+ kind: 'literal';
51
+ symbol: string;
52
+ } | {
53
+ kind: 'blank';
54
+ } | {
55
+ kind: 'wildcard';
56
+ symbol: string;
57
+ };
58
+ /**
59
+ * Per-tape write-cell token.
60
+ *
61
+ * - `literal` — engine wrote this exact symbol; not a blank, not a keep.
62
+ * - `erase` — engine wrote the tape's blank glyph; rendered as `E` by
63
+ * `formatStepNotation` but structurally distinct from a generic blank
64
+ * write so renderers can style "erase" differently from "write blank as
65
+ * the next interesting symbol."
66
+ * - `keep` — engine left the cell unchanged (`command.symbol === null`).
67
+ * `readContext` carries the kept symbol when caller supplied `reads`;
68
+ * `isBlank` flags whether the kept symbol equals the tape's blank glyph.
69
+ * No `readContext` means manual-Apply path (no transition fired, no
70
+ * per-tape read available).
71
+ */
72
+ export type WriteToken = {
73
+ kind: 'literal';
74
+ symbol: string;
75
+ } | {
76
+ kind: 'erase';
77
+ } | {
78
+ kind: 'keep';
79
+ readContext?: {
80
+ symbol: string;
81
+ isBlank: boolean;
82
+ };
83
+ };
84
+ /**
85
+ * Structured-token representation of one step. `formatStepNotation` is the
86
+ * default string renderer over this shape; consumers wanting custom
87
+ * rendering (HTML spans, alternative vocabulary, clickable cells, ANSI
88
+ * colors) call `tokenizeStep` and walk the tokens themselves.
89
+ *
90
+ * `reads === null` denotes the manual-Apply path (no transition fired);
91
+ * all read-side encoding is suppressed and `keep` writes carry no
92
+ * `readContext`.
93
+ */
94
+ export type StepTokens = {
95
+ reads: readonly ReadToken[] | null;
96
+ writes: readonly WriteToken[];
97
+ moves: readonly ('L' | 'R' | 'S')[];
98
+ };
99
+ /**
100
+ * Tokenize one step's per-tape data into renderer-agnostic structured
101
+ * form. Same input contract as `formatStepNotation` — same engine
102
+ * vocabulary, same null-`reads` manual-Apply handling, same wildcard
103
+ * suppression of the blank shortcut. Use this when you need to render the
104
+ * step in a non-string medium (HTML, terminal escape codes, JSON for
105
+ * embeds) or just want different syntax than the default string output.
106
+ */
107
+ export declare function tokenizeStep(reads: readonly string[] | null, commands: readonly StepCommand[], blanks: readonly string[], matchKinds?: readonly ('wildcard' | 'literal')[] | null): StepTokens;
108
+ /**
109
+ * Engine edge-label format — `[reads] → [writes]/[moves]`. Matches
110
+ * `toMermaid` emit byte-for-byte so a logged step's notation lines up with
111
+ * the same transition's edge label in the rendered state graph. Thin
112
+ * string renderer over `tokenizeStep`; see that function's docstring +
113
+ * `StepTokens` for the structured form most UIs should prefer.
114
+ *
115
+ * Per-cell rendering:
116
+ * - Read cell: `'X'` (literal) | `B` (blank, NON-wildcard only) | `*='X'`
117
+ * (wildcard — shows what `ifOtherSymbol` caught; the `B` shortcut is
118
+ * suppressed for wildcards so the matched literal is always visible).
119
+ * - Write cell: `'X'` (literal) | `K='X'` (keep, with concrete read
120
+ * appended) | `K=B` (keep, read was blank) | `K` (keep, no read context
121
+ * — only when `reads === null`) | `E` (erase, write equals blank).
122
+ * - Move cell: `L` | `R` | `S`.
123
+ *
124
+ * Multi-tape: per-tape entries comma-separated inside one outer bracket
125
+ * per role — `['1','a'] → ['0','b']/[R,L]`.
126
+ *
127
+ * Pass `reads === null` for the manual-Apply path: output collapses to
128
+ * `[writes]/[moves]` and `K` renders without read context. Pass
129
+ * `matchKinds === null`/omit when no transition fired: every position
130
+ * renders as a literal (no wildcard markers).
131
+ */
132
+ export declare function formatStepNotation(reads: readonly string[] | null, commands: readonly StepCommand[], blanks: readonly string[], matchKinds?: readonly ('wildcard' | 'literal')[] | null): string;
133
+ /** Inline tape rendering with the head bracketed in place (`a[b]c`).
134
+ * No UI substitution — the user controls the blank glyph. `[<blank>]`
135
+ * may render an invisible space if blank is `' '`; that's the chosen
136
+ * symbol, not a bug. */
137
+ export declare function formatTape(tape: TapeSnapshot): string;
package/dist/index.cjs CHANGED
@@ -411,6 +411,103 @@ function formatStep(m) {
411
411
  const moves = m.movements.map((mv) => MOVEMENT_LETTER.get(mv) ?? '?').join(',');
412
412
  return `[${reads}] → [${writes}]/[${moves}]`;
413
413
  }
414
+ /**
415
+ * Tokenize one step's per-tape data into renderer-agnostic structured
416
+ * form. Same input contract as `formatStepNotation` — same engine
417
+ * vocabulary, same null-`reads` manual-Apply handling, same wildcard
418
+ * suppression of the blank shortcut. Use this when you need to render the
419
+ * step in a non-string medium (HTML, terminal escape codes, JSON for
420
+ * embeds) or just want different syntax than the default string output.
421
+ */
422
+ function tokenizeStep(reads, commands, blanks, matchKinds) {
423
+ const writes = commands.map((c, i) => {
424
+ if (c.symbol === null) {
425
+ if (reads !== null) {
426
+ const r = reads[i];
427
+ if (r !== undefined) {
428
+ return { kind: 'keep', readContext: { symbol: r, isBlank: r === blanks[i] } };
429
+ }
430
+ }
431
+ return { kind: 'keep' };
432
+ }
433
+ if (c.symbol === blanks[i])
434
+ return { kind: 'erase' };
435
+ return { kind: 'literal', symbol: c.symbol };
436
+ });
437
+ const moves = commands.map((c) => c.movement);
438
+ if (reads === null) {
439
+ return { reads: null, writes, moves };
440
+ }
441
+ const readTokens = reads.map((r, i) => {
442
+ if (matchKinds?.[i] === 'wildcard')
443
+ return { kind: 'wildcard', symbol: r };
444
+ if (r === blanks[i])
445
+ return { kind: 'blank' };
446
+ return { kind: 'literal', symbol: r };
447
+ });
448
+ return { reads: readTokens, writes, moves };
449
+ }
450
+ function renderReadToken(t) {
451
+ if (t.kind === 'wildcard')
452
+ return `*='${t.symbol}'`;
453
+ if (t.kind === 'blank')
454
+ return 'B';
455
+ return `'${t.symbol}'`;
456
+ }
457
+ function renderWriteToken(t) {
458
+ if (t.kind === 'erase')
459
+ return 'E';
460
+ if (t.kind === 'literal')
461
+ return `'${t.symbol}'`;
462
+ if (!t.readContext)
463
+ return 'K';
464
+ if (t.readContext.isBlank)
465
+ return 'K=B';
466
+ return `K='${t.readContext.symbol}'`;
467
+ }
468
+ /**
469
+ * Engine edge-label format — `[reads] → [writes]/[moves]`. Matches
470
+ * `toMermaid` emit byte-for-byte so a logged step's notation lines up with
471
+ * the same transition's edge label in the rendered state graph. Thin
472
+ * string renderer over `tokenizeStep`; see that function's docstring +
473
+ * `StepTokens` for the structured form most UIs should prefer.
474
+ *
475
+ * Per-cell rendering:
476
+ * - Read cell: `'X'` (literal) | `B` (blank, NON-wildcard only) | `*='X'`
477
+ * (wildcard — shows what `ifOtherSymbol` caught; the `B` shortcut is
478
+ * suppressed for wildcards so the matched literal is always visible).
479
+ * - Write cell: `'X'` (literal) | `K='X'` (keep, with concrete read
480
+ * appended) | `K=B` (keep, read was blank) | `K` (keep, no read context
481
+ * — only when `reads === null`) | `E` (erase, write equals blank).
482
+ * - Move cell: `L` | `R` | `S`.
483
+ *
484
+ * Multi-tape: per-tape entries comma-separated inside one outer bracket
485
+ * per role — `['1','a'] → ['0','b']/[R,L]`.
486
+ *
487
+ * Pass `reads === null` for the manual-Apply path: output collapses to
488
+ * `[writes]/[moves]` and `K` renders without read context. Pass
489
+ * `matchKinds === null`/omit when no transition fired: every position
490
+ * renders as a literal (no wildcard markers).
491
+ */
492
+ function formatStepNotation(reads, commands, blanks, matchKinds) {
493
+ const tokens = tokenizeStep(reads, commands, blanks, matchKinds);
494
+ const writesStr = tokens.writes.map(renderWriteToken).join(',');
495
+ const movesStr = tokens.moves.join(',');
496
+ const writesPart = `[${writesStr}]/[${movesStr}]`;
497
+ if (tokens.reads === null)
498
+ return writesPart;
499
+ const readsStr = tokens.reads.map(renderReadToken).join(',');
500
+ return `[${readsStr}] → ${writesPart}`;
501
+ }
502
+ /** Inline tape rendering with the head bracketed in place (`a[b]c`).
503
+ * No UI substitution — the user controls the blank glyph. `[<blank>]`
504
+ * may render an invisible space if blank is `' '`; that's the chosen
505
+ * symbol, not a bug. */
506
+ function formatTape(tape) {
507
+ return tape.symbols
508
+ .map((sym, i) => (i === tape.position ? `[${sym}]` : sym))
509
+ .join('');
510
+ }
414
511
 
415
512
  const DEFAULT_MAX_STEPS = 1000;
416
513
  function snapshotTapes(machine) {
@@ -508,7 +605,10 @@ exports.bareIdOf = bareIdOf;
508
605
  exports.equivalentIds = equivalentIds;
509
606
  exports.formatCommand = formatCommand;
510
607
  exports.formatStep = formatStep;
608
+ exports.formatStepNotation = formatStepNotation;
609
+ exports.formatTape = formatTape;
511
610
  exports.highlightExpand = highlightExpand;
512
611
  exports.indexGraph = indexGraph;
513
612
  exports.recordSnippet = recordSnippet;
514
613
  exports.recordingOps = recordingOps;
614
+ exports.tokenizeStep = tokenizeStep;
package/dist/index.d.ts CHANGED
@@ -5,5 +5,5 @@ export type { GraphIndexes } from './graphIndexes';
5
5
  export { indexGraph } from './graphIndexes';
6
6
  export type { GraphHighlight, TapeSnapshot, Frame, Snippet } from './types';
7
7
  export { applyHighlight, applyIndicator } from './applyHighlight';
8
- export { formatCommand, formatStep } from './format';
8
+ export { formatCommand, formatStep, formatStepNotation, formatTape, tokenizeStep, type StepCommand, type ReadToken, type WriteToken, type StepTokens, } from './format';
9
9
  export { recordSnippet, type RecordSnippetOptions } from './recordSnippet';
package/dist/index.mjs CHANGED
@@ -409,6 +409,103 @@ function formatStep(m) {
409
409
  const moves = m.movements.map((mv) => MOVEMENT_LETTER.get(mv) ?? '?').join(',');
410
410
  return `[${reads}] → [${writes}]/[${moves}]`;
411
411
  }
412
+ /**
413
+ * Tokenize one step's per-tape data into renderer-agnostic structured
414
+ * form. Same input contract as `formatStepNotation` — same engine
415
+ * vocabulary, same null-`reads` manual-Apply handling, same wildcard
416
+ * suppression of the blank shortcut. Use this when you need to render the
417
+ * step in a non-string medium (HTML, terminal escape codes, JSON for
418
+ * embeds) or just want different syntax than the default string output.
419
+ */
420
+ function tokenizeStep(reads, commands, blanks, matchKinds) {
421
+ const writes = commands.map((c, i) => {
422
+ if (c.symbol === null) {
423
+ if (reads !== null) {
424
+ const r = reads[i];
425
+ if (r !== undefined) {
426
+ return { kind: 'keep', readContext: { symbol: r, isBlank: r === blanks[i] } };
427
+ }
428
+ }
429
+ return { kind: 'keep' };
430
+ }
431
+ if (c.symbol === blanks[i])
432
+ return { kind: 'erase' };
433
+ return { kind: 'literal', symbol: c.symbol };
434
+ });
435
+ const moves = commands.map((c) => c.movement);
436
+ if (reads === null) {
437
+ return { reads: null, writes, moves };
438
+ }
439
+ const readTokens = reads.map((r, i) => {
440
+ if (matchKinds?.[i] === 'wildcard')
441
+ return { kind: 'wildcard', symbol: r };
442
+ if (r === blanks[i])
443
+ return { kind: 'blank' };
444
+ return { kind: 'literal', symbol: r };
445
+ });
446
+ return { reads: readTokens, writes, moves };
447
+ }
448
+ function renderReadToken(t) {
449
+ if (t.kind === 'wildcard')
450
+ return `*='${t.symbol}'`;
451
+ if (t.kind === 'blank')
452
+ return 'B';
453
+ return `'${t.symbol}'`;
454
+ }
455
+ function renderWriteToken(t) {
456
+ if (t.kind === 'erase')
457
+ return 'E';
458
+ if (t.kind === 'literal')
459
+ return `'${t.symbol}'`;
460
+ if (!t.readContext)
461
+ return 'K';
462
+ if (t.readContext.isBlank)
463
+ return 'K=B';
464
+ return `K='${t.readContext.symbol}'`;
465
+ }
466
+ /**
467
+ * Engine edge-label format — `[reads] → [writes]/[moves]`. Matches
468
+ * `toMermaid` emit byte-for-byte so a logged step's notation lines up with
469
+ * the same transition's edge label in the rendered state graph. Thin
470
+ * string renderer over `tokenizeStep`; see that function's docstring +
471
+ * `StepTokens` for the structured form most UIs should prefer.
472
+ *
473
+ * Per-cell rendering:
474
+ * - Read cell: `'X'` (literal) | `B` (blank, NON-wildcard only) | `*='X'`
475
+ * (wildcard — shows what `ifOtherSymbol` caught; the `B` shortcut is
476
+ * suppressed for wildcards so the matched literal is always visible).
477
+ * - Write cell: `'X'` (literal) | `K='X'` (keep, with concrete read
478
+ * appended) | `K=B` (keep, read was blank) | `K` (keep, no read context
479
+ * — only when `reads === null`) | `E` (erase, write equals blank).
480
+ * - Move cell: `L` | `R` | `S`.
481
+ *
482
+ * Multi-tape: per-tape entries comma-separated inside one outer bracket
483
+ * per role — `['1','a'] → ['0','b']/[R,L]`.
484
+ *
485
+ * Pass `reads === null` for the manual-Apply path: output collapses to
486
+ * `[writes]/[moves]` and `K` renders without read context. Pass
487
+ * `matchKinds === null`/omit when no transition fired: every position
488
+ * renders as a literal (no wildcard markers).
489
+ */
490
+ function formatStepNotation(reads, commands, blanks, matchKinds) {
491
+ const tokens = tokenizeStep(reads, commands, blanks, matchKinds);
492
+ const writesStr = tokens.writes.map(renderWriteToken).join(',');
493
+ const movesStr = tokens.moves.join(',');
494
+ const writesPart = `[${writesStr}]/[${movesStr}]`;
495
+ if (tokens.reads === null)
496
+ return writesPart;
497
+ const readsStr = tokens.reads.map(renderReadToken).join(',');
498
+ return `[${readsStr}] → ${writesPart}`;
499
+ }
500
+ /** Inline tape rendering with the head bracketed in place (`a[b]c`).
501
+ * No UI substitution — the user controls the blank glyph. `[<blank>]`
502
+ * may render an invisible space if blank is `' '`; that's the chosen
503
+ * symbol, not a bug. */
504
+ function formatTape(tape) {
505
+ return tape.symbols
506
+ .map((sym, i) => (i === tape.position ? `[${sym}]` : sym))
507
+ .join('');
508
+ }
412
509
 
413
510
  const DEFAULT_MAX_STEPS = 1000;
414
511
  function snapshotTapes(machine) {
@@ -500,4 +597,4 @@ function recordSnippet(opts) {
500
597
  };
501
598
  }
502
599
 
503
- export { applyHighlight, applyIndicator, bareIdOf, equivalentIds, formatCommand, formatStep, highlightExpand, indexGraph, recordSnippet, recordingOps };
600
+ export { applyHighlight, applyIndicator, bareIdOf, equivalentIds, formatCommand, formatStep, formatStepNotation, formatTape, highlightExpand, indexGraph, recordSnippet, recordingOps, tokenizeStep };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turing-machine-js/visuals",
3
- "version": "7.0.0-alpha.6",
3
+ "version": "7.0.0-alpha.7",
4
4
  "description": "Pure highlight + graph-indexing logic for @turing-machine-js/machine — no DOM, no renderer.",
5
5
  "engines": {
6
6
  "npm": ">=7.0.0"
@@ -25,7 +25,7 @@
25
25
  "visualization"
26
26
  ],
27
27
  "peerDependencies": {
28
- "@turing-machine-js/machine": "^7.0.0-alpha.6"
28
+ "@turing-machine-js/machine": "^7.0.0-alpha.7"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/visuals",
@@ -42,5 +42,5 @@
42
42
  "default": "./dist/index.mjs"
43
43
  }
44
44
  },
45
- "gitHead": "7801a33fef2f6c30cf6e1c2780b1fbf58685f064"
45
+ "gitHead": "130d4fd8b964da21a408af2986295e8000828f78"
46
46
  }