$sloprun.dev

State Machine Builder

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 5 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A finite state machine is about the smallest useful computer there is: a few circles, one labelled arrow per character, and a rule about where you are allowed to stop. This one opens already running — a token walks a two-state machine that accepts binary strings with an even number of 1s, and the trace underneath names every hop it took. Then start rearranging it: drag circles, mark any of them accepting, drag the small + handle from one circle onto another to draw a new arrow, and hit Test all to re-judge all six test strings against whatever you just built. For the sharpest lesson, click the arrow q1 ──1──▶ q0, delete it, and run 1011 — the machine now runs out of road halfway through and tells you exactly which character it choked on.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/state-machine" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="State Machine Builder — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/state-machine

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1353 lines 65.0 KB index.html
demos/state-machine/index.html
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>State Machine Builder</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end token block ──────────────────────────────────────────────────────── */

/* native controls follow the resolved theme */
:root, :root[data-theme="light"] { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }

:root { --danger-soft: var(--accent-soft); }
:root { --danger-soft: color-mix(in srgb, var(--danger) 13%, var(--surface)); }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; min-height: 100vh; overflow-x: hidden; }
.sr { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
      clip:rect(0 0 0 0); white-space:nowrap; border:0; }

.wrap { max-width: 1240px; margin: 0 auto; padding: 20px 16px 26px; }

header h1 { margin: 0 0 5px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }
header p  { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--muted); max-width: 70ch; }
header b  { color: var(--ink); font-weight: 600; }

/* ── controls ─────────────────────────────────────────────────────────────── */
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:16px 0 10px; }
.grow { flex: 1 1 8px; }
.btn { font: 600 13px/1 var(--font-sans); color: var(--ink); background: var(--surface);
       border: 1px solid var(--line); border-radius: 8px; padding: 9px 12px;
       display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
       white-space: nowrap; transition: border-color .12s, color .12s, background .12s; }
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
.btn.primary:hover:not(:disabled) { filter: brightness(1.08); color: var(--surface); }
.btn.sm { padding: 6px 9px; font-size: 12px; }
.btn.wide { flex: 1 1 auto; justify-content: center; }
.btn .gl { font-size: 11px; line-height: 1; }
.btn.danger:hover:not(:disabled) { border-color: var(--danger); color: var(--danger); }

form.tape-in { display: flex; gap: 6px; align-items: center; flex: 1 1 260px; min-width: 180px; }
input[type=text] { flex: 1 1 auto; min-width: 0; font: 600 13px var(--font-mono);
  letter-spacing: .06em; color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; }
input[type=text]::placeholder { color: var(--muted); opacity: .8; letter-spacing: 0; font-weight: 500; }
input[type=text]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
select { font: 600 12.5px var(--font-sans); color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px; padding: 9px 8px; cursor: pointer; max-width: 100%; }
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── tape ─────────────────────────────────────────────────────────────────── */
.tape { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
  border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius);
  padding: 10px 12px; }
