diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b08f2b3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,57 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Repository layout
+
+Three independent surfaces of the same Turing machine concept:
+
+- `TurMac_1.1.py` — original single-file Python reference implementation by David Hartkop. Runs from a CLI: `python3 TurMac_1.1.py`. The control-memory tuple format and 4-element rule semantics `(read, write, dir, next_state)` defined here are the canonical model that the other surfaces mirror.
+- `web-app/` — browser-based simulator with two views (`public/machine.html`, `public/cards.html`) sharing engine code under `src/core/`.
+- `card-game/` — a 0-indexed variant of the cards view; its `index.html` imports modules directly from `../web-app/src/` and only adds the markup/style differences listed in `card-game/README.md`.
+
+## Running the web app
+
+ES modules are used everywhere, so `file://` URLs do not work. Serve the repo root over HTTP and open the pages from there:
+
+```
+python3 -m http.server 8000
+# then open:
+# http://localhost:8000/web-app/public/index.html (landing)
+# http://localhost:8000/web-app/public/machine.html (engineer view)
+# http://localhost:8000/web-app/public/cards.html (card-game view)
+# http://localhost:8000/card-game/index.html (0-indexed variant)
+```
+
+There is no build step, no package.json, no test runner. Edits to `.js`/`.html`/`.css` are picked up on browser reload.
+
+## Engine architecture (`web-app/src/core/turing.js`)
+
+`TuringMachine` is the single source of execution truth shared by both views. Important details that are not obvious from the file structure:
+
+- **Rule shape**: `[read, write, dir, nextState]` where `dir` is `0` (left) or `1` (right). A state whose rule list is empty is a halt state. These match the Python tuple format exactly.
+- **State indices**: the engine itself is 0-indexed. The Python file and the machine view also surface 0-indexed states; the cards view (`src/cards/main.js`) displays 1-indexed states to match the physical card game, and the card-program format (`src/cards/compiler.js`) uses 1-indexed next-state references that get translated to 0-indexed when compiled.
+- **`boundsCheck` modes**: the cards view passes `'before'` (check head bounds at the top of `step()`, so a rule that moves the head off-tape into a halt state halts cleanly). The machine view passes `'after'` (apply the rule first, then report the post-step head). Both behaviors are intentional — preserve them when modifying `step()`.
+- **`step()` return discriminator**: returns `{ kind: ... }` with one of `'already-halted' | 'cycle-limit' | 'halt' | 'no-rule' | 'oob' | 'oob-after' | 'step'`. View code switches on `kind` to log; add new cases here, do not throw.
+- **`reset()` is destructive**: it re-clones `initialTape`/`initialControl`. Anything constructed against the live `engine.tape` / `engine.control` will become stale after a reset — re-read from the engine.
+
+## Shared core utilities
+
+- `core/runner.js` — `setInterval`-driven autoplay loop. The interval is read fresh each tick via `getInterval()`, but changing speed mid-run still requires calling `runner.restart()` to apply (both views wire this up on the speed slider's `input` event).
+- `core/logger.js` — appends `
` rows to a container; optional badge element counts lines.
+- `core/dom.js` — `$(id)` shortcut plus a small `el(tag, opts)` builder.
+
+## Cards view specifics (`web-app/src/cards/`)
+
+- **Card format**: integers 0–7 only. Front face shows 0/1/2/3 (T/R/B/L); back face shows 5/6/7/4. `valueToFaceAndRotation` in `svg.js` is the source of truth. Boundary values that were `8` in the Python (`TurMac_1.1.py`) are substituted with `7` in presets because cards top out at 7.
+- **Compiled program format** (`compiler.js`): a flat comma/whitespace-separated integer sequence: `0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ...`. Use `compileProgramLenient` for user input — it auto-fixes truncation, out-of-range values, and missing markers, and reports each fix back through the logger. `compileProgram` (strict) is kept as a reference but is not wired into the UI.
+- **Click-to-edit**: every editable card has `data-role` plus `data-*` indices. `ROLE_HANDLERS` in `cards/main.js` is the dispatch table — add new editable cards by adding a role handler and emitting `makeCard(..., { role, data })`. Clicks rotate forward; Shift+click rotates backward.
+- **Edits rebuild the engine**: any role-handler edit calls `buildEngine()` and `syncEditorFromState()`. The textarea is the serialized form, not the source of truth — the in-memory `view.states` is.
+
+## Card-game variant (`card-game/`)
+
+`card-game/index.html` is a single file that imports the cards modules directly from `../web-app/src/cards/` and re-implements just the rendering pieces that need 0-indexed semantics (state rows, head position, per-tape-cell index labels) and a single combined "#states" card. When changing shared cards code, verify this page still works because it depends on the same exports (`makeCard`, `compileProgramLenient`, `serializeStates`, `PRESETS`). The differences from `cards.html` are spelled out in `card-game/README.md`.
+
+## When changing rule/program semantics
+
+If you touch the rule tuple shape, the halt convention, or the bounds-check behavior, update **all four** places that encode it: `TurMac_1.1.py`, `web-app/src/core/turing.js`, the cards compiler (`web-app/src/cards/compiler.js`), and the card-game variant in `card-game/index.html`. The README.md at the repo root documents the original semantics and is worth updating too if behavior changes.
diff --git a/card-game/README.md b/card-game/README.md
new file mode 100644
index 0000000..58a69ca
--- /dev/null
+++ b/card-game/README.md
@@ -0,0 +1,29 @@
+# Turing Machine Card Game (./index.html)
+
+A variant of `../web-app/public/cards.html` with a simplified, fully 0-indexed model where the tape value at the playhead directly selects the sub-state to run.
+
+## Differences from `cards.html`
+
+1. **States and sub-states are 0-indexed by position.** A state is identified by its row index; a sub-state is identified by its column index within that row.
+2. **Tape value == sub-state index.** When the playhead reads value `k` from the tape, sub-state `k` of the current state runs. If the current state has fewer than `k+1` sub-states, the machine halts.
+3. **Halt by out-of-range next-state.** There is no dedicated "last state is halt" convention. A `next` reference to any state index that does not exist halts the machine. A state with zero sub-states also halts when entered.
+4. **No `#states` card.** Replaced with a `+ Add state` button beneath the state rows.
+5. **No leading rule-count card on each state row.** Each row ends with a `+ sub-state` button to append a new sub-state.
+6. **No leading `read` card on each sub-state.** The read value is implied by the sub-state's column position. Each sub-state ends with a `×` button to delete it.
+7. **Direction is an arrow card.** A custom card showing a left or right arrow, sharing the same `.slot` frame as the colored-dot cards. Clicking toggles direction.
+8. **No playhead-position card.** Each tape cell has a `set start` / `★ start` button below it that picks that cell as the playhead's starting position.
+9. **Program text format is new.** A flat integer sequence, with no alignment card and no state-marker numbers:
+ ```
+ ruleCount, [write, dir, next] × ruleCount (state 0)
+ ruleCount, [write, dir, next] × ruleCount (state 1)
+ ...
+ ```
+ `dir`: 0 = L, 1 = R. State and `next` references are 0-indexed.
+
+## Implementation notes
+
+The page imports the shared engine and card renderer from `../web-app/src/` (`core/turing.js`, `core/runner.js`, `core/logger.js`, `core/dom.js`, `cards/svg.js`) but defines its own compiler, serializer, and preset programs inline — nothing in `web-app/` is modified.
+
+When building the engine, each view rule `[write, dir, next]` at column `r` is expanded to the engine's `[read, write, dir, next]` shape with `read = r`, so the shared `TuringMachine` keeps its rule-by-read lookup unchanged. The engine's existing behavior of returning `{ kind: 'halt' }` when `control[currentState]` is undefined gives "missing state halts" for free.
+
+`boundsCheck: 'before'` is used so a sub-state that moves the head off the tape into a missing state still halts cleanly on the next step.
diff --git a/card-game/index.html b/card-game/index.html
new file mode 100644
index 0000000..ead8b11
--- /dev/null
+++ b/card-game/index.html
@@ -0,0 +1,728 @@
+
+
+
+
+
+
Turing Machine Card Game
+
+
+
+
+
+
+
+ Turing Machine Card Game
+ Card-game variant of the Turing Cards simulator. States and sub-states are indexed by their row and column position (0-based). The tape value at the playhead selects which sub-state of the current state runs. A next reference to a state index that does not exist halts the machine.
+ Click any card to rotate it forward; Shift+click to rotate backward.
+
+
+
+
+
State 0
+
Cycle 0
+
Playhead 0
+
Read —
+
Status Ready
+
+
+
+
+
Program
+
+
+ + Add state
+
+
+
+
Tape (the bumped card is the playhead; use the button under a cell to set the start position)
+
+
+
+
+
+
Step
+
Run
+
Pause
+
Reset
+
+ Bit Flipper
+ Binary Increment (LSB-first)
+ Round Trip
+ 4-bit Binary Adder (A + B)
+
+
+ Speed
+
+ 500 ms
+
+
+
+
+
Program Editor
+
Compiled program (flat sequence: ruleCount, [write,dir,next]×ruleCount per state)
+
+
+
States are concatenated and indexed 0-based by row. The sub-state at index k within a state runs when the playhead reads value k. dir: 0=L, 1=R. A next reference to a state that does not exist halts the machine. The playhead's starting position is set via the button under each tape cell.
+
+ Apply Program
+ Reset to Preset
+
+
+
+
+
+
+
+
diff --git a/web-app/.DS_Store b/web-app/.DS_Store
new file mode 100644
index 0000000..175ed52
Binary files /dev/null and b/web-app/.DS_Store differ
diff --git a/web-app/public/cards.html b/web-app/public/cards.html
new file mode 100644
index 0000000..0665af1
--- /dev/null
+++ b/web-app/public/cards.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
Turing Cards Simulator
+
+
+
+
+
+
+ Turing Cards Simulator
+ Card-game-style Turing machine — based on David Hartkop's Turing-Complete-Cards.
+ Each card has 4 colored dots (0–3 on the front face; 4–7 on the back). Rotating the card brings a different number up. The compiled program reads top-to-bottom, with the playhead card bumped up over the tape.
+ Click any card to rotate it forward; Shift+click to rotate backward. Editing automatically re-validates the program and fixes stale references. ← Back to index
+
+
+
+
+
State 1
+
Cycle 0
+
Playhead 1
+
Read —
+
Status Ready
+
+
+
+
+
Compiled Program
+
+
+
+
+
+
Tape (the 5th-bumped card is the playhead)
+
+
+
+
+
+
Step
+
Run
+
Pause
+
Reset
+
+ Unary → Binary Converter
+ Number Counting Zig-Zag
+
+
+ Speed
+
+ 500 ms
+
+
+
+
+
Program Editor
+
Compiled program (flat card sequence — comma or whitespace separated)
+
+
+
Format: 0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ...
+ The halt state has rule count 0. dir: 0=L, 1=R. Next-state references are 1-indexed (state #4 = halt in a 4-state program).
+
+ Apply Program
+ Reset to Preset
+
+
+
+
+
+
+
+
diff --git a/web-app/public/css/cards.css b/web-app/public/css/cards.css
new file mode 100644
index 0000000..af5a217
--- /dev/null
+++ b/web-app/public/css/cards.css
@@ -0,0 +1,394 @@
+:root {
+ --table-light: #6b4220;
+ --table-mid: #4a2c10;
+ --table-dark: #2c1a08;
+ --card-bg: #fafaf5;
+ --card-stroke: #b0aaa0;
+ --c0: #1a1a1a; --c1: #d83a2c; --c2: #f3c422; --c3: #1f6cd6;
+ --c4: #6e3aa4; --c5: #e87a1c; --c6: #1e9a52; --c7: #ec6ea9;
+ --accent: #fbbf24;
+ --hot: #f97316;
+ --text: #f5f1ea;
+ --muted: #b4a896;
+ --panel: rgba(0, 0, 0, 0.42);
+ --border: rgba(255, 255, 255, 0.10);
+}
+
+html, body {
+ background:
+ radial-gradient(ellipse 1200px 600px at 50% 0%, rgba(255,200,140,0.10), transparent 70%),
+ linear-gradient(135deg, var(--table-light), var(--table-mid) 55%, var(--table-dark));
+ background-attachment: fixed;
+ color: var(--text);
+}
+
+header {
+ padding: 22px 28px 16px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(0,0,0,0.32);
+}
+h1 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+header p {
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 13px;
+ max-width: 720px;
+}
+header a {
+ color: var(--accent);
+ text-decoration: none;
+}
+header a:hover { text-decoration: underline; }
+
+main {
+ max-width: 1500px;
+ margin: 0 auto;
+ padding: 20px 24px 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+/* Status bar */
+.status-bar {
+ display: flex;
+ gap: 28px;
+ flex-wrap: wrap;
+ padding: 14px 18px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ align-items: center;
+}
+.status-item { display: flex; flex-direction: column; }
+.status-item .lbl {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ color: var(--muted);
+}
+.status-item .val {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 20px;
+ color: var(--accent);
+ margin-top: 2px;
+ min-width: 28px;
+}
+.status-item .val.halted { color: #86efac; }
+.status-item .val.running { color: #fde047; }
+.status-item .val.error { color: #fca5a5; }
+
+/* Board */
+.board {
+ background:
+ radial-gradient(ellipse 100% 80% at 50% 50%, rgba(255,255,255,0.04), transparent 70%),
+ repeating-linear-gradient(92deg,
+ rgba(255,220,180,0.04) 0 2px,
+ transparent 2px 6px,
+ rgba(255,220,180,0.06) 6px 8px,
+ transparent 8px 14px),
+ linear-gradient(135deg, #7a4a24, #4a2c10 50%, #2c1a08);
+ border: 2px solid rgba(0,0,0,0.4);
+ border-radius: 14px;
+ padding: 22px 24px 26px;
+ box-shadow:
+ inset 0 0 80px rgba(0,0,0,0.55),
+ inset 0 0 0 1px rgba(255,255,255,0.05),
+ 0 8px 28px rgba(0,0,0,0.4);
+ overflow-x: auto;
+}
+.section-label {
+ font-family: 'Georgia', 'Times New Roman', serif;
+ font-style: italic;
+ font-size: 13px;
+ color: rgba(255, 240, 220, 0.7);
+ margin-bottom: 8px;
+ padding-left: 4px;
+}
+.program-section { margin-bottom: 20px; }
+
+/* Rows */
+.prog-row {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ padding: 4px 8px;
+ border-radius: 8px;
+ margin-bottom: 4px;
+ transition: background 0.25s, box-shadow 0.25s;
+ min-width: max-content;
+}
+.prog-row.active {
+ background: rgba(251, 191, 36, 0.16);
+ box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.55);
+}
+.prog-row.halt-row { opacity: 0.55; }
+.prog-row.header-row { margin-bottom: 8px; }
+
+.row-label {
+ font-family: 'Georgia', serif;
+ font-style: italic;
+ font-size: 11px;
+ color: rgba(255, 240, 220, 0.55);
+ width: 96px;
+ flex: 0 0 96px;
+ padding-right: 6px;
+ text-align: right;
+}
+.prog-row.active .row-label { color: var(--accent); font-weight: 600; }
+
+.card-cluster {
+ display: flex;
+ gap: 3px;
+ padding: 4px;
+ border-radius: 6px;
+ transition: background 0.2s, box-shadow 0.2s;
+}
+.card-cluster.state-head { background: rgba(255,255,255,0.05); }
+.rule-cluster.matched {
+ background: rgba(251, 191, 36, 0.32);
+ box-shadow: 0 0 0 2px var(--accent), 0 0 12px rgba(251, 191, 36, 0.4);
+}
+
+/* Tape */
+.tape-section { margin-top: 14px; }
+.tape {
+ display: flex;
+ gap: 3px;
+ padding: 28px 10px 12px;
+ background:
+ linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.05)),
+ rgba(0,0,0,0.18);
+ border-radius: 8px;
+ position: relative;
+ align-items: flex-end;
+ min-width: max-content;
+}
+.tape::before {
+ content: 'TAPE';
+ position: absolute;
+ top: 8px; left: 14px;
+ font-family: 'Georgia', serif;
+ font-style: italic;
+ font-size: 11px;
+ color: rgba(255, 240, 220, 0.4);
+ letter-spacing: 0.1em;
+}
+
+/* Playhead card row */
+.head-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 8px 6px 102px;
+ margin-top: 8px;
+}
+.head-row .lbl {
+ font-family: 'Georgia', serif;
+ font-style: italic;
+ font-size: 11px;
+ color: rgba(255, 240, 220, 0.55);
+}
+
+/* Cards */
+.slot {
+ flex: 0 0 auto;
+ width: 56px;
+ height: 56px;
+ position: relative;
+ transition: transform 0.3s ease;
+ will-change: transform;
+}
+.slot.small { width: 44px; height: 44px; }
+.slot.bumped { transform: translateY(-14px); }
+.rotor {
+ width: 100%;
+ height: 100%;
+ transition: transform 0.35s cubic-bezier(0.5, 0.0, 0.3, 1.2);
+ will-change: transform;
+}
+.rotor svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
+}
+.slot.bumped .rotor svg {
+ filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55));
+}
+.slot.read-cell .rotor svg {
+ filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 6px rgba(251, 191, 36, 0.8));
+}
+.slot.clickable { cursor: pointer; }
+.slot.clickable:hover {
+ outline: 2px solid rgba(255, 230, 180, 0.7);
+ outline-offset: 3px;
+ border-radius: 11px;
+}
+.slot.clickable:hover .rotor svg {
+ filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)) brightness(1.05);
+}
+.slot.clickable.bumped:hover .rotor svg {
+ filter: drop-shadow(0 6px 9px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 6px rgba(251, 191, 36, 0.8)) brightness(1.05);
+}
+.tape-bump-indicator {
+ position: absolute;
+ bottom: -22px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: var(--accent);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ font-family: ui-monospace, monospace;
+ text-shadow: 0 1px 2px rgba(0,0,0,0.6);
+}
+
+/* Controls */
+.controls {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 14px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+button {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 18px;
+ font-size: 14px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: background 0.15s, border-color 0.15s, transform 0.05s;
+}
+button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+button:active:not(:disabled) { transform: translateY(1px); }
+button:disabled { opacity: 0.4; cursor: not-allowed; }
+button.primary {
+ background: var(--accent);
+ color: #1a1a1a;
+ border-color: var(--accent);
+}
+button.primary:hover:not(:disabled) { background: #f59e0b; }
+button.danger { color: #fca5a5; border-color: rgba(252, 165, 165, 0.4); }
+button.danger:hover:not(:disabled) { background: rgba(252, 165, 165, 0.1); }
+
+.speed-control {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-left: auto;
+}
+.speed-control label { font-size: 12px; color: var(--muted); }
+.speed-control input[type="range"] { width: 140px; accent-color: var(--accent); }
+.speed-control span {
+ font-family: ui-monospace, monospace;
+ font-size: 12px;
+ color: var(--accent);
+ min-width: 60px;
+ text-align: right;
+}
+
+/* Presets */
+select {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 9px 12px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+/* Editor */
+.editor {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 18px;
+}
+.editor h2 {
+ margin: 0 0 14px;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ color: var(--muted);
+ font-weight: 600;
+}
+.editor label {
+ display: block;
+ font-size: 12px;
+ color: var(--muted);
+ margin: 10px 0 4px;
+}
+.editor textarea,
+.editor input[type="text"],
+.editor input[type="number"] {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.32);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-family: ui-monospace, monospace;
+ font-size: 12px;
+}
+.editor textarea {
+ min-height: 110px;
+ resize: vertical;
+ line-height: 1.5;
+}
+.editor-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ align-items: end;
+}
+.editor small {
+ display: block;
+ margin-top: 10px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.6;
+}
+.editor-buttons {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+}
+.editor-buttons button { flex: 1; }
+
+.log {
+ margin-top: 14px;
+ font-family: ui-monospace, monospace;
+ font-size: 11px;
+ background: rgba(0, 0, 0, 0.4);
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ padding: 10px 12px;
+ max-height: 180px;
+ overflow-y: auto;
+ line-height: 1.5;
+ white-space: pre;
+}
+.log-line { color: var(--muted); }
+.log-line.halt { color: var(--accent); font-weight: 700; }
+.log-line.err { color: #fca5a5; }
+
+@media (max-width: 720px) {
+ .editor-grid { grid-template-columns: 1fr; }
+ .row-label { width: 64px; flex: 0 0 64px; font-size: 10px; }
+ .head-row { padding-left: 70px; }
+}
diff --git a/web-app/public/css/common.css b/web-app/public/css/common.css
new file mode 100644
index 0000000..071b423
--- /dev/null
+++ b/web-app/public/css/common.css
@@ -0,0 +1,8 @@
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+}
diff --git a/web-app/public/css/index.css b/web-app/public/css/index.css
new file mode 100644
index 0000000..539753b
--- /dev/null
+++ b/web-app/public/css/index.css
@@ -0,0 +1,202 @@
+:root {
+ --bg-1: #0f172a;
+ --bg-2: #1e293b;
+ --panel: rgba(30, 41, 59, 0.7);
+ --panel-hover: rgba(51, 65, 85, 0.85);
+ --border: rgba(148, 163, 184, 0.18);
+ --border-hover: rgba(56, 189, 248, 0.6);
+ --text: #e2e8f0;
+ --muted: #94a3b8;
+ --accent: #38bdf8;
+ --accent-2: #fbbf24;
+ --link: #7dd3fc;
+}
+
+html, body {
+ background:
+ radial-gradient(ellipse 1100px 600px at 50% -10%, rgba(56, 189, 248, 0.12), transparent 70%),
+ radial-gradient(ellipse 900px 500px at 90% 100%, rgba(251, 191, 36, 0.07), transparent 70%),
+ linear-gradient(135deg, var(--bg-1), var(--bg-2));
+ background-attachment: fixed;
+ color: var(--text);
+}
+
+.wrap {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 80px 28px 64px;
+}
+
+.eyebrow {
+ display: inline-block;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--accent);
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ padding: 5px 10px;
+ border-radius: 999px;
+ margin-bottom: 18px;
+}
+
+h1 {
+ font-size: clamp(32px, 5vw, 48px);
+ margin: 0 0 12px;
+ letter-spacing: -0.02em;
+ line-height: 1.05;
+ font-weight: 700;
+}
+
+.lede {
+ font-size: 17px;
+ line-height: 1.65;
+ color: var(--muted);
+ max-width: 720px;
+ margin: 0 0 48px;
+}
+.lede a { color: var(--link); text-decoration: none; }
+.lede a:hover { text-decoration: underline; }
+
+.sims {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 18px;
+ margin-bottom: 56px;
+}
+
+.sim {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 26px 24px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.18s, border-color 0.18s, transform 0.18s;
+ position: relative;
+ overflow: hidden;
+}
+.sim:hover {
+ background: var(--panel-hover);
+ border-color: var(--border-hover);
+ transform: translateY(-2px);
+}
+
+.sim-thumb {
+ height: 140px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ position: relative;
+}
+.sim-thumb.machine {
+ background:
+ radial-gradient(ellipse at 30% 30%, rgba(244, 114, 182, 0.18), transparent 60%),
+ linear-gradient(135deg, #0f172a, #273449);
+ border: 1px solid #334155;
+}
+.sim-thumb.cards {
+ background:
+ radial-gradient(ellipse 100% 80% at 50% 50%, rgba(255,255,255,0.05), transparent 70%),
+ linear-gradient(135deg, #7a4a24, #4a2c10 60%, #2c1a08);
+ border: 1px solid rgba(0,0,0,0.4);
+ box-shadow: inset 0 0 40px rgba(0,0,0,0.45);
+}
+
+.thumb-tape {
+ display: flex;
+ gap: 5px;
+}
+.thumb-cell {
+ width: 22px; height: 22px;
+ border-radius: 4px;
+ border: 1px solid #334155;
+ background: #273449;
+ display: flex; align-items: center; justify-content: center;
+ font-family: ui-monospace, monospace;
+ font-size: 12px;
+ color: #94a3b8;
+}
+.thumb-cell.b { color: #38bdf8; border-color: #38bdf8; background: rgba(56,189,248,0.08); }
+.thumb-cell.h {
+ color: #f472b6; border-color: #f472b6;
+ background: rgba(244,114,182,0.1);
+ box-shadow: 0 0 0 2px rgba(244,114,182,0.2);
+}
+
+.thumb-cards {
+ display: flex; gap: 6px;
+}
+.thumb-card {
+ width: 30px; height: 30px;
+ border-radius: 5px;
+ background: #fafaf5;
+ border: 1px solid #b0aaa0;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ padding: 3px;
+ gap: 2px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.45);
+}
+.thumb-card span {
+ border-radius: 50%;
+ align-self: center;
+ justify-self: center;
+ width: 8px; height: 8px;
+}
+
+.sim-name {
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.sim-name .arrow {
+ color: var(--accent);
+ font-size: 14px;
+ transition: transform 0.18s;
+}
+.sim:hover .sim-name .arrow { transform: translateX(3px); }
+
+.sim-desc {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+ line-height: 1.55;
+}
+
+.tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: auto;
+}
+.tag {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.12);
+ color: var(--muted);
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ font-family: ui-monospace, monospace;
+}
+
+footer {
+ border-top: 1px solid var(--border);
+ padding-top: 28px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.6;
+}
+footer a { color: var(--link); text-decoration: none; }
+footer a:hover { text-decoration: underline; }
diff --git a/web-app/public/css/machine.css b/web-app/public/css/machine.css
new file mode 100644
index 0000000..f4684d0
--- /dev/null
+++ b/web-app/public/css/machine.css
@@ -0,0 +1,322 @@
+:root {
+ --bg: #0f172a;
+ --panel: #1e293b;
+ --panel-2: #273449;
+ --border: #334155;
+ --text: #e2e8f0;
+ --muted: #94a3b8;
+ --accent: #38bdf8;
+ --accent-2: #818cf8;
+ --success: #4ade80;
+ --warn: #fbbf24;
+ --danger: #f87171;
+ --head: #f472b6;
+}
+
+html, body {
+ background: var(--bg);
+ color: var(--text);
+}
+
+header {
+ padding: 28px 32px 16px;
+ border-bottom: 1px solid var(--border);
+}
+h1 {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+header p {
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+header a {
+ color: var(--accent);
+ text-decoration: none;
+}
+header a:hover { text-decoration: underline; }
+
+main {
+ padding: 24px 32px 48px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+ gap: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.card-title {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+.card-badge {
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: var(--panel-2);
+ color: var(--muted);
+ border: 1px solid var(--border);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.card.full { grid-column: 1 / -1; }
+
+/* Tape */
+.tape {
+ display: flex;
+ overflow-x: auto;
+ gap: 6px;
+ padding: 8px 4px 16px;
+ position: relative;
+}
+.cell {
+ flex: 0 0 auto;
+ width: 44px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel-2);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 16px;
+ position: relative;
+}
+.cell.head {
+ border-color: var(--head);
+ box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.25);
+ color: var(--head);
+ font-weight: 700;
+}
+.cell.head::after {
+ content: "▲";
+ position: absolute;
+ bottom: -20px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: var(--head);
+ font-size: 12px;
+}
+.cell.boundary {
+ background: rgba(56, 189, 248, 0.12);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Stats */
+.stats {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+.stat {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+}
+.stat-label {
+ font-size: 11px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.stat-value {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 20px;
+ margin-top: 4px;
+ color: var(--text);
+}
+.stat-value.halted { color: var(--success); }
+.stat-value.running { color: var(--accent); }
+
+/* Buttons */
+.controls {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+button {
+ background: var(--panel-2);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 14px;
+ font-size: 14px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: background 0.15s, border-color 0.15s;
+}
+button:hover:not(:disabled) {
+ background: #334155;
+ border-color: var(--accent);
+}
+button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+button.primary {
+ background: var(--accent);
+ color: #0f172a;
+ border-color: var(--accent);
+}
+button.primary:hover:not(:disabled) {
+ background: #7dd3fc;
+}
+button.danger {
+ background: transparent;
+ color: var(--danger);
+ border-color: var(--danger);
+}
+.speed {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ grid-column: 1 / -1;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 12px;
+}
+.speed label {
+ font-size: 12px;
+ color: var(--muted);
+}
+.speed input { flex: 1; }
+.speed-value {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ color: var(--accent);
+ min-width: 60px;
+ text-align: right;
+}
+
+/* Control memory */
+.rules {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-height: 360px;
+ overflow-y: auto;
+}
+.state-block {
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+}
+.state-block.active {
+ border-color: var(--accent-2);
+ box-shadow: 0 0 0 1px var(--accent-2);
+}
+.state-block.halt { border-color: var(--success); }
+.state-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+.state-name {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px;
+ color: var(--accent-2);
+ font-weight: 600;
+}
+.state-block.halt .state-name { color: var(--success); }
+.state-tag {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+.rule {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ color: var(--muted);
+ padding: 4px 6px;
+ border-radius: 4px;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.rule.active {
+ background: rgba(129, 140, 248, 0.15);
+ color: var(--text);
+}
+.rule-arrow { color: var(--accent); }
+.rule-dir { color: var(--warn); }
+
+/* Log */
+.log {
+ background: #0b1220;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ max-height: 280px;
+ overflow-y: auto;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre;
+}
+.log-line { color: var(--muted); }
+.log-line.head { color: var(--head); }
+.log-line.halt { color: var(--success); font-weight: 600; }
+
+/* Editor */
+.editor {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.editor label {
+ font-size: 12px;
+ color: var(--muted);
+}
+.editor input,
+.editor textarea {
+ background: var(--panel-2);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ width: 100%;
+}
+.editor textarea {
+ min-height: 100px;
+ resize: vertical;
+}
+.editor .row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+.editor small {
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.5;
+}
+.editor button { width: 100%; }
diff --git a/web-app/public/index.html b/web-app/public/index.html
new file mode 100644
index 0000000..457eadf
--- /dev/null
+++ b/web-app/public/index.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
Compact Turing Machine Simulator
+
+
+
+
+
+
A small computer in a small space
+
Compact Turing Machine Simulator
+
+ Two interactive visualizations of the same idea: a tape, a head, a tiny set of rules, and the
+ surprising amount of computation that emerges. Inspired by David Hartkop's
+ Turing-Complete-Cards and the original TurMac_1.1.py reference.
+
+
+
+
+
+
+
+
diff --git a/web-app/public/machine.html b/web-app/public/machine.html
new file mode 100644
index 0000000..1156077
--- /dev/null
+++ b/web-app/public/machine.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+
Compact Turing Machine Simulator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Step
+
Run
+
Pause
+
Reset
+
+ Speed
+
+ 300 ms
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Control Memory (array of states; each state is an array of rules [read, write, dir, next]. dir: 0=L, 1=R. An empty state halts.)
+
+
+
+
Default program: tape [8,0,...,0,8], head at 1, three states. State 2 is empty (halt). Edit and press Apply to load a new program.
+
Apply Program
+
+
+
+
+
+
+
diff --git a/web-app/src/cards/compiler.js b/web-app/src/cards/compiler.js
new file mode 100644
index 0000000..66b4b1f
--- /dev/null
+++ b/web-app/src/cards/compiler.js
@@ -0,0 +1,148 @@
+// Card-program parsing and compilation.
+//
+// A program is a flat sequence of integers:
+// 0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ...
+// Each state's rules use 1-indexed next-state references.
+// A halt state has rule count 0.
+
+export function parseSequence(text) {
+ return text
+ .split(/[\s,]+/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ .map((s) => {
+ const n = parseInt(s, 10);
+ if (Number.isNaN(n)) throw new Error(`Not a number: "${s}"`);
+ return n;
+ });
+}
+
+// Strict compile — throws on any malformed input. Useful as a reference.
+export function compileProgram(seq) {
+ let i = 0;
+ if (seq.length < 2) throw new Error('Program too short');
+ if (seq[i++] !== 0) throw new Error('Program must start with a 0 (alignment) card');
+ const numStates = seq[i++];
+ if (numStates < 1) throw new Error('Need at least one state');
+ const states = [];
+ for (let s = 1; s <= numStates; s++) {
+ if (i >= seq.length) throw new Error(`Ran out of cards while parsing state ${s}`);
+ const stateNum = seq[i++];
+ if (stateNum !== s) {
+ throw new Error(`Expected state #${s} marker, got ${stateNum} (card index ${i - 1})`);
+ }
+ if (i >= seq.length) throw new Error(`Missing rule count for state ${s}`);
+ const ruleCount = seq[i++];
+ const rules = [];
+ for (let r = 0; r < ruleCount; r++) {
+ if (i + 4 > seq.length) {
+ throw new Error(`Rule ${r + 1} of state ${s} is truncated`);
+ }
+ const read = seq[i++], write = seq[i++], dir = seq[i++], next = seq[i++];
+ if (dir !== 0 && dir !== 1) throw new Error(`Bad direction ${dir} in state ${s} rule ${r + 1}`);
+ if (next < 1 || next > numStates) {
+ throw new Error(`Bad next-state ${next} in state ${s} rule ${r + 1}`);
+ }
+ rules.push([read, write, dir, next - 1]);
+ }
+ states.push(rules);
+ }
+ return { states, used: i, leftover: seq.slice(i) };
+}
+
+// Lenient compile — auto-fixes common mistakes and reports them.
+// Returns { states, fixes, normalized, leftover }.
+export function compileProgramLenient(seq) {
+ const fixes = [];
+ const out = seq.slice();
+ let i = 0;
+ if (out.length === 0) {
+ fixes.push('Empty program — inserted alignment + 1-state halt skeleton.');
+ out.push(0, 1, 1, 0);
+ }
+ if (out[0] !== 0) {
+ fixes.push(`First card was ${out[0]}, expected 0 (alignment) — prepended an alignment card.`);
+ out.unshift(0);
+ }
+ i = 1;
+ if (i >= out.length) {
+ out.push(1);
+ fixes.push('Missing state-count card — defaulted to 1 state.');
+ }
+ let numStates = out[i];
+ if (!Number.isInteger(numStates) || numStates < 1) {
+ fixes.push(`State-count ${numStates} invalid — set to 1.`);
+ numStates = 1; out[i] = 1;
+ }
+ if (numStates > 8) {
+ fixes.push(`State-count ${numStates} > 8 — clamped to 8.`);
+ numStates = 8; out[i] = 8;
+ }
+ i++;
+ const states = [];
+ for (let s = 1; s <= numStates; s++) {
+ if (i >= out.length) {
+ out.push(s, 0);
+ fixes.push(`State ${s} missing — appended as halt state.`);
+ }
+ if (out[i] !== s) {
+ fixes.push(`Expected state marker #${s}, got ${out[i]} — relabeled.`);
+ out[i] = s;
+ }
+ i++;
+ if (i >= out.length) {
+ out.push(0);
+ fixes.push(`State ${s} missing rule count — defaulted to 0 (halt).`);
+ }
+ let ruleCount = out[i];
+ if (!Number.isInteger(ruleCount) || ruleCount < 0) {
+ fixes.push(`State ${s} rule count ${ruleCount} invalid — set to 0.`);
+ ruleCount = 0; out[i] = 0;
+ }
+ if (ruleCount > 8) {
+ fixes.push(`State ${s} rule count ${ruleCount} > 8 — clamped to 8.`);
+ ruleCount = 8; out[i] = 8;
+ }
+ i++;
+ const rules = [];
+ for (let r = 0; r < ruleCount; r++) {
+ while (i + 4 > out.length) {
+ out.push(0);
+ fixes.push(`State ${s} rule ${r + 1} truncated — padded with 0.`);
+ }
+ let read = out[i], write = out[i + 1], dir = out[i + 2], next = out[i + 3];
+ if (!Number.isInteger(read) || read < 0 || read > 7) {
+ fixes.push(`State ${s} rule ${r + 1}: read ${read} clamped to 0–7.`);
+ read = Math.max(0, Math.min(7, read | 0)); out[i] = read;
+ }
+ if (!Number.isInteger(write) || write < 0 || write > 7) {
+ fixes.push(`State ${s} rule ${r + 1}: write ${write} clamped to 0–7.`);
+ write = Math.max(0, Math.min(7, write | 0)); out[i + 1] = write;
+ }
+ if (dir !== 0 && dir !== 1) {
+ fixes.push(`State ${s} rule ${r + 1}: direction ${dir} invalid — set to R (1).`);
+ dir = 1; out[i + 2] = 1;
+ }
+ if (!Number.isInteger(next) || next < 1 || next > numStates) {
+ fixes.push(`State ${s} rule ${r + 1}: next-state #${next} out of range — clamped to halt (#${numStates}).`);
+ next = numStates; out[i + 3] = numStates;
+ }
+ rules.push([read, write, dir, next - 1]);
+ i += 4;
+ }
+ states.push(rules);
+ }
+ return { states, fixes, normalized: out.slice(0, i), leftover: out.slice(i) };
+}
+
+// Serialize a states array back to the flat card sequence (1-indexed next refs).
+export function serializeStates(states) {
+ const parts = [0, states.length];
+ states.forEach((rules, sIdx) => {
+ parts.push(sIdx + 1, rules.length);
+ rules.forEach((rule) => {
+ parts.push(rule[0], rule[1], rule[2], rule[3] + 1);
+ });
+ });
+ return parts.join(',');
+}
diff --git a/web-app/src/cards/main.js b/web-app/src/cards/main.js
new file mode 100644
index 0000000..1ed3e12
--- /dev/null
+++ b/web-app/src/cards/main.js
@@ -0,0 +1,441 @@
+import { $ } from '../core/dom.js';
+import { Logger } from '../core/logger.js';
+import { Runner } from '../core/runner.js';
+import { TuringMachine } from '../core/turing.js';
+import { makeCard } from './svg.js';
+import { parseSequence, compileProgramLenient, serializeStates } from './compiler.js';
+import { PRESETS } from './presets.js';
+
+const view = {
+ states: [],
+ tape: [],
+ headStart: 0,
+ cycles: 500,
+ error: null,
+};
+
+let engine = null;
+let matchedThisStep = false;
+
+const logger = new Logger($('log'));
+
+const runner = new Runner({
+ tick: () => step(),
+ getInterval: () => speed,
+ onChange: () => updateButtons(),
+});
+
+let speed = 500;
+
+// ---- View helpers ----
+
+function setStatus(text, cls = '') {
+ const el = $('s-status');
+ el.textContent = text;
+ el.className = 'val ' + cls;
+}
+
+function updateButtons() {
+ const haltedOrError = !engine || engine.halted || view.error;
+ $('b-run').disabled = haltedOrError || runner.running;
+ $('b-step').disabled = haltedOrError || runner.running;
+ $('b-pause').disabled = !runner.running;
+}
+
+function renderProgram() {
+ const header = $('program-header');
+ header.querySelectorAll('.slot,.card-cluster').forEach((n) => n.remove());
+ const alignCluster = document.createElement('div');
+ alignCluster.className = 'card-cluster';
+ alignCluster.appendChild(makeCard(0, { title: 'Alignment (always 0)' }));
+ alignCluster.appendChild(makeCard(view.states.length, {
+ title: `Total states: ${view.states.length} (click to change)`,
+ role: 'numStates',
+ }));
+ header.appendChild(alignCluster);
+
+ const stateRows = $('state-rows');
+ stateRows.innerHTML = '';
+ view.states.forEach((rules, sIdx) => {
+ const row = document.createElement('div');
+ row.className = 'prog-row';
+ row.dataset.state = sIdx;
+ if (rules.length === 0) row.classList.add('halt-row');
+
+ const label = document.createElement('span');
+ label.className = 'row-label';
+ label.textContent = `State ${sIdx + 1}${rules.length === 0 ? ' · HALT' : ''}`;
+ row.appendChild(label);
+
+ const head = document.createElement('div');
+ head.className = 'card-cluster state-head';
+ head.appendChild(makeCard(sIdx + 1, { title: `State #${sIdx + 1}` }));
+ head.appendChild(makeCard(rules.length, {
+ title: `${rules.length} rules (click to add/remove)`,
+ role: 'ruleCount',
+ data: { stateIdx: sIdx },
+ }));
+ row.appendChild(head);
+
+ rules.forEach((rule, rIdx) => {
+ const cluster = document.createElement('div');
+ cluster.className = 'card-cluster rule-cluster';
+ cluster.dataset.rule = rIdx;
+ const dirName = rule[2] === 0 ? 'Left' : 'Right';
+ cluster.title = `Rule ${rIdx + 1}: read ${rule[0]} → write ${rule[1]}, move ${dirName}, go to state #${rule[3] + 1}`;
+ cluster.appendChild(makeCard(rule[0], {
+ title: `Read: ${rule[0]} (click to change)`,
+ role: 'ruleRead',
+ data: { stateIdx: sIdx, ruleIdx: rIdx },
+ }));
+ cluster.appendChild(makeCard(rule[1], {
+ title: `Write: ${rule[1]} (click to change)`,
+ role: 'ruleWrite',
+ data: { stateIdx: sIdx, ruleIdx: rIdx },
+ }));
+ cluster.appendChild(makeCard(rule[2], {
+ title: `Direction: ${dirName} (click to toggle)`,
+ role: 'ruleDir',
+ data: { stateIdx: sIdx, ruleIdx: rIdx },
+ }));
+ cluster.appendChild(makeCard(rule[3] + 1, {
+ title: `Next state: #${rule[3] + 1} (click to change)`,
+ role: 'ruleNext',
+ data: { stateIdx: sIdx, ruleIdx: rIdx },
+ }));
+ row.appendChild(cluster);
+ });
+
+ stateRows.appendChild(row);
+ });
+
+ const headRow = $('head-row');
+ headRow.innerHTML = '';
+ const headLbl = document.createElement('span');
+ headLbl.className = 'lbl';
+ headLbl.textContent = `Playhead position card (start = ${view.headStart + 1}):`;
+ headRow.appendChild(headLbl);
+ const cardValue = view.headStart + 1;
+ if (cardValue >= 0 && cardValue <= 7) {
+ headRow.appendChild(makeCard(cardValue, {
+ title: `Start at tape position ${cardValue} (click to change)`,
+ role: 'headPos',
+ }));
+ } else {
+ const note = document.createElement('span');
+ note.className = 'lbl';
+ note.style.color = 'var(--muted)';
+ note.textContent = `(position ${cardValue} is beyond a single card — encoded numerically)`;
+ headRow.appendChild(note);
+ }
+}
+
+function renderTape() {
+ const tapeEl = $('tape');
+ tapeEl.innerHTML = '';
+ const tape = engine ? engine.tape : view.tape;
+ const headIdx = engine ? engine.head : view.headStart;
+ tape.forEach((v, i) => {
+ const isHead = i === headIdx;
+ let card;
+ try {
+ card = makeCard(v, {
+ bumped: isHead,
+ title: `Tape[${i + 1}] = ${v} (click to change)`,
+ role: 'tapeCell',
+ data: { tapeIdx: i },
+ });
+ } catch (e) {
+ card = document.createElement('div');
+ card.className = 'slot';
+ card.textContent = v;
+ card.style.color = 'var(--accent)';
+ card.style.display = 'flex';
+ card.style.alignItems = 'center';
+ card.style.justifyContent = 'center';
+ card.style.background = 'rgba(0,0,0,0.3)';
+ card.style.border = '1px dashed var(--accent)';
+ card.style.borderRadius = '8px';
+ card.style.fontFamily = 'ui-monospace,monospace';
+ }
+ if (isHead) {
+ card.classList.add('read-cell');
+ const bump = document.createElement('span');
+ bump.className = 'tape-bump-indicator';
+ bump.textContent = 'PLAYHEAD';
+ card.appendChild(bump);
+ }
+ tapeEl.appendChild(card);
+ });
+}
+
+function updateStatus() {
+ if (!engine) {
+ $('s-state').textContent = '—';
+ $('s-cycle').textContent = '—';
+ $('s-head').textContent = '—';
+ $('s-read').textContent = '—';
+ } else {
+ $('s-state').textContent = engine.currentState + 1;
+ $('s-cycle').textContent = engine.cycle;
+ $('s-head').textContent = engine.head + 1;
+ const read = engine.tape[engine.head];
+ $('s-read').textContent = (read === undefined ? '—' : read);
+ }
+ if (view.error) setStatus(view.error, 'error');
+ else if (engine && engine.halted) setStatus('Halted', 'halted');
+ else if (runner.running) setStatus('Running', 'running');
+ else setStatus('Ready');
+ updateButtons();
+}
+
+function highlightActive() {
+ document.querySelectorAll('.prog-row').forEach((r) => r.classList.remove('active'));
+ document.querySelectorAll('.rule-cluster').forEach((r) => r.classList.remove('matched'));
+ if (!engine || engine.halted || view.error) return;
+ const row = document.querySelector(`.prog-row[data-state="${engine.currentState}"]`);
+ if (row) row.classList.add('active');
+ if (matchedThisStep && engine.activeRule >= 0 && row) {
+ const cluster = row.querySelector(`.rule-cluster[data-rule="${engine.activeRule}"]`);
+ if (cluster) cluster.classList.add('matched');
+ }
+}
+
+function fullRender() {
+ renderProgram();
+ renderTape();
+ updateStatus();
+ highlightActive();
+}
+
+// ---- Engine wiring ----
+
+function buildEngine() {
+ engine = new TuringMachine({
+ tape: view.tape,
+ head: view.headStart,
+ control: view.states,
+ startState: 0,
+ cycleLimit: view.cycles,
+ boundsCheck: 'before',
+ });
+ matchedThisStep = false;
+}
+
+function loadFromInputs() {
+ const progText = $('prog').value;
+ const headCard = parseInt($('head-card').value, 10);
+ const tapeText = $('tape-input').value;
+ const cycles = parseInt($('cycles').value, 10) || 500;
+
+ const seq = parseSequence(progText);
+ const compiled = compileProgramLenient(seq);
+ let tape = parseSequence(tapeText);
+ const tapeFixes = [];
+ if (tape.length === 0) {
+ tape = [0];
+ tapeFixes.push('Tape was empty — inserted a single 0 cell.');
+ }
+ tape = tape.map((v, idx) => {
+ if (!Number.isInteger(v) || v < 0 || v > 7) {
+ tapeFixes.push(`Tape[${idx + 1}] = ${v} clamped to 0–7.`);
+ return Math.max(0, Math.min(7, v | 0));
+ }
+ return v;
+ });
+ let headStart = headCard - 1;
+ if (!Number.isInteger(headCard) || headCard < 1 || headCard > tape.length) {
+ headStart = Math.max(0, Math.min(tape.length - 1, (headCard | 0) - 1));
+ tapeFixes.push(`Head card ${headCard} out of range 1–${tape.length} — set to ${headStart + 1}.`);
+ }
+
+ view.states = compiled.states;
+ view.tape = tape.slice();
+ view.headStart = headStart;
+ view.cycles = cycles;
+ view.error = null;
+
+ buildEngine();
+ logger.clear();
+ logger.line(`Loaded program with ${compiled.states.length} states. Tape length ${tape.length}, head at position ${headStart + 1}.`);
+ compiled.fixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err'));
+ tapeFixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err'));
+ if (compiled.leftover.length) {
+ logger.line(`Note: ${compiled.leftover.length} extra card(s) after the program were ignored: ${compiled.leftover.join(',')}`);
+ }
+ if (compiled.fixes.length || tapeFixes.length) {
+ syncEditorFromState();
+ }
+}
+
+function step() {
+ if (!engine || engine.halted || view.error) return false;
+ matchedThisStep = false;
+
+ const result = engine.step();
+ switch (result.kind) {
+ case 'cycle-limit':
+ logger.line('CYCLE LIMIT REACHED', 'halt');
+ fullRender();
+ return false;
+ case 'halt':
+ logger.line('HALT CONDITION REACHED', 'halt');
+ fullRender();
+ return false;
+ case 'oob':
+ logger.line(`Playhead at invalid position ${result.position + 1} in non-halt state. Halting.`, 'halt');
+ fullRender();
+ return false;
+ case 'no-rule':
+ logger.line(`No rule matches read=${result.read} in state ${engine.currentState + 1}. Halting.`, 'halt');
+ fullRender();
+ return false;
+ case 'step': {
+ matchedThisStep = true;
+ const r = result.rule;
+ logger.line(`[cycle ${engine.cycle - 1}] state ${result.prevState + 1}, head@${result.prevHead + 1}, read ${result.read} → write ${r[1]}, move ${r[2] === 0 ? 'L' : 'R'}, next state ${r[3] + 1}`);
+ fullRender();
+ return true;
+ }
+ default:
+ return false;
+ }
+}
+
+function reset() {
+ runner.stop();
+ try {
+ loadFromInputs();
+ fullRender();
+ } catch (e) {
+ view.error = e.message;
+ engine = null;
+ logger.line('Error: ' + e.message, 'err');
+ updateStatus();
+ }
+}
+
+function loadPreset(key) {
+ const p = PRESETS[key];
+ if (!p) return;
+ $('prog').value = p.program;
+ $('head-card').value = p.head;
+ $('tape-input').value = p.tape;
+ $('cycles').value = p.cycles;
+ reset();
+}
+
+// ---- Click-to-edit ----
+
+function cycleVal(v, dir, lo, hi) {
+ const size = hi - lo + 1;
+ return ((v - lo + dir) % size + size) % size + lo;
+}
+
+function autofixNextStateRefs() {
+ const n = view.states.length;
+ if (n === 0) return [];
+ const fixes = [];
+ view.states.forEach((rules, sIdx) => {
+ rules.forEach((rule, rIdx) => {
+ if (rule[3] < 0 || rule[3] >= n) {
+ const old = rule[3];
+ rule[3] = n - 1;
+ fixes.push(`State ${sIdx + 1} rule ${rIdx + 1}: next-state #${old + 1} → #${rule[3] + 1}`);
+ }
+ });
+ });
+ return fixes;
+}
+
+function syncEditorFromState() {
+ $('prog').value = serializeStates(view.states);
+ $('head-card').value = view.headStart + 1;
+ $('tape-input').value = view.tape.join(',');
+}
+
+const ROLE_HANDLERS = {
+ numStates(slot, dir) {
+ const cur = view.states.length;
+ const next = cycleVal(cur, dir, 1, 8);
+ if (next > cur) {
+ for (let i = cur; i < next; i++) view.states.push([]);
+ } else {
+ view.states.length = next;
+ }
+ const fixes = autofixNextStateRefs();
+ fixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err'));
+ logger.line(`Number of states: ${cur} → ${next}`);
+ },
+ ruleCount(slot, dir) {
+ const sIdx = parseInt(slot.dataset.stateIdx, 10);
+ const rules = view.states[sIdx];
+ const cur = rules.length;
+ const next = cycleVal(cur, dir, 0, 8);
+ if (next > cur) {
+ for (let i = cur; i < next; i++) rules.push([0, 0, 1, 0]);
+ } else {
+ rules.length = next;
+ }
+ logger.line(`State ${sIdx + 1} rules: ${cur} → ${next}`);
+ },
+ ruleRead(slot, dir) {
+ const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx];
+ rule[0] = cycleVal(rule[0], dir, 0, 7);
+ },
+ ruleWrite(slot, dir) {
+ const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx];
+ rule[1] = cycleVal(rule[1], dir, 0, 7);
+ },
+ ruleDir(slot) {
+ const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx];
+ rule[2] = rule[2] === 0 ? 1 : 0;
+ },
+ ruleNext(slot, dir) {
+ const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx];
+ const n = view.states.length;
+ rule[3] = cycleVal(rule[3], dir, 0, n - 1);
+ },
+ headPos(slot, dir) {
+ const hi = Math.min(view.tape.length, 7) - 1;
+ view.headStart = cycleVal(view.headStart, dir, 0, hi);
+ },
+ tapeCell(slot, dir) {
+ const i = +slot.dataset.tapeIdx;
+ view.tape[i] = cycleVal(view.tape[i], dir, 0, 7);
+ },
+};
+
+function handleCardClick(slot, shiftKey) {
+ const handler = ROLE_HANDLERS[slot.dataset.role];
+ if (!handler) return;
+ runner.stop();
+ handler(slot, shiftKey ? -1 : 1);
+ buildEngine();
+ syncEditorFromState();
+ fullRender();
+}
+
+// ---- Wire up ----
+
+$('b-step').addEventListener('click', () => { if (!runner.running) step(); });
+$('b-run').addEventListener('click', () => runner.start());
+$('b-pause').addEventListener('click', () => runner.stop());
+$('b-reset').addEventListener('click', reset);
+$('b-apply').addEventListener('click', reset);
+$('b-load-default').addEventListener('click', () => loadPreset($('preset').value));
+$('preset').addEventListener('change', (e) => loadPreset(e.target.value));
+
+$('board').addEventListener('click', (e) => {
+ const slot = e.target.closest('.slot.clickable');
+ if (!slot) return;
+ handleCardClick(slot, e.shiftKey);
+});
+
+$('speed').addEventListener('input', (e) => {
+ speed = parseInt(e.target.value, 10);
+ $('speed-v').textContent = speed + ' ms';
+ runner.restart();
+});
+
+loadPreset('unary');
diff --git a/web-app/src/cards/presets.js b/web-app/src/cards/presets.js
new file mode 100644
index 0000000..2a77a40
--- /dev/null
+++ b/web-app/src/cards/presets.js
@@ -0,0 +1,20 @@
+// Preset programs from the Turing Cards setup material.
+// Card values are 0–7; the original Python uses 8 as a boundary marker, which
+// we substitute with 7 here.
+
+export const PRESETS = {
+ unary: {
+ name: 'Unary → Binary Converter',
+ program: '0,4, 1,4,1,3,0,2,2,2,1,1,3,3,1,1,5,5,0,3, 2,4,3,3,0,2,4,2,0,2,5,4,1,1,2,4,1,1, 3,4,2,0,0,3,3,3,0,3,4,1,0,3,5,5,0,4, 4,0',
+ head: 5,
+ tape: '5,5,5,5,1,1,1,1,5,4',
+ cycles: 500,
+ },
+ zigzag: {
+ name: 'Number Counting Zig-Zag (from TurMac_1.1.py)',
+ program: '0,3, 1,4,0,1,1,1,7,7,0,2,2,3,1,1,4,4,0,3, 2,3,1,2,0,2,7,7,1,1,3,4,0,2, 3,0',
+ head: 2,
+ tape: '7,0,0,0,0,0,0,0,0,0,0,0,0,0,7',
+ cycles: 500,
+ },
+};
diff --git a/web-app/src/cards/svg.js b/web-app/src/cards/svg.js
new file mode 100644
index 0000000..bd4726f
--- /dev/null
+++ b/web-app/src/cards/svg.js
@@ -0,0 +1,67 @@
+// Card SVG rendering.
+// Front face shows 0/1/2/3 (black/red/yellow/blue clockwise from top).
+// Back face shows 5/6/7/4 (orange/green/pink/purple clockwise from top).
+// Each dot's number is rotated so that, when the whole card is turned to put
+// that number on top, the digit reads upright.
+
+export const COLORS = [
+ 'var(--c0)', 'var(--c1)', 'var(--c2)', 'var(--c3)',
+ 'var(--c4)', 'var(--c5)', 'var(--c6)', 'var(--c7)',
+];
+
+export function svgFace(face) {
+ const nums = face === 'front' ? [0, 1, 2, 3] : [5, 6, 7, 4];
+ const [nT, nR, nB, nL] = nums;
+ const dot = (cx, cy, rot, num) => `
+
+
+ ${num}
+ `;
+ return `
+
+
+ ${dot(50, 22, 0, nT)}
+ ${dot(78, 50, 90, nR)}
+ ${dot(50, 78, 180, nB)}
+ ${dot(22, 50, -90, nL)}
+ `;
+}
+
+export function valueToFaceAndRotation(value) {
+ if (value < 0 || value > 7 || !Number.isInteger(value)) {
+ throw new Error(`Card value must be an integer 0–7 (got ${value})`);
+ }
+ if (value < 4) {
+ return { face: 'front', rotation: -value * 90 };
+ }
+ const idx = [5, 6, 7, 4].indexOf(value);
+ return { face: 'back', rotation: -idx * 90 };
+}
+
+export function makeCard(value, opts = {}) {
+ const slot = document.createElement('div');
+ slot.className = 'slot';
+ if (opts.small) slot.classList.add('small');
+ if (opts.bumped) slot.classList.add('bumped');
+ if (opts.title) slot.title = opts.title;
+ if (opts.role) {
+ slot.classList.add('clickable');
+ slot.dataset.role = opts.role;
+ if (opts.data) {
+ for (const k of Object.keys(opts.data)) {
+ slot.dataset[k] = opts.data[k];
+ }
+ }
+ }
+ const rotor = document.createElement('div');
+ rotor.className = 'rotor';
+ const { face, rotation } = valueToFaceAndRotation(value);
+ rotor.style.transform = `rotate(${rotation}deg)`;
+ rotor.innerHTML = svgFace(face);
+ slot.appendChild(rotor);
+ slot.dataset.value = value;
+ return slot;
+}
diff --git a/web-app/src/core/dom.js b/web-app/src/core/dom.js
new file mode 100644
index 0000000..09a0b63
--- /dev/null
+++ b/web-app/src/core/dom.js
@@ -0,0 +1,18 @@
+export const $ = (id) => document.getElementById(id);
+
+export function el(tag, opts = {}) {
+ const node = document.createElement(tag);
+ if (opts.className) node.className = opts.className;
+ if (opts.text != null) node.textContent = opts.text;
+ if (opts.html != null) node.innerHTML = opts.html;
+ if (opts.attrs) {
+ for (const [k, v] of Object.entries(opts.attrs)) node.setAttribute(k, v);
+ }
+ if (opts.dataset) {
+ for (const [k, v] of Object.entries(opts.dataset)) node.dataset[k] = v;
+ }
+ if (opts.children) {
+ for (const c of opts.children) node.appendChild(c);
+ }
+ return node;
+}
diff --git a/web-app/src/core/logger.js b/web-app/src/core/logger.js
new file mode 100644
index 0000000..6e847b6
--- /dev/null
+++ b/web-app/src/core/logger.js
@@ -0,0 +1,26 @@
+export class Logger {
+ constructor(el, badgeEl = null) {
+ this.el = el;
+ this.badgeEl = badgeEl;
+ }
+
+ line(text, cls = '') {
+ const div = document.createElement('div');
+ div.className = ('log-line ' + cls).trim();
+ div.textContent = text;
+ this.el.appendChild(div);
+ this.el.scrollTop = this.el.scrollHeight;
+ this._updateBadge();
+ }
+
+ clear() {
+ this.el.innerHTML = '';
+ this._updateBadge();
+ }
+
+ _updateBadge() {
+ if (!this.badgeEl) return;
+ const n = this.el.children.length;
+ this.badgeEl.textContent = `${n} line${n === 1 ? '' : 's'}`;
+ }
+}
diff --git a/web-app/src/core/runner.js b/web-app/src/core/runner.js
new file mode 100644
index 0000000..840ea4a
--- /dev/null
+++ b/web-app/src/core/runner.js
@@ -0,0 +1,32 @@
+export class Runner {
+ constructor({ tick, getInterval, onChange }) {
+ this.tick = tick;
+ this.getInterval = getInterval;
+ this.onChange = onChange || (() => {});
+ this.handle = null;
+ }
+
+ get running() { return this.handle !== null; }
+
+ start() {
+ if (this.handle) return;
+ this.handle = setInterval(() => {
+ const ok = this.tick();
+ if (!ok) this.stop();
+ }, this.getInterval());
+ this.onChange(true);
+ }
+
+ stop() {
+ if (!this.handle) return;
+ clearInterval(this.handle);
+ this.handle = null;
+ this.onChange(false);
+ }
+
+ restart() {
+ if (!this.handle) return;
+ this.stop();
+ this.start();
+ }
+}
diff --git a/web-app/src/core/turing.js b/web-app/src/core/turing.js
new file mode 100644
index 0000000..431896e
--- /dev/null
+++ b/web-app/src/core/turing.js
@@ -0,0 +1,102 @@
+// Compact Turing machine engine.
+//
+// Control format: control[stateIdx] = array of rules.
+// Each rule = [read, write, dir, nextStateIdx]; dir 0=L, 1=R.
+// A state with zero rules is a halt state.
+//
+// boundsCheck:
+// 'before' — check head bounds at the start of each step (cards.html semantics:
+// transitions that move the head off-tape into a halt state are
+// treated as a clean halt because the halt check runs first).
+// 'after' — apply the rule first, then check bounds (machine.html semantics:
+// moving off the tape is reported with the post-step head position).
+
+export class TuringMachine {
+ constructor({
+ tape,
+ head,
+ control,
+ startState = 0,
+ cycleLimit = 100,
+ boundsCheck = 'after',
+ }) {
+ this.initialTape = tape.slice();
+ this.initialHead = head;
+ this.initialControl = control.map((rules) => rules.map((r) => r.slice()));
+ this.startState = startState;
+ this.cycleLimit = cycleLimit;
+ this.boundsCheck = boundsCheck;
+ this.reset();
+ }
+
+ reset() {
+ this.tape = this.initialTape.slice();
+ this.head = this.initialHead;
+ this.control = this.initialControl.map((rules) => rules.map((r) => r.slice()));
+ this.currentState = this.startState;
+ this.cycle = 0;
+ this.halted = false;
+ this.activeRule = -1;
+ }
+
+ get readValue() {
+ return this.tape[this.head];
+ }
+
+ // Returns one of:
+ // { kind: 'already-halted' }
+ // { kind: 'cycle-limit' }
+ // { kind: 'halt' } // empty rule list
+ // { kind: 'no-rule', read } // no rule matches
+ // { kind: 'oob', position } // boundsCheck: 'before'
+ // { kind: 'oob-after', position, ...stepInfo } // boundsCheck: 'after'
+ // { kind: 'step', read, rule, ruleIdx, prevHead, prevState } // normal step
+ step() {
+ if (this.halted) return { kind: 'already-halted' };
+ if (this.cycle >= this.cycleLimit) {
+ this.halted = true;
+ this.activeRule = -1;
+ return { kind: 'cycle-limit' };
+ }
+
+ const rules = this.control[this.currentState];
+ if (!rules || rules.length === 0) {
+ this.halted = true;
+ this.activeRule = -1;
+ return { kind: 'halt' };
+ }
+
+ if (this.boundsCheck === 'before' && (this.head < 0 || this.head >= this.tape.length)) {
+ this.halted = true;
+ this.activeRule = -1;
+ return { kind: 'oob', position: this.head };
+ }
+
+ const read = this.tape[this.head];
+ let ruleIdx = -1;
+ for (let i = 0; i < rules.length; i++) {
+ if (rules[i][0] === read) { ruleIdx = i; break; }
+ }
+ if (ruleIdx === -1) {
+ this.halted = true;
+ this.activeRule = -1;
+ return { kind: 'no-rule', read };
+ }
+
+ const rule = rules[ruleIdx];
+ const prevHead = this.head;
+ const prevState = this.currentState;
+ this.activeRule = ruleIdx;
+ this.tape[this.head] = rule[1];
+ this.head += rule[2] === 0 ? -1 : 1;
+ this.currentState = rule[3];
+ this.cycle += 1;
+
+ if (this.boundsCheck === 'after' && (this.head < 0 || this.head >= this.tape.length)) {
+ this.halted = true;
+ return { kind: 'oob-after', position: this.head, read, rule, ruleIdx, prevHead, prevState };
+ }
+
+ return { kind: 'step', read, rule, ruleIdx, prevHead, prevState };
+ }
+}
diff --git a/web-app/src/machine/main.js b/web-app/src/machine/main.js
new file mode 100644
index 0000000..7dedb7f
--- /dev/null
+++ b/web-app/src/machine/main.js
@@ -0,0 +1,220 @@
+import { $ } from '../core/dom.js';
+import { Logger } from '../core/logger.js';
+import { Runner } from '../core/runner.js';
+import { TuringMachine } from '../core/turing.js';
+
+const defaultProgram = {
+ tape: [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8],
+ head: 1,
+ control: [
+ [[0, 1, 1, 0], [8, 8, 0, 1], [2, 3, 1, 0], [4, 4, 0, 2]],
+ [[1, 2, 0, 1], [8, 8, 1, 0], [3, 4, 0, 1]],
+ [],
+ ],
+ startState: 0,
+ cycles: 100,
+};
+
+let engine = null;
+let speed = 300;
+
+const logger = new Logger($('log'), $('log-badge'));
+
+const runner = new Runner({
+ tick: () => step(),
+ getInterval: () => speed,
+ onChange: () => updateButtons(),
+});
+
+function updateButtons() {
+ const halted = !engine || engine.halted;
+ $('btn-run').disabled = halted || runner.running;
+ $('btn-step').disabled = halted || runner.running;
+ $('btn-pause').disabled = !runner.running;
+}
+
+function loadProgram(p) {
+ engine = new TuringMachine({
+ tape: p.tape,
+ head: p.head,
+ control: p.control,
+ startState: p.startState,
+ cycleLimit: p.cycles,
+ boundsCheck: 'after',
+ });
+ logger.clear();
+ render();
+}
+
+function render() {
+ renderTape();
+ renderStats();
+ renderRules();
+ $('cycle-limit-badge').textContent = `limit: ${engine.cycleLimit}`;
+ $('status-badge').textContent = engine.halted
+ ? 'Halted'
+ : (runner.running ? 'Running' : 'Ready');
+ updateButtons();
+}
+
+function renderTape() {
+ const tape = $('tape');
+ tape.innerHTML = '';
+ $('tape-badge').textContent = `${engine.tape.length} cells`;
+ engine.tape.forEach((v, i) => {
+ const cell = document.createElement('div');
+ cell.className = 'cell';
+ if (v === 8) cell.classList.add('boundary');
+ if (i === engine.head) cell.classList.add('head');
+ cell.textContent = v;
+ tape.appendChild(cell);
+ });
+}
+
+function renderStats() {
+ $('stat-state').textContent = engine.currentState;
+ $('stat-cycle').textContent = engine.cycle;
+ $('stat-head').textContent = engine.head;
+ const read = engine.tape[engine.head];
+ $('stat-read').textContent = read === undefined ? '—' : read;
+ const stateEl = $('stat-state');
+ stateEl.classList.toggle('halted', engine.halted);
+ stateEl.classList.toggle('running', !engine.halted && runner.running);
+}
+
+function renderRules() {
+ const container = $('rules');
+ container.innerHTML = '';
+ engine.control.forEach((rules, sIdx) => {
+ const block = document.createElement('div');
+ block.className = 'state-block';
+ const isHalt = rules.length === 0;
+ if (sIdx === engine.currentState) block.classList.add('active');
+ if (isHalt) block.classList.add('halt');
+
+ const header = document.createElement('div');
+ header.className = 'state-header';
+ const name = document.createElement('span');
+ name.className = 'state-name';
+ name.textContent = `State ${sIdx}`;
+ const tag = document.createElement('span');
+ tag.className = 'state-tag';
+ tag.textContent = isHalt
+ ? 'halt'
+ : (sIdx === engine.currentState ? 'active' : `${rules.length} rules`);
+ header.appendChild(name);
+ header.appendChild(tag);
+ block.appendChild(header);
+
+ rules.forEach((rule, rIdx) => {
+ const r = document.createElement('div');
+ r.className = 'rule';
+ if (sIdx === engine.currentState && rIdx === engine.activeRule) {
+ r.classList.add('active');
+ }
+ const dir = rule[2] === 0 ? 'L' : 'R';
+ r.innerHTML = `read
${rule[0]} → write
${rule[1]} , move
${dir} , next state
${rule[3]} `;
+ block.appendChild(r);
+ });
+
+ container.appendChild(block);
+ });
+}
+
+function logCurrent() {
+ const tapeStr = '[' + engine.tape.join(', ') + ']';
+ logger.line(`${tapeStr} state: ${engine.currentState} cycle: ${engine.cycle}`);
+ const pointer = ' '.repeat(engine.head * 3 + 1) + '^';
+ logger.line(pointer, 'head');
+}
+
+function step() {
+ if (!engine || engine.halted) return false;
+
+ // Cycle-limit check is done first so the limit message isn't preceded by a
+ // configuration dump (matches the original machine.html log order).
+ if (engine.cycle >= engine.cycleLimit) {
+ engine.step(); // mutates halted + activeRule
+ logger.line('CYCLE LIMIT REACHED', 'halt');
+ render();
+ return false;
+ }
+
+ logCurrent();
+
+ const result = engine.step();
+ switch (result.kind) {
+ case 'cycle-limit':
+ logger.line('CYCLE LIMIT REACHED', 'halt');
+ render();
+ return false;
+ case 'halt':
+ logger.line('HALT CONDITION REACHED', 'halt');
+ render();
+ return false;
+ case 'no-rule':
+ logger.line(`No matching rule for read=${result.read} in state ${engine.currentState}. Halting.`, 'halt');
+ render();
+ return false;
+ case 'oob-after':
+ logger.line(`Head moved out of bounds at position ${result.position}. Halting.`, 'halt');
+ render();
+ return false;
+ case 'step':
+ render();
+ return true;
+ default:
+ return false;
+ }
+}
+
+function reset() {
+ runner.stop();
+ try {
+ const program = readEditor();
+ loadProgram(program);
+ } catch (e) {
+ loadProgram(defaultProgram);
+ }
+}
+
+function readEditor() {
+ const tape = JSON.parse($('edit-tape').value);
+ const head = parseInt($('edit-head').value, 10);
+ const control = JSON.parse($('edit-control').value);
+ const startState = parseInt($('edit-start').value, 10);
+ const cycles = parseInt($('edit-cycles').value, 10);
+ if (!Array.isArray(tape)) throw new Error('Tape must be an array');
+ if (!Array.isArray(control)) throw new Error('Control must be an array');
+ return { tape, head, control, startState, cycles };
+}
+
+function writeEditor(p) {
+ $('edit-tape').value = JSON.stringify(p.tape);
+ $('edit-head').value = p.head;
+ $('edit-control').value = JSON.stringify(p.control, null, 2);
+ $('edit-start').value = p.startState;
+ $('edit-cycles').value = p.cycles;
+}
+
+$('btn-step').addEventListener('click', () => { if (!runner.running) step(); });
+$('btn-run').addEventListener('click', () => runner.start());
+$('btn-pause').addEventListener('click', () => runner.stop());
+$('btn-reset').addEventListener('click', reset);
+$('btn-apply').addEventListener('click', () => {
+ try {
+ const p = readEditor();
+ loadProgram(p);
+ logger.line('Program loaded.', 'halt');
+ } catch (e) {
+ logger.line(`Error: ${e.message}`, 'halt');
+ }
+});
+$('speed').addEventListener('input', (e) => {
+ speed = parseInt(e.target.value, 10);
+ $('speed-value').textContent = `${speed} ms`;
+ runner.restart();
+});
+
+writeEditor(defaultProgram);
+loadProgram(defaultProgram);