Back to the library

Dwell & Transition

A finite state machine executes itself: a single token occupies IDLE, dwells, then travels labeled edges through ARMED and RUNNING, faults into RETRY, loops back, and finally commits to SETTLED. Every traversal is ease-in-out — the token has mass, accelerating off one state and decelerating into the next — while arrivals use a short ease-out overshoot so landing reads as a settle rather than a stop; edges draw themselves under the token and stay as a faint trace, so the walk accumulates into a visible history. The dwell (~800ms) is deliberately longer than the move (620ms), which is what makes the rhythm feel intentional instead of restless. Reach for it when you need to explain a workflow, retry policy, or lifecycle — anywhere a static box-and-arrow diagram fails to convey that only one thing is true at a time.

conceptstate-machinegraphdiagramsvgsystems

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dwell &amp; Transition — a state machine walking its own graph</title>
<style>
  /* ============================================================
     DWELL & TRANSITION
     A finite state machine executing. A token occupies a state,
     dwells there, then travels an edge to the next. Edges light
     as they are taken and stay as a faint trace of the walk.

     WHEN PASTING INTO A REAL PROJECT:
       keep  →  .stage and everything inside it (the <svg> + the
                readout), plus every rule from "SCENE" down.
       drop  →  the page-level `body`/`.card`/`.card-head` chrome
                and the `--T` loop wrapper if you would rather
                drive state changes from your own app instead of
                a 12-second cycle. Every keyframe is a percentage
                of --T, so retiming the whole piece is one value.
     ============================================================ */

  :root {
    --ivory:    #FAF9F5;
    --ink:      #141413;
    --clay:     #D97757;
    --oat:      #E3DACC;
    --olive:    #788C5D;
    --rust:     #B04A3F;
    --gray-500: #87867F;
    --gray-300: #D1CFC5;

    --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    --serif: ui-serif, Georgia, "Times New Roman", serif;

    --ease-out:    cubic-bezier(0.23, 1, 0.32, 1);
    --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

    /* one knob for the whole loop */
    --T: 12s;

    /* page gutters, continuous: nothing on a phone frame, full on a desktop
       one, no breakpoint in between. */
    --pad-y: clamp(0px, 6vh - 18px, 24px);
    --pad-x: clamp(0px, 4vw - 14px, 20px);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    background: var(--ivory);
    color: var(--ink);
    font-family: var(--mono);
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: var(--pad-y) var(--pad-x);
    -webkit-font-smoothing: antialiased;
  }

  /* ── card: 4:3 where there is room, otherwise as tall as the
        frame allows. A hard `aspect-ratio` overflows any viewport
        shorter than 3/4 of its width — which is every embedded
        frame — so the ratio becomes a ceiling, not a demand. ── */
  .card {
    width: min(660px, 100%);
    height: min(495px, 100vh - 2 * var(--pad-y));
    background: var(--ivory);
    border: 1px solid var(--oat);
    border-radius: 14px;
    padding: clamp(8px, 3vh, 20px) clamp(10px, 2.4vw, 22px) clamp(6px, 2.6vh, 18px);
    display: flex;
    flex-direction: column;
    gap: clamp(6px, 1.6vh, 12px);
  }

  .card-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
    padding-bottom: clamp(6px, 1.4vh, 12px);
    border-bottom: 1px solid var(--oat);
  }
  .eyebrow {
    font-size: 11px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--gray-500);
  }
  .eyebrow strong { color: var(--ink); font-weight: 500; }

  /* ── SCENE ─────────────────────────────────────── */
  .stage {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: clamp(6px, 1.5vh, 10px);
    animation: scene-cycle var(--T) linear infinite;
    will-change: opacity, filter;
  }
  .stage svg { flex: 1; min-height: 0; width: 100%; display: block; overflow: visible; }

  /* the loop: fade up, run, hold the settled state, dissolve, replay.
     blur bridges the dissolve so the two cycles read as one object. */
  @keyframes scene-cycle {
    0%     { opacity: 0; filter: blur(4px); }
    4%     { opacity: 1; filter: blur(0);   }
    91.67% { opacity: 1; filter: blur(0);   }
    97.5%  { opacity: 0; filter: blur(4px); }
    100%   { opacity: 0; filter: blur(4px); }
  }

  /* ── graph chrome ──────────────────────────────── */
  .edge-base { stroke: var(--gray-300); stroke-width: 1.5; fill: none; }
  .head-base { fill: var(--gray-300); }

  .edge-lit {
    stroke: var(--accent, var(--clay));
    stroke-width: 2;
    fill: none;
    stroke-linecap: round;
    stroke-dasharray: 100;
    stroke-dashoffset: 100;
    opacity: 0;
  }
  .head-lit { fill: var(--accent, var(--clay)); opacity: 0; }

  /* SVG type is in user units: rendered px = size x (rendered svg width /
     viewBox width), and `meet` means that scale is the SMALLER of the width
     and height fits. Across the four stages that scale runs 0.65 – 1.10, so
     these are sized off the smallest of them to clear the 11px floor
     (17.5 x 0.655 = 11.5). Letter-spacing comes down to pay for the width. */
  .edge-label {
    font-family: var(--mono);
    font-size: 17.5px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    fill: var(--gray-500);
    opacity: 0;
  }

  .ring  { fill: var(--ivory); stroke: var(--gray-300); stroke-width: 1.5; }
  .n-label {
    font-family: var(--mono);
    font-size: 18px;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    fill: var(--gray-500);
    text-anchor: middle;
  }
  .lit { opacity: 0; }
  .lit .glow  { fill: var(--accent, var(--clay)); opacity: 0.11; }
  .lit .halo  { fill: none; stroke: var(--accent, var(--clay)); stroke-width: 1.75; }
  .lit .n-label { fill: var(--accent, var(--clay)); }

  .n-d, .e3, .e4 { --accent: var(--rust);  }
  .n-e, .e5      { --accent: var(--olive); }

  /* ── the token ─────────────────────────────────── */
  .token { transform-box: fill-box; transform-origin: center; }
  .token-halo { fill: var(--clay); opacity: 0.13; }
  .token-core { fill: var(--clay); }

  /* dwell, then move. the move is ease-in-out — the token has mass;
     it accelerates off the state and decelerates into the next one.
     the arrival overshoot is ease-out, so landing reads as a settle. */
  @keyframes travel {
    0%     { transform: translate(58px,165px) scale(0.86); animation-timing-function: var(--ease-out); }
    4%     { transform: translate(58px,165px) scale(1); }
    12.5%  { transform: translate(58px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
    17.67% { transform: translate(170px,84px) scale(1.24); animation-timing-function: var(--ease-out); }
    19.2%  { transform: translate(170px,84px) scale(1); }
    24.17% { transform: translate(170px,84px) scale(1); animation-timing-function: var(--ease-in-out); }
    29.33% { transform: translate(282px,165px) scale(1.24); animation-timing-function: var(--ease-out); }
    30.9%  { transform: translate(282px,165px) scale(1); }
    35.83% { transform: translate(282px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
    41%    { transform: translate(170px,246px) scale(1.24); animation-timing-function: var(--ease-out); }
    42.5%  { transform: translate(170px,246px) scale(1); }
    47.5%  { transform: translate(170px,246px) scale(1); animation-timing-function: var(--ease-in-out); }
    52.67% { transform: translate(170px,84px) scale(1.24); animation-timing-function: var(--ease-out); }
    54.2%  { transform: translate(170px,84px) scale(1); }
    59.17% { transform: translate(170px,84px) scale(1); animation-timing-function: var(--ease-in-out); }
    64.33% { transform: translate(282px,165px) scale(1.24); animation-timing-function: var(--ease-out); }
    65.9%  { transform: translate(282px,165px) scale(1); }
    70.83% { transform: translate(282px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
    76%    { transform: translate(394px,165px) scale(1.3); animation-timing-function: var(--ease-out); }
    77.8%  { transform: translate(394px,165px) scale(1); }
    100%   { transform: translate(394px,165px) scale(1); }
  }
  .token { animation: travel var(--T) linear infinite; }

  /* ── edges drawing themselves under the token ──── */
  .e1 .edge-lit { animation: sweep-e1 var(--T) var(--ease-in-out) infinite; }
  .e2 .edge-lit { animation: sweep-e2 var(--T) var(--ease-in-out) infinite; }
  .e3 .edge-lit { animation: sweep-e3 var(--T) var(--ease-in-out) infinite; }
  .e4 .edge-lit { animation: sweep-e4 var(--T) var(--ease-in-out) infinite; }
  .e5 .edge-lit { animation: sweep-e5 var(--T) var(--ease-in-out) infinite; }

  @keyframes sweep-e1 {
    0%, 12.5%  { stroke-dashoffset: 100; opacity: 0.9; }
    17.67%     { stroke-dashoffset: 0;   opacity: 0.9; }
    21%        { opacity: 0.42; }
    100%       { stroke-dashoffset: 0; opacity: 0.42; }
  }
  @keyframes sweep-e2 {
    0%, 24.17% { stroke-dashoffset: 100; opacity: 0.9; }
    29.33%     { stroke-dashoffset: 0;   opacity: 0.9; }
    33%        { opacity: 0.42; }
    59.17%     { opacity: 0.42; }
    61.5%      { opacity: 0.9; }
    64.33%     { opacity: 0.9; }
    67.5%      { opacity: 0.42; }
    100%       { stroke-dashoffset: 0; opacity: 0.42; }
  }
  @keyframes sweep-e3 {
    0%, 35.83% { stroke-dashoffset: 100; opacity: 0.9; }
    41%        { stroke-dashoffset: 0;   opacity: 0.9; }
    44%        { opacity: 0.42; }
    100%       { stroke-dashoffset: 0; opacity: 0.42; }
  }
  @keyframes sweep-e4 {
    0%, 47.5%  { stroke-dashoffset: 100; opacity: 0.9; }
    52.67%     { stroke-dashoffset: 0;   opacity: 0.9; }
    55.5%      { opacity: 0.42; }
    100%       { stroke-dashoffset: 0; opacity: 0.42; }
  }
  @keyframes sweep-e5 {
    0%, 70.83% { stroke-dashoffset: 100; opacity: 0.95; }
    76%        { stroke-dashoffset: 0;   opacity: 0.95; }
    100%       { stroke-dashoffset: 0; opacity: 0.95; }
  }

  /* arrowheads + edge names share the reveal — the transition
     names itself only once the machine has actually taken it. */
  .e1 .head-lit, .e1 .edge-label { animation: mark-e1 var(--T) var(--ease-out) infinite; }
  .e2 .head-lit, .e2 .edge-label { animation: mark-e2 var(--T) var(--ease-out) infinite; }
  .e3 .head-lit, .e3 .edge-label { animation: mark-e3 var(--T) var(--ease-out) infinite; }
  .e4 .head-lit, .e4 .edge-label { animation: mark-e4 var(--T) var(--ease-out) infinite; }
  .e5 .head-lit, .e5 .edge-label { animation: mark-e5 var(--T) var(--ease-out) infinite; }

  @keyframes mark-e1 {
    0%, 15%   { opacity: 0; }
    17.67%    { opacity: 1; }
    21%       { opacity: 0.55; }
    100%      { opacity: 0.55; }
  }
  @keyframes mark-e2 {
    0%, 27%   { opacity: 0; }
    29.33%    { opacity: 1; }
    33%       { opacity: 0.55; }
    59.17%    { opacity: 0.55; }
    61.5%     { opacity: 1; }
    64.33%    { opacity: 1; }
    67.5%     { opacity: 0.55; }
    100%      { opacity: 0.55; }
  }
  @keyframes mark-e3 {
    0%, 38.5% { opacity: 0; }
    41%       { opacity: 1; }
    44%       { opacity: 0.55; }
    100%      { opacity: 0.55; }
  }
  @keyframes mark-e4 {
    0%, 50%   { opacity: 0; }
    52.67%    { opacity: 1; }
    55.5%     { opacity: 0.55; }
    100%      { opacity: 0.55; }
  }
  @keyframes mark-e5 {
    0%, 73.5% { opacity: 0; }
    76%       { opacity: 1; }
    100%      { opacity: 1; }
  }

  /* ── states lighting as they become active ─────── */
  .n-a .lit, .r-a { animation: lit-a var(--T) var(--ease-out) infinite; }
  .n-b .lit, .r-b { animation: lit-b var(--T) var(--ease-out) infinite; }
  .n-c .lit, .r-c { animation: lit-c var(--T) var(--ease-out) infinite; }
  .n-d .lit, .r-d { animation: lit-d var(--T) var(--ease-out) infinite; }
  .n-e .lit, .r-e { animation: lit-e var(--T) var(--ease-out) infinite; }

  @keyframes lit-a {
    0%, 12.5% { opacity: 1; }
    14.6%     { opacity: 0; }
    100%      { opacity: 0; }
  }
  @keyframes lit-b {
    0%, 16%   { opacity: 0; }
    17.67%    { opacity: 1; }
    24.17%    { opacity: 1; }
    26.3%     { opacity: 0; }
    51%       { opacity: 0; }
    52.67%    { opacity: 1; }
    59.17%    { opacity: 1; }
    61.3%     { opacity: 0; }
    100%      { opacity: 0; }
  }
  @keyframes lit-c {
    0%, 27.7% { opacity: 0; }
    29.33%    { opacity: 1; }
    35.83%    { opacity: 1; }
    37.9%     { opacity: 0; }
    62.7%     { opacity: 0; }
    64.33%    { opacity: 1; }
    70.83%    { opacity: 1; }
    72.9%     { opacity: 0; }
    100%      { opacity: 0; }
  }
  @keyframes lit-d {
    0%, 39.4% { opacity: 0; }
    41%       { opacity: 1; }
    47.5%     { opacity: 1; }
    49.6%     { opacity: 0; }
    100%      { opacity: 0; }
  }
  @keyframes lit-e {
    0%, 74.4% { opacity: 0; }
    76%       { opacity: 1; }
    100%      { opacity: 1; }
  }

  /* ── readout ───────────────────────────────────── */
  .readout {
    display: flex;
    align-items: baseline;
    gap: 12px;
    padding-top: clamp(6px, 1.4vh, 10px);
    border-top: 1px solid var(--oat);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
  }
  .r-key { color: var(--gray-500); }
  .r-val { position: relative; flex: 1; height: 1.2em; }
  .r-val i {
    position: absolute;
    inset: 0;
    font-style: normal;
    opacity: 0;
    color: var(--clay);
  }
  .r-val i.r-d { color: var(--rust); }
  .r-val i.r-e { color: var(--olive); }
  .r-note { color: var(--gray-300); font-size: 11px; letter-spacing: 0.1em; }

  /* ── small stages ──────────────────────────────────────────
        Sizing above is continuous, so this is a supplement, not the
        fix: on a phone-width frame the head strip is page chrome the
        comment at the top already marks as droppable, and dropping it
        (plus the trailing note) hands ~30px of height back to the
        graph, which is what sets the type scale. ───────────────── */
  @media (max-width: 420px) {
    .card-head { display: none; }
    .r-note    { display: none; }
  }

  /* ── reduced motion: no travel, no dissolve, no redraw. The claim
        is unchanged — the walk is already traced, the token rests on
        SETTLED, and nothing ever moves or resets. What replaces the
        freeze is memory rather than motion: a slow recall pulse runs
        the traced path in order (arm → run → fault → retry → commit)
        purely in opacity, ending on the settled node and its token
        breathing together. One 5.4s cycle, no travel, no rewind —
        the lit trace never drops to zero, so there is nothing to
        redraw and the loop has no visible seam. ─────────────── */
  @media (prefers-reduced-motion: reduce) {
    .stage, .token, .edge-lit, .head-lit, .edge-label, .lit, .r-val i {
      animation: none !important;
    }
    .stage { opacity: 1; filter: none; }
    .token { transform: translate(394px, 165px); }
    .edge-lit  { stroke-dashoffset: 0; opacity: 0.42; }
    .e5 .edge-lit { opacity: 0.95; }
    .head-lit, .edge-label { opacity: 0.55; }
    .e5 .head-lit, .e5 .edge-label { opacity: 1; }
    .lit { opacity: 0; }
    .n-e .lit { opacity: 1; }
    .r-val i.r-e { opacity: 1; }

    /* the recall pulse. --TR is the reduced-only knob; the stagger
       walks it along the edges in the order the machine took them. */
    .stage { --TR: 5.4s; }

    @keyframes edge-recall-reduced {
      0%, 100% { opacity: 0.3; }
      45%      { opacity: 0.58; }
    }
    @keyframes commit-recall-reduced {
      0%, 100% { opacity: 0.72; }
      45%      { opacity: 1; }
    }
    @keyframes mark-recall-reduced {
      0%, 100% { opacity: 0.42; }
      45%      { opacity: 0.68; }
    }
    @keyframes settled-breathe-reduced {
      0%, 100% { opacity: 0.62; }
      45%      { opacity: 1; }
    }
    @keyframes token-breathe-reduced {
      0%, 100% { opacity: 0.08; }
      45%      { opacity: 0.22; }
    }

    .e1 .edge-lit, .e2 .edge-lit, .e3 .edge-lit, .e4 .edge-lit {
      animation: edge-recall-reduced var(--TR, 5.4s) ease-in-out infinite !important;
    }
    .e1 .head-lit, .e1 .edge-label,
    .e2 .head-lit, .e2 .edge-label,
    .e3 .head-lit, .e3 .edge-label,
    .e4 .head-lit, .e4 .edge-label {
      animation: mark-recall-reduced var(--TR, 5.4s) ease-in-out infinite !important;
    }
    .e2 .edge-lit, .e2 .head-lit, .e2 .edge-label { animation-delay: 0.36s !important; }
    .e3 .edge-lit, .e3 .head-lit, .e3 .edge-label { animation-delay: 0.72s !important; }
    .e4 .edge-lit, .e4 .head-lit, .e4 .edge-label { animation-delay: 1.08s !important; }

    .e5 .edge-lit {
      animation: commit-recall-reduced var(--TR, 5.4s) ease-in-out 1.44s infinite !important;
    }
    .e5 .head-lit, .e5 .edge-label {
      animation: commit-recall-reduced var(--TR, 5.4s) ease-in-out 1.44s infinite !important;
    }

    /* the destination answers the pulse a beat later */
    .n-e .lit {
      animation: settled-breathe-reduced var(--TR, 5.4s) ease-in-out 1.8s infinite !important;
    }
    .token .token-halo {
      animation: token-breathe-reduced var(--TR, 5.4s) ease-in-out 1.8s infinite;
    }
    .r-val i.r-e {
      animation: settled-breathe-reduced var(--TR, 5.4s) ease-in-out 1.8s infinite !important;
    }
  }
</style>
</head>
<body>

<div class="card">
  <div class="card-head">
    <span class="eyebrow"><strong>Finite state machine</strong> — dwell, then transition</span>
    <span class="eyebrow">1 token</span>
  </div>

  <!-- KEEP FROM HERE ────────────────────────────────────────── -->
  <div class="stage">
    <svg viewBox="0 0 440 330" xmlns="http://www.w3.org/2000/svg" role="img"
         aria-label="A state machine: a token moves from Idle to Armed to Running, faults into Retry, retries back to Armed, runs again, and commits to Settled.">

      <!-- edges -->
      <g class="edges">
        <g class="e1">
          <line class="edge-base" x1="84.7" y1="145.7" x2="143.3" y2="103.3"/>
          <path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(143.3,103.3) rotate(-35.9)"/>
          <line class="edge-lit" pathLength="100" x1="84.7" y1="145.7" x2="143.3" y2="103.3"/>
          <path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(143.3,103.3) rotate(-35.9)"/>
          <text class="edge-label" x="106" y="113" text-anchor="middle">arm</text>
        </g>
        <g class="e2">
          <line class="edge-base" x1="196.7" y1="103.3" x2="255.3" y2="145.7"/>
          <path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(255.3,145.7) rotate(35.9)"/>
          <line class="edge-lit" pathLength="100" x1="196.7" y1="103.3" x2="255.3" y2="145.7"/>
          <path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(255.3,145.7) rotate(35.9)"/>
          <text class="edge-label" x="236" y="113" text-anchor="middle">run</text>
        </g>
        <g class="e3">
          <line class="edge-base" x1="255.3" y1="184.3" x2="196.7" y2="226.7"/>
          <path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(196.7,226.7) rotate(144.1)"/>
          <line class="edge-lit" pathLength="100" x1="255.3" y1="184.3" x2="196.7" y2="226.7"/>
          <path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(196.7,226.7) rotate(144.1)"/>
          <text class="edge-label" x="247" y="232" text-anchor="middle">fault</text>
        </g>
        <g class="e4">
          <line class="edge-base" x1="170" y1="213" x2="170" y2="117"/>
          <path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(170,117) rotate(-90)"/>
          <line class="edge-lit" pathLength="100" x1="170" y1="213" x2="170" y2="117"/>
          <path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(170,117) rotate(-90)"/>
          <text class="edge-label" x="160" y="169" text-anchor="end">retry</text>
        </g>
        <g class="e5">
          <line class="edge-base" x1="315" y1="165" x2="361" y2="165"/>
          <path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(361,165)"/>
          <line class="edge-lit" pathLength="100" x1="315" y1="165" x2="361" y2="165"/>
          <path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(361,165)"/>
          <text class="edge-label" x="338" y="145" text-anchor="middle">commit</text>
        </g>
      </g>

      <!-- states -->
      <g class="nodes">
        <g class="node n-a" transform="translate(58,165)">
          <circle class="ring" r="26"/>
          <text class="n-label" y="45">idle</text>
          <g class="lit">
            <circle class="glow" r="26"/>
            <circle class="halo" r="26"/>
            <text class="n-label" y="45">idle</text>
          </g>
        </g>
        <g class="node n-b" transform="translate(170,84)">
          <circle class="ring" r="26"/>
          <text class="n-label" y="-38">armed</text>
          <g class="lit">
            <circle class="glow" r="26"/>
            <circle class="halo" r="26"/>
            <text class="n-label" y="-38">armed</text>
          </g>
        </g>
        <g class="node n-c" transform="translate(282,165)">
          <circle class="ring" r="26"/>
          <text class="n-label" y="45">running</text>
          <g class="lit">
            <circle class="glow" r="26"/>
            <circle class="halo" r="26"/>
            <text class="n-label" y="45">running</text>
          </g>
        </g>
        <g class="node n-d" transform="translate(170,246)">
          <circle class="ring" r="26"/>
          <text class="n-label" y="46">retry</text>
          <g class="lit">
            <circle class="glow" r="26"/>
            <circle class="halo" r="26"/>
            <text class="n-label" y="46">retry</text>
          </g>
        </g>
        <g class="node n-e" transform="translate(394,165)">
          <circle class="ring" r="26"/>
          <circle class="ring" r="20.5"/>
          <text class="n-label" y="45">settled</text>
          <g class="lit">
            <circle class="glow" r="26"/>
            <circle class="halo" r="26"/>
            <circle class="halo" r="20.5"/>
            <text class="n-label" y="45">settled</text>
          </g>
        </g>
      </g>

      <!-- the token: exactly one, always somewhere -->
      <g class="token">
        <circle class="token-halo" r="16"/>
        <circle class="token-core" r="7"/>
      </g>
    </svg>

    <div class="readout">
      <span class="r-key">state</span>
      <span class="r-val">
        <i class="r-a">idle</i>
        <i class="r-b">armed</i>
        <i class="r-c">running</i>
        <i class="r-d">retry</i>
        <i class="r-e">settled</i>
      </span>
      <span class="r-note">deterministic</span>
    </div>
  </div>
  <!-- ────────────────────────────────────────── KEEP TO HERE -->
</div>

</body>
</html>