.cells { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.cell { width: 25px; height: 30px; display: grid; place-items: center; border-radius: 6px;
  border: 1px solid var(--line); background: var(--bg); color: var(--muted);
  font: 600 13px var(--font-mono); position: relative; transition: background .14s, color .14s, border-color .14s; }
.cell.done { color: var(--muted); opacity: .5; }
.cell.head { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.cell.head::after { content: "▲"; position: absolute; bottom: -11px; font-size: 8px; color: var(--accent); }
.cell.todo { color: var(--ink); background: var(--surface); }
.cell.eps { width: auto; padding: 0 9px; font-style: normal; }
.cell.bad { border-color: var(--danger); background: var(--danger-soft); color: var(--danger); }
.pill { font: 650 11px var(--font-mono); letter-spacing: .1em; padding: 4px 9px;
  border-radius: 999px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.pill[data-s="RUNNING"]  { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.pill[data-s="ACCEPTED"] { color: var(--run);    border-color: var(--run);    background: var(--run-soft); }
.pill[data-s="REJECTED"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }

/* ── status strip ─────────────────────────────────────────────────────────── */
.strip { display:flex; flex-wrap:wrap; align-items:center; gap:6px 14px; margin-top: 10px;
  font: 500 11.5px var(--font-mono); color: var(--muted);
  border: 1px solid var(--line); background: var(--surface);
  border-radius: var(--radius); padding: 9px 12px; }
.strip b { color: var(--ink); font-weight: 600; }

/* ── banner ───────────────────────────────────────────────────────────────── */
.banner { margin: 10px 0 0; border: 1px solid var(--line); border-left-width: 3px;
  background: var(--surface); border-radius: 8px; padding: 10px 12px; font-size: 13px; }
.banner[hidden] { display: none; }
.banner .hd { font-weight: 600; display: flex; gap: 7px; align-items: baseline; }
.banner .mono { display: block; margin-top: 5px; font: 500 12px var(--font-mono);
  color: var(--ink); word-break: break-word; line-height: 1.6; }
.banner[data-k="err"] { border-left-color: var(--danger); background: var(--danger-soft); }
.banner[data-k="err"] .hd { color: var(--danger); }
.banner[data-k="ok"]  { border-left-color: var(--run); background: var(--run-soft); }
.banner[data-k="ok"] .hd { color: var(--run); }
.banner[data-k="ok"] .mono { color: var(--run); }
.banner[data-k="info"] { border-left-color: var(--accent); background: var(--accent-soft); }
.banner[data-k="info"] .hd { color: var(--accent); }
.banner[data-k="hint"] .hd { color: var(--muted); font-weight: 500; }
.banner[data-k="hint"] .mono { color: var(--muted); }

/* ── stage ────────────────────────────────────────────────────────────────── */
.stage { display: grid; grid-template-columns: minmax(0,1fr) 264px; gap: 14px; margin-top: 12px; }
@media (max-width: 1000px) { .stage { grid-template-columns: minmax(0,1fr); } }

.canvas { position: relative; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--bg); overflow: hidden; height: clamp(360px, 60vh, 540px); }
svg.board { display:block; width:100%; height:100%; touch-action: none; }
.dots { fill: var(--muted); opacity: .26; }
/* arrowheads — set in CSS, not as fill="var(…)" presentation attributes
   (those are unreliable in some engines and would render black in dark mode) */
#ah  path { fill: var(--muted); fill-opacity: .55; }
#ah-a path { fill: var(--accent); fill-opacity: 1; }

/* edges */
.edge .hit  { stroke: transparent; stroke-width: 18; fill: none; pointer-events: stroke; cursor: pointer; }
.edge .wire { stroke: var(--muted); stroke-opacity: .55; stroke-width: 1.7; fill: none;
              marker-end: url(#ah); pointer-events: none; }
.edge .lbl  { fill: var(--muted); font: 600 12px var(--font-mono); text-anchor: middle; pointer-events: none; }
.edge .lbg  { fill: var(--surface); pointer-events: none; }
.edge:hover .wire, .edge.sel .wire { stroke: var(--accent); stroke-opacity: 1; stroke-width: 2.2; marker-end: url(#ah-a); }
.edge:hover .lbl,  .edge.sel .lbl  { fill: var(--accent); }
.edge.live .wire { stroke: var(--accent); stroke-opacity: 1; stroke-width: 2.6; marker-end: url(#ah-a); }
.edge.live .lbl  { fill: var(--accent); }
.edge .del { cursor: pointer; opacity: 0; pointer-events: none; }
.edge.sel .del { opacity: 1; pointer-events: auto; }
.edge .del circle { fill: var(--surface); stroke: var(--danger); stroke-width: 1.4; }
.edge .del text { fill: var(--danger); font: 700 11px var(--font-mono); text-anchor: middle; }
.ghost { stroke: var(--accent); stroke-width: 2.2; stroke-dasharray: 6 5; fill: none;
         marker-end: url(#ah-a); pointer-events: none; }

/* nodes */
.node { cursor: grab; touch-action: none; outline: none; }
.node.dragging { cursor: grabbing; }
.node .disc { fill: var(--surface); stroke: var(--line); stroke-width: 1.8;
  transition: fill .16s, stroke .16s; }
.node .inner { fill: none; stroke: var(--line); stroke-width: 1.6; transition: stroke .16s; }
.node .nm { fill: var(--ink); font: 650 13px var(--font-mono); text-anchor: middle; pointer-events: none; }
.node .ring { fill: none; stroke: var(--accent); stroke-width: 2.2; stroke-dasharray: 5 4; opacity: 0; }
.node.sel .ring { opacity: .95; }
.node:focus-visible .ring { opacity: 1; stroke-dasharray: none; stroke-width: 2.6; }
.node .halo { fill: none; stroke: var(--accent); stroke-width: 2; opacity: 0; pointer-events: none; }
.node .port { fill: var(--surface); stroke: var(--muted); stroke-width: 1.5; pointer-events: none; }
.node .portx { stroke: var(--muted); stroke-width: 1.5; pointer-events: none; }
.node .porthit { fill: transparent; cursor: crosshair; touch-action: none; }
.node:hover .port, .node.sel .port, .node:focus-visible .port { stroke: var(--accent); fill: var(--accent-soft); }
.node:hover .portx, .node.sel .portx, .node:focus-visible .portx { stroke: var(--accent); }
.node.acc .inner { stroke: var(--muted); stroke-opacity: .75; }
.node.cur .disc { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2.6; }
.node.cur .nm   { fill: var(--accent); }
.node.cur .inner{ stroke: var(--accent); }
.node.cur .halo { opacity: .5; animation: pulse 1.2s ease-out infinite; }
.node.win .disc { fill: var(--run-soft); stroke: var(--run); stroke-width: 2.8; }
.node.win .nm   { fill: var(--run); }
.node.win .inner{ stroke: var(--run); }
.node.win .halo { opacity: 0; }
.node.lose .disc { fill: var(--danger-soft); stroke: var(--danger); stroke-width: 2.8; }
.node.lose .nm   { fill: var(--danger); }
.node.lose .inner{ stroke: var(--danger); }
.node.lose .halo { opacity: 0; }
.startmark { stroke: var(--muted); stroke-width: 1.8; fill: none; marker-end: url(#ah); }
.startlbl  { fill: var(--muted); font: 600 9.5px var(--font-mono); letter-spacing: .08em; text-anchor: middle; }
.token { fill: var(--accent); stroke: var(--surface); stroke-width: 2; pointer-events: none; }
@keyframes pulse { 0% { opacity:.55; stroke-width:2; } 100% { opacity:0; stroke-width:11; } }

/* symbol popover */
.pop { position: absolute; transform: translate(-50%, -50%); display: flex; gap: 5px;
  align-items: center; background: var(--surface); border: 1px solid var(--accent);
  border-radius: 8px; padding: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.16); z-index: 5; }
.pop[hidden] { display: none; }
.pop .ph { font: 600 11px var(--font-mono); color: var(--muted); white-space: nowrap; }
.pop input { width: 74px; padding: 6px 7px; font-size: 12px; }

/* ── panel ────────────────────────────────────────────────────────────────── */
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
  padding: 13px; display: flex; flex-direction: column; gap: 12px; align-self: start; }
.panel h2 { margin: 0; font: 650 10.5px var(--font-mono); letter-spacing: .11em;
  text-transform: uppercase; color: var(--muted); }
.panel .blurb { margin: 0; font-size: 12.5px; line-height: 1.55; color: var(--muted); }
.panel .blurb b { display: block; color: var(--ink); font: 650 10.5px var(--font-mono);
  letter-spacing: .11em; text-transform: uppercase; margin-bottom: 4px; }
.panel .hint { margin: 0; padding-left: 17px; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.panel .hint li { margin-bottom: 5px; }
.panel .hint b { color: var(--ink); font-weight: 600; }
.kbd { font: 600 11px var(--font-mono); color: var(--ink); background: var(--bg);
  border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; }
.sep { border: 0; border-top: 1px solid var(--line); margin: 0; }
.row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.mono { font: 500 11.5px var(--font-mono); color: var(--muted); }
.mono b { color: var(--ink); font-weight: 600; }
.tlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.tlist li { display: flex; align-items: center; gap: 6px; justify-content: space-between;
  font: 500 11.5px var(--font-mono); background: var(--bg); border: 1px solid var(--line);
  border-radius: 6px; padding: 4px 4px 4px 8px; color: var(--ink); }
.tlist li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tlist .none { color: var(--muted); background: transparent; border-style: dashed; }
.x { border: 0; background: transparent; color: var(--muted); cursor: pointer;
  font: 700 12px var(--font-mono); line-height: 1; padding: 3px 6px; border-radius: 5px; }
.x:hover { color: var(--danger); background: var(--danger-soft); }
.x:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* test strings */
.tests { display: flex; flex-direction: column; gap: 5px; }
.tst { display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; text-align: left; background: var(--bg); border: 1px solid var(--line);
  border-radius: 7px; padding: 6px 6px 6px 9px; cursor: pointer;
  font: 600 12px var(--font-mono); color: var(--ink); letter-spacing: .05em;
  transition: border-color .12s, background .12s; }
.tst:hover { border-color: var(--accent); }
.tst:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tst .s { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tst .bg { font: 650 9.5px var(--font-mono); letter-spacing: .1em; padding: 3px 6px;
  border-radius: 999px; border: 1px solid var(--line); color: var(--muted); flex: none; }
.tst .bg[data-v="ACCEPT"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.tst .bg[data-v="REJECT"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
.tst .bg[data-v="STUCK"]  { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
.tst.on { border-color: var(--accent); background: var(--accent-soft); }
.addstr { display: flex; gap: 5px; }
.addstr input { font-size: 12px; padding: 7px 8px; }

.legend { display: flex; flex-wrap: wrap; gap: 5px 12px; border-top: 1px solid var(--line); padding-top: 11px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; font: 500 11px var(--font-mono); color: var(--muted); }
/* swatches mimic the board: a --surface disc reads against --bg, not against
   the --surface panel, so give the chip the board's own ground */
.sw { width: 12px; height: 12px; border-radius: 50%; border: 1.6px solid var(--muted);
  background: var(--bg); flex: none; }
.sw.dbl { box-shadow: inset 0 0 0 1.6px var(--bg), inset 0 0 0 3.2px var(--muted); }
.sw.cur { border-color: var(--accent); background: var(--accent-soft); }
.sw.win { border-color: var(--run); background: var(--run-soft); }

/* ── trace log ────────────────────────────────────────────────────────────── */
.logwrap { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; }
.loghd { display:flex; align-items:center; justify-content:space-between; gap:10px;
  padding: 8px 12px; border-bottom: 1px solid var(--line); }
.loghd h2 { margin:0; font: 650 10.5px var(--font-mono); letter-spacing:.11em;
  text-transform: uppercase; color: var(--muted); }
.log { margin: 0; padding: 9px 12px 11px; max-height: 190px; overflow: auto; }
.ln { display: grid; grid-template-columns: 34px 1fr; gap: 10px; min-width: max-content;
  font: 500 11.5px/1.8 var(--font-mono); color: var(--muted); }
.ln .t { opacity: .7; }
.ln .d { color: var(--ink); white-space: pre; }
.ln.ok .d, .ln.ok .t { color: var(--run); }
.ln.no .d, .ln.no .t { color: var(--danger); }
.ln.hd .d { color: var(--muted); }
.log .empty { font: 500 11.5px var(--font-mono); color: var(--muted); }

footer { margin-top: 18px; font: 500 11.5px var(--font-mono); color: var(--muted); opacity: .85; }

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
  .node.cur .halo { opacity: .4; stroke-width: 5; }
}
@media (max-width: 1000px) { .canvas { height: clamp(360px, 54vh, 480px); } }
@media (max-width: 560px) {
  header p .wide { display: none; }
  .wrap { padding: 16px 12px 22px; }
  .btn { padding: 8px 10px; font-size: 12.5px; }
  .cell { width: 22px; height: 28px; font-size: 12px; }
  .canvas { height: clamp(330px, 50vh, 430px); }
  .strip { gap: 5px 11px; padding: 8px 10px; }
}
@media (max-width: 400px) {
  form.tape-in { flex: 1 1 100%; }
  .bar .grow { display: none; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>State Machine Builder</h1>
    <p>A finite state machine is a handful of circles and labelled arrows: it reads one character
       at a time and each character bumps it into another circle. If it lands on a
       <b>double circle</b> when the input runs out, the string is <b>accepted</b>.
       <span class="wide">This one starts loaded with a machine that accepts binary strings containing an
       even number of 1s — press Run and watch the token walk.</span></p>
  </header>

  <div class="bar">
    <form class="tape-in" id="runf">
      <label class="sr" for="inp">Input string to feed the machine</label>
      <input type="text" id="inp" value="110101" maxlength="26" autocomplete="off" spellcheck="false" placeholder="type a string…">
      <button class="btn primary" id="run" type="submit"><span class="gl" aria-hidden="true">▶</span><span id="runlbl">Run</span></button>
    </form>
    <button class="btn" id="step" type="button"><span class="gl" aria-hidden="true">⇥</span>Step</button>
    <button class="btn" id="rewind" type="button">Rewind</button>
    <span class="grow"></span>
    <label class="sr" for="preset">Example machine</label>
    <select id="preset">
      <option value="even1">machine: even number of 1s</option>
      <option value="ends01">machine: ends with 01</option>
      <option value="hasab">machine: contains "ab"</option>
    </select>
    <button class="btn" id="addstate" type="button"><span class="gl" aria-hidden="true">+</span>Add state</button>
    <button class="btn" id="reload" type="button">Reset machine</button>
  </div>

  <div class="tape">
    <span class="pill" id="pill" data-s="IDLE">IDLE</span>
    <div class="cells" id="cells" aria-hidden="true"></div>
    <span class="grow"></span>
    <span class="mono">read <b id="readn">0</b>/<b id="totn">0</b> · in <b id="curname">—</b></span>
  </div>

  <div class="strip">
    <span>states <b id="nstates">0</b></span>
    <span>transitions <b id="ntrans">0</b></span>
    <span>alphabet <b id="alpha">—</b></span>
    <span>start <b id="startn">—</b></span>
    <span>accepting <b id="accn">—</b></span>
  </div>

  <div class="banner" id="banner" role="status" aria-live="polite" data-k="hint">
    <div class="hd"><span id="bico" aria-hidden="true">›</span><span id="btxt"></span></div>
    <span class="mono" id="bsub"></span>
  </div>

  <div class="stage">
    <div class="canvas" id="canvas">
      <svg class="board" id="board" role="application"
           aria-label="State machine editor. Tab to a state, arrow keys move it, S makes it the start state, A toggles accepting, C starts a transition, Enter finishes it, Delete removes the state.">
        <defs>
          <pattern id="dot" width="26" height="26" patternUnits="userSpaceOnUse">
            <circle class="dots" cx="1.2" cy="1.2" r="1.2"></circle>
          </pattern>
          <marker id="ah" viewBox="0 0 10 8" refX="9.2" refY="4" markerWidth="8" markerHeight="7" orient="auto-start-reverse">
            <path d="M0 0 L10 4 L0 8 z"></path>
          </marker>
          <marker id="ah-a" viewBox="0 0 10 8" refX="9.2" refY="4" markerWidth="8" markerHeight="7" orient="auto-start-reverse">
            <path d="M0 0 L10 4 L0 8 z"></path>
          </marker>
        </defs>
        <rect id="bgrect" x="0" y="0" width="100%" height="100%" fill="url(#dot)"></rect>
        <g id="elayer"></g>
        <g id="slayer"></g>
        <g id="nlayer"></g>
        <g id="tlayer"></g>
        <g id="glayer"></g>
      </svg>
      <div class="pop" id="pop" hidden>
        <span class="ph" id="popph">q0 → q1 on</span>
        <label class="sr" for="symin">Symbols for this transition</label>
        <input type="text" id="symin" maxlength="14" autocomplete="off" spellcheck="false" placeholder="0,1">
        <button class="btn sm" id="popok" type="button">Add</button>
      </div>
    </div>

    <aside class="panel">
      <p class="blurb" id="pblurb"></p>
      <hr class="sep">
      <h2 id="ptitle">How it works</h2>
      <div id="pbody"></div>
      <hr class="sep">
      <h2>Test strings</h2>
      <div class="tests" id="tests"></div>
      <div class="row">
        <button class="btn sm wide" id="testall" type="button">Test all</button>
      </div>
      <form class="addstr" id="addstrf">
        <label class="sr" for="newstr">Add a test string</label>
        <input type="text" id="newstr" maxlength="26" autocomplete="off" spellcheck="false" placeholder="add a string…">
        <button class="btn sm" type="submit"><span class="gl" aria-hidden="true">+</span></button>
      </form>
      <div class="legend">
        <span><i class="sw"></i>state</span>
        <span><i class="sw dbl"></i>accepting</span>
        <span><i class="sw cur"></i>reading</span>
        <span><i class="sw win"></i>accepted</span>
      </div>
    </aside>
  </div>

  <div class="logwrap">
    <div class="loghd">
      <h2>run trace</h2>
      <span class="mono" id="logmeta">idle</span>
    </div>
    <div class="log" id="log"><div class="empty">press “Run” to feed the string into the machine…</div></div>
  </div>

  <footer>demo · sloprun.dev</footer>
</div>

<script>
(function () {
"use strict";

/* ── theme bridge ───────────────────────────────────────────────────────── */
addEventListener("message", function (e) {
  var d = e && e.data;
  if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
    document.documentElement.setAttribute("data-theme", d.theme);
  }
});

var SVGNS = "http://www.w3.org/2000/svg";
var R = 30;            /* state radius */
var PADX = 66, PADY = 74;
var mqReduce = matchMedia("(prefers-reduced-motion: reduce)");

var $ = function (id) { return document.getElementById(id); };
var board = $("board"), canvas = $("canvas"),
    elayer = $("elayer"), nlayer = $("nlayer"), slayer = $("slayer"),
    tlayer = $("tlayer"), glayer = $("glayer");

/* ── model ──────────────────────────────────────────────────────────────── */
/* states: {id, name, x, y}  x/y normalised 0..1 inside the padded area       */
/* trans:  {from, to, sym}                                                    */
var states = [], trans = [], accept = {}, startId = null, seq = 0;
var sel = null, selEdge = null;
var nodeEls = new Map(), edgeEls = new Map();
var W = 800, H = 420;
var connect = null, pending = null, ghost = null, drag = null;
var tests = [], testResults = {};

var PRESETS = {
  even1: {
    label: "even number of 1s",
    blurb: "Accepts binary strings that contain an even count of 1s. Every 1 flips it between the two states; 0 changes nothing.",
    st: [["q0", .13, .48, true], ["q1", .87, .48, false]],
    tr: [[0,0,"0"], [0,1,"1"], [1,1,"0"], [1,0,"1"]],
    start: 0,
    tests: ["110101", "1011", "0000", "111", "10", ""],
    first: "110101"
  },
  ends01: {
    label: "ends with 01",
    blurb: "Accepts binary strings whose last two characters are 0 then 1. The machine only has to remember how much of “01” it has just seen.",
    st: [["q0", .08, .28, false], ["q1", .50, .78, false], ["q2", .92, .28, true]],
    tr: [[0,1,"0"], [0,0,"1"], [1,1,"0"], [1,2,"1"], [2,1,"0"], [2,0,"1"]],
    start: 0,
    tests: ["1101", "0100", "01", "1", "00101", "10"],
    first: "1101"
  },
  hasab: {
    label: "contains “ab”",
    blurb: "Accepts any word over a and b that contains the pair “ab” somewhere. Once it has been seen, the machine can never leave the accepting state.",
    st: [["q0", .08, .48, false], ["q1", .50, .48, false], ["q2", .92, .48, true]],
    tr: [[0,1,"a"], [0,0,"b"], [1,1,"a"], [1,2,"b"], [2,2,"a"], [2,2,"b"]],
    start: 0,
    tests: ["bbab", "aab", "ba", "abba", "bbb", "aaa"],
    first: "bbab"
  }
};
var presetKey = "even1";

function byId(id) { for (var i=0;i<states.length;i++) if (states[i].id === id) return states[i]; return null; }
function nameOf(id) { var s = byId(id); return s ? s.name : "?"; }
function px(s) { return PADX + s.x * Math.max(1, W - 2*PADX); }
function py(s) { return PADY + s.y * Math.max(1, H - 2*PADY); }
function ekey(f, t) { return f + ">" + t; }

function groups() {
  var m = new Map();
  for (var i=0;i<trans.length;i++) {
    var t = trans[i], k = ekey(t.from, t.to);
    if (!m.has(k)) m.set(k, { from: t.from, to: t.to, syms: [] });
    m.get(k).syms.push(t.sym);
  }
  return m;
}
function findT(from, sym) {
  for (var i=0;i<trans.length;i++) if (trans[i].from === from && trans[i].sym === sym) return trans[i];
  return null;
}
function outOf(id) { return trans.filter(function (t) { return t.from === id; })
  .sort(function (a,b) { return a.sym < b.sym ? -1 : a.sym > b.sym ? 1 : 0; }); }
function alphabet() {
  var s = {}; trans.forEach(function (t) { s[t.sym] = 1; });
  return Object.keys(s).sort();
}
function acceptIds() { return states.filter(function (s) { return accept[s.id]; }); }

/* ── loading a machine ──────────────────────────────────────────────────── */
function loadPreset(k) {
  var p = PRESETS[k]; if (!p) return;
  presetKey = k;
  stopSim();
  states = []; trans = []; accept = {}; seq = 0; sel = null; selEdge = null;
  var ids = p.st.map(function (s) {
    seq++;
    var st = { id: "s" + seq, name: s[0], x: s[1], y: s[2] };
    states.push(st); if (s[3]) accept[st.id] = true;
    return st.id;
  });
  p.tr.forEach(function (t) { trans.push({ from: ids[t[0]], to: ids[t[1]], sym: t[2] }); });
  startId = ids[p.start];
  tests = p.tests.slice(); testResults = {};
  $("inp").value = p.first;
  setBlurb(p.label, p.blurb);
  build(); renderTests(); resetSim();
  banner("hint", p.label + " — " + p.blurb, "");
}

function setBlurb(head, text) {
  var pb = $("pblurb"); pb.textContent = "";
  var h = document.createElement("b"); h.textContent = head;
  pb.appendChild(h); pb.appendChild(document.createTextNode(text));
}
function markEdited() {
  var p = PRESETS[presetKey];
  setBlurb((p ? p.label : "custom") + " · edited",
    "You have changed this machine, so the description no longer fits. “Reset machine” puts the original back.");
}

/* ── SVG helpers ────────────────────────────────────────────────────────── */
function el(tag, attrs, cls) {
  var e = document.createElementNS(SVGNS, tag);
  if (cls) e.setAttribute("class", cls);
  if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
  return e;
}
function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); }

/* ── geometry ───────────────────────────────────────────────────────────── */
function edgeGeom(g) {
  var a = byId(g.from), b = byId(g.to);
  if (!a || !b) return null;
  var ax = px(a), ay = py(a);
  if (g.from === g.to) {
    /* self loop: above unless there is no room, then below */
    var dir = (ay - 96 < 6) ? 1 : -1;
    var top = ay + dir * (R + 54);
    var d = "M " + (ax - 17) + " " + (ay + dir * (R - 6)) +
            " C " + (ax - 46) + " " + top + " " + (ax + 46) + " " + top +
            " " + (ax + 17) + " " + (ay + dir * (R - 6));
    return { d: d, lx: ax, ly: ay + dir * (R + 44) + (dir < 0 ? -4 : 14) };
  }
  var bx = px(b), by = py(b);
  var dx = bx - ax, dy = by - ay, len = Math.sqrt(dx*dx + dy*dy) || 1;
  var ux = dx/len, uy = dy/len, nx = -uy, ny = ux;
  var two = edgeEls.has(ekey(g.to, g.from)) || hasGroup(g.to, g.from);
  var bend = two ? 34 : 20;
  var cx = (ax + bx)/2 + nx*bend, cy = (ay + by)/2 + ny*bend;
  var sa = Math.atan2(cy - ay, cx - ax), sb = Math.atan2(cy - by, cx - bx);
  var sx = ax + Math.cos(sa)*R, sy = ay + Math.sin(sa)*R;
  var ex = bx + Math.cos(sb)*(R + 5), ey = by + Math.sin(sb)*(R + 5);
  var mx = (sx + 2*cx + ex)/4, my = (sy + 2*cy + ey)/4;
  return { d: "M " + sx + " " + sy + " Q " + cx + " " + cy + " " + ex + " " + ey,
           lx: mx + nx*11, ly: my + ny*11 + 4 };
}
function hasGroup(f, t) {
  for (var i=0;i<trans.length;i++) if (trans[i].from === f && trans[i].to === t) return true;
  return false;
}

/* ── build (structure changed) ──────────────────────────────────────────── */
function build() {
  clear(elayer); clear(nlayer); clear(slayer);
  edgeEls.clear(); nodeEls.clear();

  groups().forEach(function (g, k) {
    var wrap = el("g", { "data-k": k }, "edge");
    var hit  = el("path", { d: "", "stroke-linecap": "round" }, "hit");
    var wire = el("path", { d: "", "stroke-linecap": "round" }, "wire");
    var lbg  = el("rect", { rx: 4, height: 16 }, "lbg");
    var lbl  = el("text", { dy: ".32em" }, "lbl");
    var del  = el("g", {}, "del");
    del.appendChild(el("circle", { r: 8 }));
    var dt = el("text", { dy: ".34em" }); dt.textContent = "×"; del.appendChild(dt);
    wrap.appendChild(hit); wrap.appendChild(wire);
    wrap.appendChild(lbg); wrap.appendChild(lbl); wrap.appendChild(del);
    elayer.appendChild(wrap);
    edgeEls.set(k, { g: wrap, hit: hit, wire: wire, lbg: lbg, lbl: lbl, del: del, dc: del.firstChild, dt: dt, grp: g });

    hit.addEventListener("click", function (ev) {
      ev.stopPropagation();
      selEdge = (selEdge === k) ? null : k; sel = null; paintSel(); renderPanel();
    });
    del.addEventListener("click", function (ev) {
      ev.stopPropagation();
      trans = trans.filter(function (t) { return ekey(t.from, t.to) !== k; });
      selEdge = null; afterEdit("Removed the arrow " + nameOf(g.from) + " → " + nameOf(g.to) + ".");
    });
  });

  states.forEach(function (s) {
    var g = el("g", { tabindex: "0", role: "button", "data-id": s.id }, "node");
    var halo = el("circle", { r: R }, "halo");
    var disc = el("circle", { r: R }, "disc");
    var inner = el("circle", { r: R - 6 }, "inner");
    var ring = el("circle", { r: R + 7 }, "ring");
    var nm = el("text", { dy: ".34em" }, "nm"); nm.textContent = s.name;
    var porthit = el("circle", { r: 13 }, "porthit");
    var port = el("circle", { r: 8 }, "port");
    var pv = el("path", {}, "portx"), ph = el("path", {}, "portx");
    g.appendChild(halo); g.appendChild(disc); g.appendChild(inner);
    g.appendChild(ring); g.appendChild(nm);
    g.appendChild(porthit); g.appendChild(port); g.appendChild(pv); g.appendChild(ph);
    nlayer.appendChild(g);
    nodeEls.set(s.id, { g: g, disc: disc, inner: inner, ring: ring, nm: nm, halo: halo,
                        porthit: porthit, port: port, pv: pv, ph: ph });
    wireNode(s, g, porthit);
  });

  /* start marker lives in its own layer so it sits under the discs */
  geom(); paintSel(); paintStates(); renderStrip(); renderPanel();
}

function geom() {
  edgeEls.forEach(function (E, k) {
    var gg = edgeGeom(E.grp); if (!gg) return;
    E.hit.setAttribute("d", gg.d); E.wire.setAttribute("d", gg.d);
    var txt = E.grp.syms.slice().sort().join(", ");
    E.lbl.textContent = txt;
    E.lbl.setAttribute("x", gg.lx); E.lbl.setAttribute("y", gg.ly);
    var w = txt.length * 7.4 + 10;
    E.lbg.setAttribute("x", gg.lx - w/2); E.lbg.setAttribute("y", gg.ly - 8);
    E.lbg.setAttribute("width", w);
    E.del.setAttribute("transform", "translate(" + (gg.lx + w/2 + 11) + "," + gg.ly + ")");
  });
  states.forEach(function (s) {
    var N = nodeEls.get(s.id); if (!N) return;
    var x = px(s), y = py(s);
    N.g.setAttribute("transform", "translate(" + x + "," + y + ")");
    N.nm.textContent = s.name;
    N.disc.setAttribute("r", R); N.halo.setAttribute("r", R);
    N.inner.setAttribute("r", R - 6); N.ring.setAttribute("r", R + 7);
    var pxx = R * 0.72, pyy = -R * 0.72;
    N.port.setAttribute("cx", pxx); N.port.setAttribute("cy", pyy);
    N.porthit.setAttribute("cx", pxx); N.porthit.setAttribute("cy", pyy);
    N.pv.setAttribute("d", "M " + pxx + " " + (pyy-4) + " V " + (pyy+4));
    N.ph.setAttribute("d", "M " + (pxx-4) + " " + pyy + " H " + (pxx+4));
    N.inner.setAttribute("visibility", accept[s.id] ? "visible" : "hidden");
    N.g.classList.toggle("acc", !!accept[s.id]);
    N.g.setAttribute("aria-label", "State " + s.name +
      (s.id === startId ? ", start state" : "") + (accept[s.id] ? ", accepting" : "") +
      ". " + outOf(s.id).length + " outgoing transitions.");
  });
  clear(slayer);
  var st = byId(startId);
  if (st) {
    var x = px(st), y = py(st);
    slayer.appendChild(el("path", { d: "M " + (x - R - 34) + " " + y + " H " + (x - R - 5) }, "startmark"));
    var lt = el("text", { x: x - R - 20, y: y - 9 }, "startlbl"); lt.textContent = "start";
    slayer.appendChild(lt);
  }
}

/* ── painting ───────────────────────────────────────────────────────────── */
function paintSel() {
  nodeEls.forEach(function (N, id) { N.g.classList.toggle("sel", id === sel); });
  edgeEls.forEach(function (E, k) { E.g.classList.toggle("sel", k === selEdge); });
}
function paintStates() {
  nodeEls.forEach(function (N, id) {
    var cur = sim.started && !sim.done && sim.cur === id;
    var win = sim.done && sim.verdict === "ACCEPTED" && sim.cur === id;
    var lose = sim.done && sim.verdict === "REJECTED" && sim.cur === id;
    N.g.classList.toggle("cur", !!cur);
    N.g.classList.toggle("win", !!win);
    N.g.classList.toggle("lose", !!lose);
  });
}
function liveEdge(k) {
  edgeEls.forEach(function (E, kk) { E.g.classList.toggle("live", kk === k); });
}
function renderStrip() {
  $("nstates").textContent = states.length;
  $("ntrans").textContent = trans.length;
  var a = alphabet();
  $("alpha").textContent = a.length ? "{" + a.join(",") + "}" : "∅";
  $("startn").textContent = startId ? nameOf(startId) : "—";
  var acc = acceptIds();
  $("accn").textContent = acc.length ? "{" + acc.map(function (s) { return s.name; }).join(",") + "}" : "∅";
}
function banner(kind, text, sub) {
  var b = $("banner");
  if (!kind) { b.hidden = true; return; }
  b.hidden = false; b.setAttribute("data-k", kind);
  $("bico").textContent = kind === "ok" ? "✓" : kind === "err" ? "✗" : kind === "info" ? "●" : "›";
  $("btxt").textContent = text;
  $("bsub").textContent = sub || "";
  $("bsub").style.display = sub ? "" : "none";
}

/* ── panel ──────────────────────────────────────────────────────────────── */
function renderPanel() {
  var body = $("pbody"), title = $("ptitle");
  body.textContent = "";
  if (selEdge && edgeEls.has(selEdge)) {
    var grp = edgeEls.get(selEdge).grp;
    title.textContent = "Arrow " + nameOf(grp.from) + " → " + nameOf(grp.to);
    var p = document.createElement("p"); p.className = "mono";
    p.textContent = "reads " + grp.syms.slice().sort().join(", ") + " in state " + nameOf(grp.from);
    body.appendChild(p);
    body.appendChild(transList(grp.from, grp.to));
    var b = document.createElement("button");
    b.className = "btn sm danger"; b.type = "button"; b.textContent = "Delete this arrow";
    b.addEventListener("click", function () {
      trans = trans.filter(function (t) { return ekey(t.from, t.to) !== selEdge; });
      selEdge = null; afterEdit("Removed the arrow " + nameOf(grp.from) + " → " + nameOf(grp.to) + ".");
    });
    body.appendChild(b);
    return;
  }
  var s = sel ? byId(sel) : null;
  if (!s) {
    title.textContent = "How it works";
    var ul = document.createElement("ul"); ul.className = "hint";
    [["Press <b>Run</b> to feed the string into the machine. <b>Step</b> walks it one character at a time."],
     ["<b>Drag</b> a circle to move it. Click one to edit it — rename, make it the start, or mark it accepting."],
     ["Drag the small <b>+</b> handle onto another circle (or back onto itself) to draw a new arrow, then type the characters it reads."],
     ["Keyboard: <span class=\"kbd\">Tab</span> to a state, arrows move it, <span class=\"kbd\">S</span> start, <span class=\"kbd\">A</span> accepting, <span class=\"kbd\">C</span> then <span class=\"kbd\">Enter</span> to connect, <span class=\"kbd\">Del</span> to remove."]]
      .forEach(function (t) { var li = document.createElement("li"); li.innerHTML = t[0]; ul.appendChild(li); });
    body.appendChild(ul);
    return;
  }
  title.textContent = "State " + s.name;

  var r1 = document.createElement("div"); r1.className = "row";
  var lab = document.createElement("label"); lab.className = "sr"; lab.setAttribute("for", "rn"); lab.textContent = "State name";
  var nameIn = document.createElement("input");
  nameIn.type = "text"; nameIn.id = "rn"; nameIn.value = s.name; nameIn.maxLength = 5;
  nameIn.style.flex = "1 1 90px"; nameIn.autocomplete = "off"; nameIn.spellcheck = false;
  nameIn.addEventListener("input", function () {
    var v = nameIn.value.replace(/\s+/g, "").slice(0, 5);
    if (v) { s.name = v; geom(); renderStrip(); renderTrace(); updateCur(); }
  });
  r1.appendChild(lab); r1.appendChild(nameIn);
  body.appendChild(r1);

  var r2 = document.createElement("div"); r2.className = "row";
  var sb = document.createElement("button");
  sb.className = "btn sm"; sb.type = "button";
  sb.textContent = s.id === startId ? "✓ start state" : "Make start";
  sb.setAttribute("aria-pressed", s.id === startId ? "true" : "false");
  sb.disabled = s.id === startId;
  sb.addEventListener("click", function () {
    startId = s.id; afterEdit(s.name + " is now the start state.");
  });
  var ab = document.createElement("button");
  ab.className = "btn sm"; ab.type = "button";
  ab.textContent = accept[s.id] ? "✓ accepting" : "Accepting";
  ab.setAttribute("aria-pressed", accept[s.id] ? "true" : "false");
  ab.addEventListener("click", function () {
    if (accept[s.id]) delete accept[s.id]; else accept[s.id] = true;
    afterEdit(s.name + (accept[s.id] ? " is now an accepting state." : " is no longer accepting."));
  });
  r2.appendChild(sb); r2.appendChild(ab);
  body.appendChild(r2);

  var h = document.createElement("h2"); h.textContent = "outgoing"; h.style.marginTop = "4px";
  body.appendChild(h);
  body.appendChild(transList(s.id, null));

  var r3 = document.createElement("div"); r3.className = "row";
  var db = document.createElement("button");
  db.className = "btn sm danger"; db.type = "button"; db.textContent = "Delete state";
  db.disabled = states.length <= 1;
  db.addEventListener("click", function () { deleteState(s.id); });
  r3.appendChild(db);
  body.appendChild(r3);
}
function transList(from, to) {
  var ul = document.createElement("ul"); ul.className = "tlist";
  var rows = outOf(from).filter(function (t) { return to === null || t.to === to; });
  if (!rows.length) {
    var li = document.createElement("li"); li.className = "none";
    li.textContent = "no arrows out — drag the + handle";
    ul.appendChild(li); return ul;
  }
  rows.forEach(function (t) {
    var li = document.createElement("li");
    var sp = document.createElement("span");
    sp.textContent = nameOf(t.from) + " ──" + t.sym + "──▶ " + nameOf(t.to);
    var x = document.createElement("button");
    x.className = "x"; x.type = "button"; x.textContent = "✕";
    x.setAttribute("aria-label", "Remove transition on " + t.sym + " from " + nameOf(t.from));
    x.addEventListener("click", function () {
      trans = trans.filter(function (o) { return o !== t; });
      afterEdit("Removed " + nameOf(t.from) + " ──" + t.sym + "──▶ " + nameOf(t.to) + ".");
    });
    li.appendChild(sp); li.appendChild(x); ul.appendChild(li);
  });
  return ul;
}
function afterEdit(msg) {
  stopSim(); markEdited();
  if (!byId(startId)) startId = states.length ? states[0].id : null;
  testResults = {};
  build(); resetSim(); renderTests();
  if (msg) banner("info", msg, "the machine changed — press Run to test it again");
}
function deleteState(id) {
  if (states.length <= 1) {
    banner("err", "A machine needs at least one state.",
      "add another state first, then this one can go");
    return;
  }
  var nm = nameOf(id);
  states = states.filter(function (s) { return s.id !== id; });
  trans = trans.filter(function (t) { return t.from !== id && t.to !== id; });
  delete accept[id];
  if (sel === id) sel = null;
  if (startId === id) startId = states[0].id;
  afterEdit("Deleted state " + nm + " and every arrow touching it.");
}

/* ── node interaction ───────────────────────────────────────────────────── */
function toSvg(clientX, clientY) {
  var m = board.getScreenCTM();
  if (!m) return { x: 0, y: 0 };
  var p = board.createSVGPoint(); p.x = clientX; p.y = clientY;
  p = p.matrixTransform(m.inverse());
  return { x: p.x, y: p.y };
}
function setPos(s, sx, sy) {
  var aw = Math.max(1, W - 2*PADX), ah = Math.max(1, H - 2*PADY);
  s.x = Math.min(1, Math.max(0, (sx - PADX) / aw));
  s.y = Math.min(1, Math.max(0, (sy - PADY) / ah));
}
function wireNode(s, g, porthit) {
  g.addEventListener("pointerdown", function (e) {
    if (e.button != null && e.button !== 0) return;
    if (e.target === porthit) return;
    e.preventDefault();
    g.focus({ preventScroll: true });
    select(s.id);
    var p = toSvg(e.clientX, e.clientY);
    drag = { id: s.id, dx: p.x - px(s), dy: p.y - py(s), moved: false, pid: e.pointerId };
    g.classList.add("dragging");
    try { g.setPointerCapture(e.pointerId); } catch (err) {}
  });
  g.addEventListener("pointermove", function (e) {
    if (!drag || drag.id !== s.id) return;
    var p = toSvg(e.clientX, e.clientY);
    setPos(s, p.x - drag.dx, p.y - drag.dy);
    drag.moved = true; geom();
  });
  var end = function (e) {
    if (drag && drag.id === s.id) {
      g.classList.remove("dragging");
      try { g.releasePointerCapture(drag.pid); } catch (err) {}
      drag = null;
    }
    if (connect && connect.pointer && e) finishConnectAt(e.clientX, e.clientY);
  };
  g.addEventListener("pointerup", end);
  g.addEventListener("pointercancel", end);

  porthit.addEventListener("pointerdown", function (e) {
    e.preventDefault(); e.stopPropagation();
    select(s.id);
    startConnect(s.id, true);
    try { board.setPointerCapture(e.pointerId); } catch (err) {}
    connect.pid = e.pointerId;
    moveGhost(e.clientX, e.clientY);
  });

  g.addEventListener("keydown", function (e) {
    var k = e.key, stepd = e.shiftKey ? 24 : 8;
    if (k === "ArrowLeft" || k === "ArrowRight" || k === "ArrowUp" || k === "ArrowDown") {
      e.preventDefault();
      var x = px(s), y = py(s);
      if (k === "ArrowLeft") x -= stepd; if (k === "ArrowRight") x += stepd;
      if (k === "ArrowUp") y -= stepd; if (k === "ArrowDown") y += stepd;
      setPos(s, x, y); geom(); return;
    }
    if (k === "Enter" || k === " ") {
      e.preventDefault();
      if (connect) { completeConnect(s.id); return; }
      select(s.id); return;
    }
    if (k === "Escape") { cancelConnect(); return; }
    if (k === "s" || k === "S") { e.preventDefault(); startId = s.id; afterEdit(s.name + " is now the start state."); focusNode(s.id); return; }
    if (k === "a" || k === "A") {
      e.preventDefault();
      if (accept[s.id]) delete accept[s.id]; else accept[s.id] = true;
      afterEdit(s.name + (accept[s.id] ? " is now an accepting state." : " is no longer accepting."));
      focusNode(s.id); return;
    }
    if (k === "c" || k === "C") { e.preventDefault(); select(s.id); startConnect(s.id, false); return; }
    if (k === "Delete" || k === "Backspace") { e.preventDefault(); deleteState(s.id); return; }
  });
  g.addEventListener("focus", function () { if (!drag) select(s.id); });
}
function focusNode(id) {
  var N = nodeEls.get(id); if (N) N.g.focus({ preventScroll: true });
}
function select(id) {
  sel = id; selEdge = null; paintSel();
  renderPanel();
}

/* ── connecting ─────────────────────────────────────────────────────────── */
function startConnect(from, pointer) {
  cancelConnect();
  connect = { from: from, pointer: !!pointer };
  ghost = el("path", {}, "ghost"); glayer.appendChild(ghost);
  var s = byId(from);
  moveGhostTo(px(s), py(s) - R - 10);
  if (!pointer) banner("info", "Drawing an arrow from " + nameOf(from) + ".",
    "tab or click to the target state and press Enter — Esc cancels");
}
function moveGhost(clientX, clientY) {
  var p = toSvg(clientX, clientY); moveGhostTo(p.x, p.y);
}
function moveGhostTo(x, y) {
  if (!ghost || !connect) return;
  var s = byId(connect.from); if (!s) return;
  var ax = px(s), ay = py(s);
  var dx = x - ax, dy = y - ay, len = Math.sqrt(dx*dx + dy*dy) || 1;
  var sx = ax + dx/len * R, sy = ay + dy/len * R;
  ghost.setAttribute("d", "M " + sx + " " + sy + " L " + x + " " + y);
}
function cancelConnect() {
  if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost);
  ghost = null; connect = null;
}
function finishConnectAt(clientX, clientY) {
  var target = null;
  var e = document.elementFromPoint(clientX, clientY);
  var g = e && e.closest ? e.closest(".node") : null;
  if (g) target = g.getAttribute("data-id");
  if (target) completeConnect(target);
  else { cancelConnect(); banner("hint", "Arrow cancelled — drop the + handle on a state circle.", ""); }
}
function completeConnect(to) {
  if (!connect) return;
  var from = connect.from;
  cancelConnect();
  pending = { from: from, to: to };
  var gg = edgeGeom({ from: from, to: to, syms: [] });
  var pt = gg ? { x: gg.lx, y: gg.ly } : { x: px(byId(from)), y: py(byId(from)) - 50 };
  openPop(pt.x, pt.y, from, to);
}
function openPop(sx, sy, from, to) {
  var pop = $("pop");
  $("popph").textContent = nameOf(from) + " → " + nameOf(to) + " on";
  $("symin").value = "";
  pop.hidden = false;
  var m = board.getScreenCTM(); var rect = canvas.getBoundingClientRect();
  var cx = sx, cy = sy;
  if (m) {
    var p = board.createSVGPoint(); p.x = sx; p.y = sy;
    p = p.matrixTransform(m);
    cx = p.x - rect.left; cy = p.y - rect.top;
  }
  var pw = pop.offsetWidth || 200, ph2 = pop.offsetHeight || 40;
  cx = Math.min(rect.width - pw/2 - 6, Math.max(pw/2 + 6, cx));
  cy = Math.min(rect.height - ph2/2 - 6, Math.max(ph2/2 + 6, cy));
  pop.style.left = cx + "px"; pop.style.top = cy + "px";
  $("symin").focus();
}
function closePop() { $("pop").hidden = true; pending = null; }
function commitPop() {
  if (!pending) { closePop(); return; }
  var raw = $("symin").value.trim();
  var from = pending.from, to = pending.to;
  if (!raw) { closePop(); banner("hint", "No characters typed — no arrow added.", ""); return; }
  var parts = /[,\s]/.test(raw) ? raw.split(/[,\s]+/) : raw.split("");
  var addOk = [], tooLong = [], clash = [];
  parts.forEach(function (p) {
    if (!p) return;
    if (p.length > 1) { tooLong.push(p); return; }
    if (findT(from, p)) { clash.push(p); return; }
    if (addOk.indexOf(p) < 0) addOk.push(p);
  });
  addOk.forEach(function (p) { trans.push({ from: from, to: to, sym: p }); });
  closePop();
  if (tooLong.length) {
    banner("err", "One character per arrow label.",
      "“" + tooLong.join("”, “") + "” " + (tooLong.length > 1 ? "are" : "is") + " longer than one character — try 0,1 or ab");
    if (addOk.length) { testResults = {}; stopSim(); markEdited(); build(); resetSim(); renderTests(); }
    return;
  }
  if (clash.length) {
    var dests = clash.map(function (p) { return nameOf(findT(from, p).to); });
    stopSim(); testResults = {}; if (addOk.length) markEdited();
    build(); resetSim(); renderTests();
    banner("err", nameOf(from) + " already has an arrow for “" + clash.join("”, “") + "”.",
      "a deterministic machine allows exactly one route per character — " +
      nameOf(from) + " ──" + clash[0] + "──▶ " + dests[0] + " already exists, so delete it first");
    return;
  }
  afterEdit("Added " + nameOf(from) + " ──" + addOk.join(",") + "──▶ " + nameOf(to) + ".");
}

/* ── simulation ─────────────────────────────────────────────────────────── */
var sim = { input: "", i: 0, cur: null, started: false, done: false, running: false,
            verdict: "IDLE", timer: 0, raf: 0, steps: [], busy: false, stuck: false };

function stopSim() {
  sim.running = false;
  if (sim.timer) { clearTimeout(sim.timer); sim.timer = 0; }
  if (sim.raf) { cancelAnimationFrame(sim.raf); sim.raf = 0; }
  clear(tlayer); sim.busy = false;
  liveEdge(null); setRunBtn(false);
}
function resetSim() {
  stopSim();
  sim.input = $("inp").value;
  sim.i = 0; sim.cur = startId; sim.started = false; sim.done = false;
  sim.stuck = false; sim.verdict = "IDLE"; sim.steps = [];
  pill("IDLE"); paintStates(); paintTape(); renderTrace();
  $("logmeta").textContent = "idle";
}
function pill(v) {
  var p = $("pill"); p.textContent = v; p.setAttribute("data-s", v);
}
function setRunBtn(on) {
  $("runlbl").textContent = on ? "Pause" : "Run";
  $("run").classList.toggle("primary", !on);
  $("run").firstElementChild.textContent = on ? "❚❚" : "▶";
}
function paintTape() {
  var cells = $("cells"); cells.textContent = "";
  var s = sim.input;
  if (!s.length) {
    var e = document.createElement("span");
    e.className = "cell eps" + (sim.done ? "" : " todo");
    e.textContent = "ε (empty)";
    cells.appendChild(e);
  } else {
    for (var i=0;i<s.length;i++) {
      var c = document.createElement("span");
      var cls = "cell ";
      if (i < sim.i) cls += "done";
      else if (i === sim.i && sim.started && !sim.done) cls += "head";
      else if (i === sim.i && sim.done && sim.verdict === "REJECTED" && sim.stuck) cls += "bad";
      else cls += "todo";
      c.className = cls;
      c.textContent = s.charAt(i) === " " ? "␣" : s.charAt(i);
      cells.appendChild(c);
    }
  }
  $("readn").textContent = sim.i;
  $("totn").textContent = s.length;
  updateCur();
}
function updateCur() {
  $("curname").textContent = sim.cur ? nameOf(sim.cur) : "—";
}
function beginRun() {
  resetSim();
  if (!startId) { banner("err", "This machine has no start state.", "click a circle and press “Make start”"); return false; }
  sim.started = true; sim.stuck = false;
  pill("RUNNING"); paintStates(); paintTape();
  sim.steps = [{ k: "hd", t: "", d: "start  →  " + nameOf(startId) + "   input “" + (sim.input || "ε") + "”" }];
  renderTrace();
  /* never hide the banner mid-run: doing so collapses the strip and shunts the
     whole board up and down on every Run */
  banner("info", "Reading “" + (sim.input || "ε") + "” from " + nameOf(startId) + "…",
    "the token walks one arrow per character; the verdict lands when the input runs out");
  return true;
}
function stepGap() { return mqReduce.matches ? 200 : 135; }
function glideMs() { return mqReduce.matches ? 0 : 330; }

function doStep(after) {
  if (sim.busy) { after(false); return; }
  if (sim.done) { after(false); return; }
  if (!sim.started) { if (!beginRun()) { after(false); return; } }
  if (sim.i >= sim.input.length) { finish(!!accept[sim.cur], null); after(false); return; }
  var sym = sim.input.charAt(sim.i);
  var t = findT(sim.cur, sym);
  if (!t) { finish(false, sym); after(false); return; }
  var k = ekey(t.from, t.to);
  liveEdge(k);
  var E = edgeEls.get(k);
  sim.busy = true;
  glide(E ? E.wire : null, function () {
    sim.busy = false;
    sim.i++; sim.cur = t.to;
    sim.steps.push({ k: "", t: String(sim.i),
      d: nameOf(t.from) + " ──" + sym + "──▶ " + nameOf(t.to) +
         "   (" + (sim.input.length - sim.i) + " left)" });
    paintStates(); paintTape(); renderTrace();
    $("logmeta").textContent = (sim.running ? "running" : "paused") + " · " + sim.i + " step" + (sim.i === 1 ? "" : "s");
    if (sim.i >= sim.input.length) { finish(!!accept[sim.cur], null); after(false); }
    else after(true);
  });
}
function glide(pathEl, done) {
  if (!pathEl || mqReduce.matches || glideMs() === 0) { sim.timer = setTimeout(done, 150); return; }
  var total = 0;
  try { total = pathEl.getTotalLength(); } catch (e) { total = 0; }
  if (!total) { sim.timer = setTimeout(done, 150); return; }
  var tok = el("circle", { r: 6.5, cx: 0, cy: 0 }, "token");
  tlayer.appendChild(tok);
  var t0 = performance.now(), dur = glideMs();
  var frame = function (now) {
    var u = Math.min(1, (now - t0) / dur);
    var e = u < .5 ? 2*u*u : -1 + (4 - 2*u)*u;   /* ease in-out */
    var p = pathEl.getPointAtLength(e * total);
    tok.setAttribute("cx", p.x); tok.setAttribute("cy", p.y);
    if (u < 1) { sim.raf = requestAnimationFrame(frame); }
    else { if (tok.parentNode) tok.parentNode.removeChild(tok); sim.raf = 0; done(); }
  };
  sim.raf = requestAnimationFrame(frame);
}
function finish(ok, badSym) {
  sim.done = true; sim.running = false; sim.started = true;
  if (sim.timer) { clearTimeout(sim.timer); sim.timer = 0; }
  setRunBtn(false); liveEdge(null);
  sim.stuck = badSym !== null && badSym !== undefined;
  sim.verdict = ok ? "ACCEPTED" : "REJECTED";
  pill(sim.verdict); paintStates(); paintTape();
  var where = nameOf(sim.cur);
  if (sim.stuck) {
    sim.steps.push({ k: "no", t: "✗", d: "stuck in " + where + " — no arrow for “" + badSym + "”" });
    banner("err", "REJECTED — the machine got stuck.",
      "in state " + where + " there is no arrow labelled “" + badSym + "” (position " + (sim.i + 1) +
      " of " + sim.input.length + "). Alphabet is {" + alphabet().join(",") + "}.");
  } else if (ok) {
    sim.steps.push({ k: "ok", t: "✓", d: "read all " + sim.input.length + " characters, halted in " + where + " — accepting" });
    banner("ok", "ACCEPTED — “" + (sim.input || "ε") + "” is in this machine's language.",
      "read " + sim.input.length + " characters and halted in " + where + ", which is an accepting state");
  } else {
    sim.steps.push({ k: "no", t: "✗", d: "read all " + sim.input.length + " characters, halted in " + where + " — not accepting" });
    banner("err", "REJECTED — “" + (sim.input || "ε") + "” is not in this machine's language.",
      "the input ran out in state " + where + ", which is not an accepting state");
  }
  renderTrace();
  $("logmeta").textContent = sim.verdict.toLowerCase() + " · " + sim.i + " step" + (sim.i === 1 ? "" : "s");
  if (tests.indexOf(sim.input) >= 0) {
    testResults[sim.input] = sim.stuck ? "STUCK" : ok ? "ACCEPT" : "REJECT";
    renderTests();
  }
}
function tick() {
  if (!sim.running) return;
  doStep(function (more) {
    if (more && sim.running) sim.timer = setTimeout(tick, stepGap());
    else if (!more) { sim.running = false; setRunBtn(false); }
  });
}
function renderTrace() {
  var log = $("log");
  log.textContent = "";
  if (!sim.steps.length) {
    var d = document.createElement("div"); d.className = "empty";
    d.textContent = "press “Run” to feed the string into the machine…";
    log.appendChild(d); return;
  }
  sim.steps.forEach(function (s) {
    var ln = document.createElement("div");
    ln.className = "ln" + (s.k ? " " + s.k : "");
    var a = document.createElement("span"); a.className = "t"; a.textContent = s.t;
    var b = document.createElement("span"); b.className = "d"; b.textContent = s.d;
    ln.appendChild(a); ln.appendChild(b); log.appendChild(ln);
  });
  log.scrollTop = log.scrollHeight;
}

/* headless run, used by the test list */
function evaluate(str) {
  if (!startId) return { v: "STUCK", why: "no start state" };
  var cur = startId;
  for (var i=0;i<str.length;i++) {
    var t = findT(cur, str.charAt(i));
    if (!t) return { v: "STUCK", why: "no arrow for “" + str.charAt(i) + "” in " + nameOf(cur) };
    cur = t.to;
  }
  return { v: accept[cur] ? "ACCEPT" : "REJECT", why: "halted in " + nameOf(cur) };
}

/* ── test strings ───────────────────────────────────────────────────────── */
function renderTests() {
  var box = $("tests"); box.textContent = "";
  if (!tests.length) {
    var p = document.createElement("p"); p.className = "mono";
    p.textContent = "no test strings yet";
    box.appendChild(p); return;
  }
  tests.forEach(function (str) {
    var b = document.createElement("button");
    b.type = "button"; b.className = "tst";
    if (str === $("inp").value) b.classList.add("on");
    var s = document.createElement("span"); s.className = "s";
    s.textContent = str.length ? str : "ε (empty)";
    var g = document.createElement("span"); g.className = "bg";
    var r = testResults[str];
    g.textContent = r ? r : "run";
    if (r) g.setAttribute("data-v", r);
    b.setAttribute("aria-label", "Run the string " + (str.length ? str : "empty") +
      (r ? ", last result " + r : ""));
    b.appendChild(s); b.appendChild(g);
    b.addEventListener("click", function () {
      $("inp").value = str;
      renderTests();
      startRunning();
    });
    box.appendChild(b);
  });
}
function startRunning() {
  if (!beginRun()) return;
  sim.running = true; setRunBtn(true);
  tick();
}

/* ── wiring ─────────────────────────────────────────────────────────────── */
$("runf").addEventListener("submit", function (e) {
  e.preventDefault();
  if (sim.running) { sim.running = false; if (sim.timer) clearTimeout(sim.timer); setRunBtn(false); pill("PAUSED"); return; }
  if (sim.done) { startRunning(); return; }
  if (sim.started) {
    sim.running = true; setRunBtn(true); pill("RUNNING");
    /* if a token is still gliding, its own callback will carry the chain on —
       calling tick() here would hit the busy guard and stop the run dead */
    if (!sim.busy) tick();
    return;
  }
  startRunning();
});
$("step").addEventListener("click", function () {
  if (sim.running) { sim.running = false; if (sim.timer) clearTimeout(sim.timer); setRunBtn(false); }
  if (sim.done) resetSim();
  doStep(function () { if (sim.started && !sim.done) pill("PAUSED"); });
});
$("rewind").addEventListener("click", function () {
  resetSim();
  banner("hint", "Rewound to the start state — nothing read yet.", "");
});
$("inp").addEventListener("input", function () {
  resetSim(); renderTests();
});
$("preset").addEventListener("change", function () { loadPreset(this.value); });
$("reload").addEventListener("click", function () { loadPreset(presetKey); });
$("addstate").addEventListener("click", function () {
  seq++;
  var used = {}; states.forEach(function (s) { used[s.name] = 1; });
  var k = states.length; while (used["q" + k]) k++;
  var n = "q" + k;
  var spot = freeSpot();
  var st = { id: "s" + seq, name: n, x: spot.x, y: spot.y };
  states.push(st);
  if (!startId) startId = st.id;
  sel = st.id; selEdge = null;
  stopSim(); markEdited(); testResults = {}; build(); resetSim(); renderTests();
  banner("info", "Added state " + n + ".", "drag its + handle onto another circle to give it an arrow");
  focusNode(st.id);
});
function freeSpot() {
  var best = { x: .5, y: .5 }, bestD = -1;
  for (var gx = 0; gx <= 4; gx++) for (var gy = 0; gy <= 3; gy++) {
    var cx = gx / 4, cy = gy / 3, m = 9;
    states.forEach(function (s) {
      var d = Math.hypot((s.x - cx) * (W - 2*PADX), (s.y - cy) * (H - 2*PADY));
      if (d < m) m = d;
    });
    if (m > bestD) { bestD = m; best = { x: cx, y: cy }; }
  }
  return best;
}
$("testall").addEventListener("click", function () {
  testResults = {};
  var acc = 0;
  tests.forEach(function (s) { var r = evaluate(s); testResults[s] = r.v; if (r.v === "ACCEPT") acc++; });
  renderTests();
  banner("info", "Tested " + tests.length + " strings against this machine.",
    acc + " accepted · " + (tests.length - acc) + " not accepted");
});
$("addstrf").addEventListener("submit", function (e) {
  e.preventDefault();
  var v = $("newstr").value;
  if (tests.indexOf(v) < 0) tests.push(v);
  $("newstr").value = "";
  $("inp").value = v;
  resetSim(); renderTests();
  banner("hint", "Added “" + (v || "ε") + "” to the test list.", "press Run to walk it through");
});
$("popok").addEventListener("click", commitPop);
$("symin").addEventListener("keydown", function (e) {
  if (e.key === "Enter") { e.preventDefault(); commitPop(); }
  else if (e.key === "Escape") { e.preventDefault(); closePop(); cancelConnect(); }
});

board.addEventListener("pointermove", function (e) {
  if (connect && connect.pointer) moveGhost(e.clientX, e.clientY);
});
board.addEventListener("pointerup", function (e) {
  if (connect && connect.pointer) {
    try { board.releasePointerCapture(connect.pid); } catch (err) {}
    finishConnectAt(e.clientX, e.clientY);
  }
});
board.addEventListener("pointerdown", function (e) {
  if (e.target === board || e.target.id === "bgrect") {
    if (connect && !connect.pointer) { cancelConnect(); banner("hint", "Arrow cancelled.", ""); }
    sel = null; selEdge = null; paintSel(); renderPanel();
    if (!$("pop").hidden) { closePop(); cancelConnect(); }
  }
});
addEventListener("keydown", function (e) {
  if (e.key === "Escape") {
    if (!$("pop").hidden) { closePop(); }
    if (connect) { cancelConnect(); banner("hint", "Arrow cancelled.", ""); }
  }
});

/* ── responsive sizing ──────────────────────────────────────────────────── */
function resize() {
  var r = canvas.getBoundingClientRect();
  W = Math.max(240, Math.round(r.width));
  H = Math.max(220, Math.round(r.height));
  R    = W >= 780 ? 34 : W < 430 ? 27 : 30;
  PADX = (W < 430 ? 46 : 60) + R;
  PADY = (H < 340 ? 30 : 42) + R;
  board.setAttribute("viewBox", "0 0 " + W + " " + H);
  geom();
  if (!$("pop").hidden) { closePop(); cancelConnect(); }
}
var ro = new ResizeObserver(resize);

/* ── boot ───────────────────────────────────────────────────────────────── */
resize();
loadPreset("even1");
resize();
ro.observe(canvas);

/* open already doing its thing: walk the preloaded string once, unhelped */
setTimeout(function () {
  if (!sim.started && !sim.running && !connect && $("pop").hidden) startRunning();
}, 420);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong