Theme
Design your own
On this page (14)

§Example

§Data platform integration

Fan-in and fan-out spread over the node sides; the bar spans the platform’s two columns.

§Step by step

§Sources, platform, consumers

Three steps: what plugs in, the platform, what plugs out.

§Interactive

A click activates a box or an arrow: what it touches stays lit, everything else steps back. The buttons outside the figure drive the same activation - data-diagram-for + data-diagram-action="next|prev|clear" - and the <output data-diagram-for> names what is active. Keyboard: Tab to a box, Enter, arrows, Escape.

§Topology, interactive

data-interactive on the figure; nothing else to write - the controls and the output are declared in markup, no script.

§Inspect and edit

Select, inspect, edit: a click hands the selection to a Property Grid in a closable Panel - df$.shadcn.diagram.properties() reads the box or the arrow as JSON, propertySchema() gives the grid its options, setProperties() writes an edit back and the diagram redraws. The × closes the panel, the Inspector button toggles it.

§Topology, inspect and edit

Edit a name, the tone, the shape - or an arrow's label, line and ends: the diagram follows.

§Flow

Every edge flows at once: data-flow (ms a pass), data-flow-tokens (tokens on the wire) and data-flow-delay (when this wire starts) hand the stream from one connection to the next. Tokens never run under reduced motion.

§Topology, flowing

Each edge carries data-flow, two tokens and a start offset 700 ms after the previous edge.

§Delta

§The mainframe leaves

A changed protocol is a changed edge (data-change on the li) - the ledger names both ends.

Rendered by the diagram component - plain HTML nodes, an edge list, wires drawn from the measured layout; colors from the theme tokens, so every theme and dark mode apply. Visual grammar adapted from diagram-design by Cathryn Lavery (MIT).

§CSS view file

The diagram stylesheet - every type on this page is a preset in it.

/* -- Diagram -----------------------------------------------------------------
   Diagrams from plain HTML: nodes on a CSS grid (or a free / radial canvas),
   wires drawn by diagram.js into an SVG layer behind them. Paper + ink + one
   focal accent; every color comes from the theme tokens (dark mode follows),
   the accent and the change colors are component-local literals.
   Visual grammar adapted from diagram-design by Cathryn Lavery (MIT). */
@layer components {
  /* the diagram measures its own boxes: border-box everywhere inside, zero specificity */
  :where(.diagram, .diagram *, .diagram *::before, .diagram *::after) {
    box-sizing: border-box;
  }
  .diagram {
    --diagram-paper: var(--card);
    --diagram-ink: var(--foreground);
    --diagram-muted: var(--muted-foreground);
    --diagram-rule: var(--border);
    --diagram-soft: color-mix(in oklab, var(--foreground) 4%, var(--card));
    --diagram-line: color-mix(in oklab, var(--foreground) 72%, var(--card));
    --diagram-wire: color-mix(in oklab, var(--foreground) 52%, var(--card));
    /* every color is the theme's: the accent and the change colors are chart colors */
    --diagram-accent: var(--chart-2);
    --diagram-accent-tint: color-mix(in oklab, var(--diagram-accent) 10%, var(--diagram-paper));
    --diagram-link: var(--chart-4);
    --diagram-added: var(--chart-3);
    --diagram-removed: var(--destructive);
    --diagram-changed: var(--chart-1);
    --diagram-gap-x: 4rem;
    --diagram-gap-y: 2.5rem;
    --diagram-col-min: 7.5rem;
    --diagram-node-w: 10.5rem;
    /* steps reveal: future elements are hidden, each step fades in, then holds */
    --diagram-element-ms: 900ms; /* one element after the other */
    --diagram-step-ms: 800ms;    /* how long an element takes to appear */
    --diagram-hold: 400ms;       /* the rest after a step's last element */
    --diagram-future-opacity: 0;
    --diagram-dim-opacity: 0.25;
    display: grid;
    gap: 0.875rem;
    min-width: 0;
    margin: 0;
    overflow-x: auto;
    color: var(--diagram-ink);
    font-family: var(--font-mono);
    container-type: inline-size;
    letter-spacing: var(--tracking-normal);
  }
  /* on a presentation slide the diagram takes the slide's palette: paper and
     ink of a paper slide (the deck's accent too), of an ink slide on one */
  [data-slide] .diagram {
    --diagram-paper: var(--presentation-paper, var(--card));
    --diagram-ink: var(--presentation-paper-foreground, var(--foreground));
    --diagram-accent: var(--presentation-accent, var(--chart-2));
  }
  [data-slide][data-theme="ink"] .diagram {
    --diagram-paper: var(--presentation-ink, var(--card));
    --diagram-ink: var(--presentation-ink-foreground, var(--foreground));
    --diagram-accent: var(--chart-2);
  }
  [data-slide] .diagram {
    --diagram-muted: color-mix(in oklab, var(--diagram-ink) 62%, var(--diagram-paper));
    --diagram-rule: color-mix(in oklab, var(--diagram-ink) 22%, var(--diagram-paper));
    --diagram-soft: color-mix(in oklab, var(--diagram-ink) 4%, var(--diagram-paper));
    --diagram-line: color-mix(in oklab, var(--diagram-ink) 72%, var(--diagram-paper));
    --diagram-wire: color-mix(in oklab, var(--diagram-ink) 52%, var(--diagram-paper));
  }
  /* -- caption -------------------------------------------------------------- */
  .diagram-caption {
    display: grid;
    gap: 0.25rem;
    position: sticky;
    left: 0;
  }
  .diagram-eyebrow {
    color: var(--diagram-muted);
    font: 500 0.625rem/1.4 var(--font-mono);
    letter-spacing: 0.18em;
    text-transform: uppercase;
  }
  .diagram-title {
    font: 400 1.5rem/1.15 var(--font-mono);
    letter-spacing: -0.015em;
    text-wrap: balance;
  }
  .diagram-dek {
    max-width: 44rem;
    color: var(--diagram-muted);
    font-size: 0.8125rem;
    line-height: 1.55;
    text-wrap: pretty;
  }
  /* -- canvas: a grid by default --------------------------------------------- */
  .diagram-canvas {
    /* the horizontal gap and the column minimum give in a narrow figure
       (cqi = the figure's width): --diagram-gap-x / --diagram-col-min are the
       preferred sizes, 3rem / 5rem the floors - a four- or five-column
       diagram fits a docs column with even whitespace on both sides instead
       of running into (and past) its right border; a column never gets
       narrower than its content */
    --_diagram-gap-x: clamp(min(3rem, var(--diagram-gap-x)), 6cqi, var(--diagram-gap-x));
    --_diagram-col-min: clamp(min(5rem, var(--diagram-col-min)), 12cqi, var(--diagram-col-min));
    position: relative;
    isolation: isolate;
    display: grid;
    grid-template-columns: repeat(var(--cols, 4), minmax(var(--_diagram-col-min), 1fr));
    grid-auto-rows: minmax(3.25rem, auto);
    gap: var(--diagram-gap-y) var(--_diagram-gap-x);
    min-width: min-content;
    padding: 2rem;
    /* extra room on a side for absolutely placed content (free / wardley
       nodes, timeline ticks): diagram.ts sets it when something comes closer
       than its CLEARANCE to the border - padding cannot move absolute boxes */
    --_inset-top: 0px;
    --_inset-right: 0px;
    --_inset-bottom: 0px;
    --_inset-left: 0px;
    background-color: var(--diagram-paper);
    background-image: radial-gradient(color-mix(in oklab, var(--diagram-ink) 9%, transparent) 1px, transparent 1.2px);
    background-size: 1rem 1rem;
    border: 1px solid var(--diagram-rule);
    border-radius: var(--radius-lg);
    /* grid placement - each element's own --col/--row win; nothing inherits */
    & * {
      --col: initial;
      --row: initial;
      --span: initial;
      --rspan: initial;
      --x: initial;
      --y: initial;
      --cols: initial;
    }
    & > *,
    & .diagram-group > * {
      grid-column: var(--col, auto) / span var(--span, 1);
      grid-row: var(--row, auto) / span var(--rspan, 1);
    }
  }
  .diagram[data-paper="plain"] .diagram-canvas {
    background-image: none;
  }
  .diagram[data-frame="none"] .diagram-canvas {
    border: 0;
    padding-inline: 0;
    background-color: transparent;
  }
  /* -- nodes ------------------------------------------------------------------ */
  .diagram-node {
    position: relative;
    z-index: 2;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 0.125rem;
    min-width: 0;
    min-block-size: 3.25rem;
    inline-size: min(100%, var(--w, var(--diagram-node-w)));
    align-self: center;
    justify-self: center;
    padding: 0.5rem 0.75rem;
    background: var(--diagram-paper);
    border: 1px solid var(--diagram-line);
    border-radius: var(--radius-md);
    text-align: start;
    transition:
      opacity var(--diagram-step-ms) ease,
      outline-color var(--diagram-step-ms) ease;
    &[data-tone="accent"] {
      background: var(--diagram-accent-tint);
      border-color: var(--diagram-accent);
    }
    &[data-tone="link"] {
      border-color: var(--diagram-link);
    }
    &[data-tone="muted"] {
      background: var(--diagram-soft);
      border-color: color-mix(in oklab, var(--diagram-ink) 40%, var(--diagram-paper));
    }
    &[data-tone="external"] {
      background: var(--diagram-soft);
      border-style: dashed;
      border-color: color-mix(in oklab, var(--diagram-ink) 38%, var(--diagram-paper));
    }
    &[data-tone="warn"] {
      border-color: var(--diagram-changed);
      border-style: dashed;
      background: color-mix(in oklab, var(--diagram-changed) 8%, var(--diagram-paper));
    }
    &[data-tone="ok"] {
      border-color: var(--diagram-added);
      background: color-mix(in oklab, var(--diagram-added) 8%, var(--diagram-paper));
    }
    &[data-tone="danger"] {
      border-color: var(--diagram-removed);
      background: color-mix(in oklab, var(--diagram-removed) 7%, var(--diagram-paper));
    }
    &[data-tone="bronze"] {
      border-color: var(--chart-5);
      background: color-mix(in oklab, var(--chart-5) 10%, var(--diagram-paper));
    }
    &[data-tone="silver"] {
      border-color: var(--chart-3);
      background: color-mix(in oklab, var(--chart-3) 12%, var(--diagram-paper));
    }
    &[data-tone="gold"] {
      border-color: var(--chart-1);
      background: color-mix(in oklab, var(--chart-1) 14%, var(--diagram-paper));
    }
    /* a corner badge: "×3" replicas, "4 in" fan-in, a version */
    &[data-badge]::after {
      content: attr(data-badge);
      position: absolute;
      top: -0.5rem;
      right: 0.5rem;
      padding: 0 0.3rem;
      background: var(--diagram-paper);
      border: 1px solid currentColor;
      border-radius: var(--radius-sm);
      color: var(--diagram-muted);
      font: 500 0.5625rem/1.4 var(--font-mono);
      letter-spacing: 0.06em;
    }
  }
  .diagram-node-eyebrow {
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.35 var(--font-mono);
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }
  .diagram-node[data-tone="accent"] > .diagram-node-eyebrow {
    color: color-mix(in oklab, var(--diagram-accent) 80%, var(--diagram-ink));
  }
  .diagram-node-name {
    font-size: 0.8125rem;
    font-weight: 600;
    line-height: 1.25;
    text-wrap: balance;
  }
  .diagram-node-meta {
    color: var(--diagram-muted);
    font: 0.6875rem/1.35 var(--font-mono);
    text-wrap: pretty;
  }
  /* compartments: entity / table / class fields and operations */
  .diagram-node:has(> .diagram-node-fields, > .diagram-node-ops) {
    justify-content: flex-start;
    gap: 0;
    padding: 0;
    overflow: visible;
    & > .diagram-node-eyebrow,
    & > .diagram-node-name,
    & > .diagram-node-meta {
      padding-inline: 0.75rem;
    }
    & > :first-child {
      padding-block-start: 0.5rem;
    }
    & > .diagram-node-name:has(+ .diagram-node-fields, + .diagram-node-ops),
    & > .diagram-node-meta:has(+ .diagram-node-fields, + .diagram-node-ops) {
      padding-block-end: 0.5rem;
    }
  }
  .diagram-node-fields,
  .diagram-node-ops,
  .diagram-node-chips {
    margin: 0;
    padding: 0.375rem 0;
    list-style: none;
    border-top: 1px solid color-mix(in oklab, var(--diagram-ink) 18%, transparent);
    font-size: 0.75rem;
    & > li {
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: 0 0.5rem;
      overflow-wrap: anywhere;
      padding: 0.1875rem 0.75rem;
      min-block-size: 1.375rem;
      box-sizing: border-box;
    }
    & > li > span:nth-child(2) {
      margin-inline-start: auto;
      color: var(--diagram-muted);
      font: 0.625rem/1.4 var(--font-mono);
    }
  }
  .diagram-node-ops > li {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
  }
  .diagram-node-fields > li[data-key]::before {
    content: attr(data-key);
    flex: none;
    padding: 0 0.25rem;
    border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent);
    border-radius: var(--radius-sm);
    color: var(--diagram-muted);
    font: 600 0.5rem/1.5 var(--font-mono);
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }
  .diagram-node-fields > li[data-key="pk"]::before {
    border-color: var(--diagram-accent);
    color: var(--diagram-accent);
  }
  .diagram-node-fields > li.diagram-node-more {
    color: var(--diagram-muted);
    font: 0.625rem/1.4 var(--font-mono);
  }
  .diagram-node-chips {
    display: grid;
    gap: 0.25rem;
    padding: 0.375rem 0.5rem 0.5rem;
    border-top: 0;
    & > li {
      padding: 0.125rem 0.5rem;
      background: var(--diagram-soft);
      border: 1px solid color-mix(in oklab, var(--diagram-ink) 22%, transparent);
      border-radius: calc(var(--radius-sm) + 1px);
      font-size: 0.6875rem;
    }
  }
  .diagram-node:not(:has(> .diagram-node-fields)) > .diagram-node-chips {
    padding-inline: 0;
    padding-block-end: 0;
  }
  .diagram-node[data-abstract] > .diagram-node-name {
    font-style: italic;
  }
  .diagram-node[data-shape="class"],
  .diagram-node[data-shape="entity"] {
    & > .diagram-node-name,
    & > .diagram-node-eyebrow {
      text-align: center;
    }
  }
  /* -- shapes ----------------------------------------------------------------- */
  .diagram-node[data-shape="pill"] {
    border-radius: calc(var(--radius) * 4);
    align-items: center;
    text-align: center;
    padding-inline: 1.25rem;
  }
  .diagram-node[data-shape="diamond"] {
    align-items: center;
    justify-content: center;
    min-block-size: 5.25rem;
    padding: 0.75rem 1.5rem;
    background: transparent;
    border: 0;
    text-align: center;
    &::before,
    &::after {
      content: "";
      position: absolute;
      z-index: -1;
      inset: 0;
      clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
      background: var(--diagram-line);
    }
    &::after {
      inset: 1.5px 2.5px;
      background: var(--diagram-paper);
    }
    &[data-tone="accent"]::before {
      background: var(--diagram-accent);
    }
    &[data-tone="accent"]::after {
      background: var(--diagram-accent-tint);
    }
  }
  .diagram-node[data-shape="store"] {
    padding-block-start: 1.375rem;
    border-radius: 50% / 0.6rem;
    &::before {
      content: "";
      position: absolute;
      inset: -1px -1px auto;
      height: 1.2rem;
      border: 1px solid currentColor;
      border-color: inherit;
      border-radius: 50%;
    }
  }
  .diagram-node:is([data-shape="start"], [data-shape="end"], [data-shape="dot"], [data-shape="ghost"]) {
    min-block-size: 0;
    padding: 0;
    justify-self: center;
  }
  .diagram-node[data-shape="start"] {
    inline-size: 1rem;
    block-size: 1rem;
    border-radius: 50%;
    background: var(--diagram-ink);
    border-color: var(--diagram-ink);
  }
  .diagram-node[data-shape="end"] {
    inline-size: 1.25rem;
    block-size: 1.25rem;
    border-radius: 50%;
    border: 1.5px solid var(--diagram-ink);
    background: var(--diagram-ink);
    box-shadow: inset 0 0 0 3px var(--diagram-paper);
  }
  .diagram-node:is([data-shape="start"], [data-shape="end"]) > * {
    position: absolute;
    top: calc(100% + 0.25rem);
    left: 50%;
    translate: -50% 0;
    white-space: nowrap;
  }
  .diagram-node[data-shape="dot"] {
    inline-size: 0.75rem;
    block-size: 0.75rem;
    border-radius: 50%;
    background: var(--diagram-paper);
    border: 1.25px solid var(--diagram-ink);
    &[data-tone="accent"] {
      background: var(--diagram-accent);
      border-color: var(--diagram-accent);
    }
    & > .diagram-node-name,
    & > .diagram-node-eyebrow {
      position: absolute;
      bottom: calc(100% + 0.3rem);
      left: 50%;
      translate: -50% 0;
      white-space: nowrap;
      font-size: 0.75rem;
    }
    & > .diagram-node-meta {
      position: absolute;
      top: calc(100% + 0.3rem);
      left: 50%;
      translate: -50% 0;
      white-space: nowrap;
      font-size: 0.625rem;
    }
  }
  /* a dot's name on the side its wires leave free (set by diagram.js, or authored) */
  .diagram-node[data-shape="dot"][data-label-side="below"] {
    & > .diagram-node-name { bottom: auto; top: calc(100% + 0.3rem); }
    & > .diagram-node-meta { top: calc(100% + 1.45rem); }
  }
  .diagram-node[data-shape="dot"]:is([data-label-side="left"], [data-label-side="right"]) {
    & > .diagram-node-name { bottom: auto; top: 50%; translate: 0 -50%; }
    & > .diagram-node-meta { top: calc(50% + 0.65rem); translate: 0 0; }
  }
  .diagram-node[data-shape="dot"][data-label-side="right"] > * { left: calc(100% + 0.45rem); }
  .diagram-node[data-shape="dot"][data-label-side="left"] > * { left: auto; right: calc(100% + 0.45rem); }
  .diagram-node[data-shape="ghost"] {
    inline-size: 1px;
    block-size: 1px;
    border: 0;
    background: none;
    overflow: hidden;
  }
  .diagram-node[data-shape="bar"] {
    --diagram-node-w: 100%;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    min-block-size: 2.5rem;
    background: var(--diagram-soft);
  }
  .diagram-node[data-shape="note"] {
    border: 0;
    background: none;
    color: var(--diagram-muted);
    font: italic 0.9375rem/1.4 var(--font-mono);
  }
  .diagram-node[data-shape="circle"] {
    aspect-ratio: 1;
    border-radius: 50%;
    align-items: center;
    text-align: center;
    justify-self: center;
    inline-size: 7.5rem;
  }
  /* -- groups: zones, lanes, columns, containment ---------------------------- */
  .diagram-group {
    position: relative;
    display: grid;
    align-content: start;
    gap: 0.75rem;
    min-width: 0;
    align-self: stretch;
    padding: 1.5rem 1rem 1rem;
    background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent);
    border: 1px dashed color-mix(in oklab, var(--diagram-ink) 30%, transparent);
    border-radius: var(--radius-lg);
    transition: opacity var(--diagram-step-ms) ease;
    &[data-tone="accent"] {
      border-style: solid;
      border-color: var(--diagram-accent);
      background: color-mix(in oklab, var(--diagram-accent) 5%, transparent);
    }
    &[data-tone="solid"] {
      border-style: solid;
    }
  }
  /* a zone drawn behind nodes placed on the same cells reaches into the gaps */
  .diagram-group:not(:has(.diagram-node, .diagram-group)) {
    margin: calc(var(--diagram-gap-y) * -0.55) calc(var(--_diagram-gap-x) * -0.42);
  }
  .diagram-group-label {
    position: absolute;
    top: 0;
    left: 0.75rem;
    translate: 0 -50%;
    padding: 0 0.375rem;
    background: var(--diagram-paper);
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.5 var(--font-mono);
    letter-spacing: 0.16em;
    text-transform: uppercase;
    white-space: nowrap;
  }
  .diagram-group[data-tone="accent"] > .diagram-group-label {
    color: var(--diagram-accent);
  }
  .diagram-group-meta {
    padding: 0 0.3rem;
    border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent);
    border-radius: var(--radius-sm);
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.5 var(--font-mono);
  }
  .diagram-group[data-over] .diagram-group-meta {
    border-color: var(--diagram-accent);
    color: var(--diagram-accent);
  }
  /* swimlanes: full-width bands, the label in the first column */
  .diagram-group[data-shape="lane"] {
    margin: calc(var(--diagram-gap-y) * -0.5) 0;
    border: 0;
    border-block-start: 1px solid var(--diagram-rule);
    border-radius: 0;
    background: none;
    padding: 0;
    &:nth-of-type(even) {
      background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent);
    }
    & > .diagram-group-label {
      top: 50%;
      left: 0;
      translate: 0 -50%;
      background: none;
      white-space: normal;
      max-width: calc(var(--_diagram-col-min) - 1rem);
    }
  }
  /* kanban columns, nested containment: groups that hold their nodes */
  .diagram-group[data-shape="column"] {
    --diagram-node-w: 100%;
    padding: 0.75rem;
    border: 0;
    border-radius: var(--radius-md);
    background: color-mix(in oklab, var(--diagram-ink) 3%, transparent);
    & > .diagram-group-label {
      position: static;
      translate: none;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 0.5rem;
      padding: 0 0 0.5rem;
      background: none;
      border-bottom: 1px solid var(--diagram-rule);
      color: var(--diagram-ink);
      font: 600 0.8125rem/1.4 var(--font-mono);
      letter-spacing: 0;
      text-transform: none;
    }
    & > .diagram-node {
      align-self: stretch;
    }
  }
  /* -- kanban card states ------------------------------------------------------ */
  .diagram-node[data-status="blocked"] {
    border: 1px dashed var(--diagram-accent);
    background: var(--diagram-accent-tint);
    box-shadow: inset 3px 0 0 var(--diagram-accent);
  }
  .diagram-node[data-status="waiting"] {
    border-style: dashed;
    border-color: color-mix(in oklab, var(--diagram-ink) 28%, transparent);
    background: var(--diagram-soft);
  }
  .diagram-node[data-status="done"] {
    color: var(--diagram-muted);
    border-color: color-mix(in oklab, var(--diagram-ink) 25%, transparent);
    & > .diagram-node-name::before {
      content: "✓ ";
      color: var(--diagram-added);
    }
  }
  /* -- policy trace: rule outcomes, never color alone ------------------------- */
  .diagram-node[data-shape="status"] {
    flex-direction: row;
    align-items: center;
    justify-content: center;
    gap: 0.375rem;
    min-block-size: 2.25rem;
    justify-self: center;
    min-inline-size: 7.5rem;
    border-radius: var(--radius-sm);
    font: 600 0.625rem/1.3 var(--font-mono);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    &::before {
      font-size: 0.8125rem;
    }
    &[data-status="pass"] {
      border-color: var(--diagram-added);
      color: color-mix(in oklab, var(--diagram-added) 75%, var(--diagram-ink));
      &::before { content: "✓"; }
    }
    &[data-status="fail"] {
      border-color: var(--diagram-accent);
      background: var(--diagram-accent-tint);
      color: color-mix(in oklab, var(--diagram-accent) 80%, var(--diagram-ink));
      &::before { content: "×"; }
    }
    &[data-status="skip"] {
      border-style: dashed;
      color: var(--diagram-muted);
      &::before { content: "—"; }
    }
    &[data-status="unreached"] {
      border-style: dotted;
      border-color: color-mix(in oklab, var(--diagram-ink) 30%, transparent);
      background: var(--diagram-soft);
      color: var(--diagram-muted);
      &::before { content: "○"; }
    }
  }
  /* -- phases (chevron banner), cells, tags, rules, notes, axes, legend -------- */
  .diagram-phases {
    display: grid;
    grid-template-columns: subgrid;
    margin: 0 0 calc(var(--diagram-gap-y) * -0.4);
    padding: 0;
    list-style: none;
    align-self: start;
    & > li {
      grid-column: auto / span var(--span, 1);
      display: grid;
      place-items: center;
      min-block-size: 1.75rem;
      margin-inline-end: calc(var(--_diagram-gap-x) * -1 + 0.25rem);
      padding: 0 1.25rem;
      background: var(--diagram-soft);
      border: 0;
      clip-path: polygon(0 0, calc(100% - 0.75rem) 0, 100% 50%, calc(100% - 0.75rem) 100%, 0 100%, 0.75rem 50%);
      color: var(--diagram-muted);
      font: 500 0.5625rem/1.3 var(--font-mono);
      letter-spacing: 0.16em;
      text-transform: uppercase;
      text-align: center;
    }
    & > li:first-child {
      clip-path: polygon(0 0, calc(100% - 0.75rem) 0, 100% 50%, calc(100% - 0.75rem) 100%, 0 100%);
    }
    & > li:last-child {
      margin-inline-end: 0;
    }
    & > li[data-tone="accent"] {
      background: var(--diagram-accent-tint);
      color: var(--diagram-accent);
    }
  }
  .diagram-cell {
    align-self: center;
    min-width: 0;
    font-size: 0.75rem;
    line-height: 1.4;
    &[data-role="head"] {
      text-align: center;
      font-weight: 600;
      font-size: 0.8125rem;
    }
    &[data-role="label"] {
      color: var(--diagram-muted);
      font: 500 0.5625rem/1.4 var(--font-mono);
      letter-spacing: 0.14em;
      text-transform: uppercase;
    }
    &[data-role="mono"] {
      color: var(--diagram-muted);
      font: 0.6875rem/1.4 var(--font-mono);
      text-align: center;
    }
    &[data-role="center"] {
      text-align: center;
    }
  }
  .diagram-cell-eyebrow {
    display: block;
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.4 var(--font-mono);
    letter-spacing: 0.16em;
    text-transform: uppercase;
  }
  .diagram-tag {
    justify-self: center;
    align-self: start;
    padding: 0.0625rem 0.375rem;
    border: 1px dashed color-mix(in oklab, var(--diagram-accent) 70%, transparent);
    border-radius: var(--radius-sm);
    background: var(--diagram-accent-tint);
    color: var(--diagram-accent);
    font: 500 0.5625rem/1.5 var(--font-mono);
    letter-spacing: 0.1em;
    text-transform: uppercase;
  }
  .diagram-rule {
    position: relative;
    z-index: 3;
    grid-column: 1 / -1;
    align-self: end;
    margin-block-end: calc(var(--diagram-gap-y) * -0.5);
    border-top: 1.5px solid var(--diagram-accent);
    block-size: 0;
    & > span {
      position: absolute;
      right: 0;
      top: 0;
      translate: 0 -50%;
      padding: 0 0.375rem;
      background: var(--diagram-paper);
      color: var(--diagram-accent);
      font: 500 0.5625rem/1.5 var(--font-mono);
      letter-spacing: 0.16em;
      text-transform: uppercase;
    }
    &[data-tone="muted"] {
      border-top: 1px dashed var(--diagram-rule);
      & > span { color: var(--diagram-muted); }
    }
  }
  .diagram-note {
    color: var(--diagram-muted);
    font: italic 0.9375rem/1.45 var(--font-mono);
    text-wrap: pretty;
  }
  .diagram-band {
    z-index: 0;
    align-self: stretch;
    min-block-size: var(--band, 9rem);
    background:
      linear-gradient(var(--diagram-rule), var(--diagram-rule)) top / 100% 1px no-repeat,
      linear-gradient(var(--diagram-rule), var(--diagram-rule)) center / 100% 1px no-repeat,
      linear-gradient(var(--diagram-rule), var(--diagram-rule)) bottom / 100% 1px no-repeat;
  }
  .diagram-band-labels {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    align-self: stretch;
    margin-block: -0.4rem;
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.4 var(--font-mono);
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }
  .diagram-legend {
    position: sticky;
    left: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem 1.5rem;
    margin: 0;
    padding: 0.625rem 0 0;
    list-style: none;
    border-top: 1px solid var(--diagram-rule);
    color: var(--diagram-muted);
    font: 0.6875rem/1.4 var(--font-mono);
    & > li {
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }
    & > li::before {
      content: "";
      inline-size: 1rem;
      block-size: 0.625rem;
      border: 1px solid var(--diagram-line);
      border-radius: var(--radius-sm);
      background: var(--diagram-paper);
    }
    & > li[data-swatch="accent"]::before { border-color: var(--diagram-accent); background: var(--diagram-accent-tint); }
    & > li[data-swatch="external"]::before { border-style: dashed; background: var(--diagram-soft); }
    & > li[data-swatch="muted"]::before { background: var(--diagram-soft); }
    & > li[data-swatch="line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-wire); }
    & > li[data-swatch="dashed"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-style: dashed; border-color: var(--diagram-wire); }
    & > li[data-swatch="accent-line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-accent); }
    & > li[data-swatch="link-line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-link); }
    & > li[data-swatch="dot"]::before { inline-size: 0.625rem; border-radius: 50%; border-color: var(--diagram-ink); }
    & > li[data-swatch="warn"]::before { border-style: dashed; border-color: var(--diagram-changed); }
  }
  /* -- the edge list: readable without JS, a screen-reader list once wired ---- */
  .diagram-edges {
    position: sticky;
    left: 0;
    margin: 0;
    padding: 0 0 0 1.25rem;
    color: var(--diagram-muted);
    font-size: 0.75rem;
    line-height: 1.6;
    & > .diagram-edge::before {
      content: attr(data-from) " → " attr(data-to) " ";
      color: var(--diagram-ink);
      font-family: var(--font-mono);
      font-size: 0.6875rem;
    }
  }
  .diagram:has(.diagram-wires) > .diagram-edges {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  /* -- wires (drawn by diagram.js) --------------------------------------------- */
  .diagram-wires {
    position: absolute;
    z-index: 1;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
    pointer-events: none;
  }
  .diagram-wire {
    color: var(--diagram-wire);
    transition: opacity var(--diagram-step-ms) ease;
    &[data-tone="accent"] { color: var(--diagram-accent); }
    &[data-tone="link"] { color: var(--diagram-link); }
    &[data-tone="ink"] { color: var(--diagram-ink); }
    &[data-tone="ok"] { color: var(--diagram-added); }
    &[data-tone="danger"] { color: var(--diagram-removed); }
    &[data-line="dashed"] .diagram-wire-line { stroke-dasharray: 5 4; }
    &[data-line="dotted"] .diagram-wire-line { stroke-dasharray: 1.5 3.5; }
    &[data-line="thick"] .diagram-wire-line { stroke-width: 2.25; }
    &[data-lifeline] .diagram-wire-line {
      stroke-dasharray: 3 4;
      opacity: 0.55;
    }
    &[data-spine] .diagram-wire-line { stroke-width: 1.75; }
    &[data-bone] .diagram-wire-line { stroke-width: 1.25; }
  }
  .diagram-wire-line {
    fill: none;
    stroke: currentColor;
    stroke-width: 1.25;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .diagram-wire-head {
    stroke-width: 1.25;
    stroke-linejoin: round;
    &[data-paint="fill"] { fill: currentColor; stroke: none; }
    &[data-paint="stroke"] { fill: none; stroke: currentColor; }
    &[data-paint="hollow"] { fill: var(--diagram-paper); stroke: currentColor; }
  }
  .diagram-wire-labels {
    position: absolute;
    z-index: 3;
    inset: 0;
    pointer-events: none;
  }
  .diagram-wire-label {
    position: absolute;
    translate: -50% -50%;
    padding: 0 0.3rem;
    background: var(--diagram-paper);
    border-radius: var(--radius-sm);
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.45 var(--font-mono);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    white-space: nowrap;
    transition: opacity var(--diagram-step-ms) ease;
    &[data-tone="accent"] { color: var(--diagram-accent); }
    &[data-tone="link"] { color: var(--diagram-link); }
  }
  .diagram-wire-end {
    padding: 0 0.125rem;
    background: none;
    color: var(--diagram-ink);
    font-size: 0.625rem;
    letter-spacing: 0;
    text-transform: none;
  }
  .diagram-wire-self {
    translate: 0 -50%;
  }
  .diagram[data-type="sequence"] .diagram-wire-label {
    text-transform: none;
    letter-spacing: 0;
    font-size: 0.6875rem;
    color: var(--diagram-ink);
  }
  .diagram-token {
    position: absolute;
    top: 0;
    left: 0;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-radius: 50%;
    background: var(--diagram-wire);
    box-shadow: 0 0 0 2px var(--diagram-paper);
    offset-rotate: 0deg;
    &[data-tone="accent"] { background: var(--diagram-accent); }
    &[data-tone="link"] { background: var(--diagram-link); }
  }
  /* -- steps (data-steps / state playing · paused) ----------------------------- */
  .diagram [data-step-state="future"]:not(.diagram-edge) {
    opacity: var(--diagram-future-opacity);
  }
  .diagram :is(.diagram-wire, .diagram-wire-label)[data-step-state="future"] {
    opacity: 0;
  }
  .diagram :is(.diagram-node, .diagram-group, tr, .diagram-cell)[data-step-state="current"] {
    outline: 2px solid var(--diagram-accent);
    outline-offset: 3px;
    animation: diagram-enter var(--diagram-step-ms) cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--step-i, 0) * var(--diagram-element-ms)) both;
  }
  .diagram .diagram-node[data-shape="ghost"][data-step-state="current"] {
    outline: 0;
  }
  /* a wire appears on its beat - just after its later end - and solid ones draw in;
     one that already entered (data-step-entered, kept across a redraw) stays as it is */
  .diagram .diagram-wire[data-step-state="current"]:not([data-step-entered]) {
    animation: diagram-fade calc(var(--diagram-step-ms) * 0.4) ease calc(var(--step-i, 0) * var(--diagram-element-ms)) both;
  }
  .diagram .diagram-wire[data-step-state="current"]:not([data-step-entered]) .diagram-wire-line[pathLength] {
    stroke-dasharray: 1;
    animation: diagram-draw var(--diagram-step-ms) ease-out calc(var(--step-i, 0) * var(--diagram-element-ms)) both;
  }
  .diagram .diagram-wire[data-step-state="current"] {
    color: var(--diagram-accent);
  }
  @keyframes diagram-enter {
    from { opacity: 0; translate: 0 0.5rem; }
  }
  .diagram .diagram-wire-label[data-step-state="current"]:not([data-step-entered]) {
    animation: diagram-fade calc(var(--diagram-step-ms) * 0.6) ease calc(var(--step-i, 0) * var(--diagram-element-ms) + var(--diagram-step-ms) * 0.6) both;
  }
  @keyframes diagram-fade {
    from { opacity: 0; }
  }
  /* -- activation (state 'active', data-interactive) ------------------------------------ */
  .diagram :is(.diagram-node, .diagram-cell, .diagram-wire, .diagram-wire-label, tr)[data-active-state="dimmed"] {
    opacity: var(--diagram-dim-opacity);
  }
  .diagram .diagram-node[data-active-state="active"] {
    outline: 2px solid var(--diagram-accent);
    outline-offset: 3px;
    box-shadow: 0 0 0 7px color-mix(in oklab, var(--diagram-accent) 16%, transparent);
  }
  .diagram tr[data-active-state="active"] > * {
    background-color: color-mix(in oklab, var(--diagram-accent) 12%, var(--diagram-paper));
    box-shadow: inset 0 2px 0 var(--diagram-accent), inset 0 -2px 0 var(--diagram-accent);
  }
  .diagram[data-interactive] tr[data-node] {
    cursor: pointer;
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
  }
  .diagram .diagram-node[data-active-state="related"] {
    outline: 1px dashed color-mix(in oklab, var(--diagram-accent) 70%, transparent);
    outline-offset: 3px;
  }
  .diagram .diagram-wire:is([data-active-state="active"], [data-active-state="related"]) {
    color: var(--diagram-accent);
    & .diagram-wire-line { stroke-width: 2; }
  }
  .diagram .diagram-wire-label:is([data-active-state="active"], [data-active-state="related"]) {
    color: var(--diagram-accent);
  }
  .diagram .diagram-wire-label[data-active-state="active"] {
    box-shadow: inset 0 0 0 1px var(--diagram-accent);
  }
  .diagram[data-interactive] {
    & .diagram-node[role="button"] {
      cursor: pointer;
      transition: opacity 0.3s ease, outline-color 0.3s ease, box-shadow 0.3s ease;
    }
    & .diagram-node[role="button"]:hover {
      border-color: var(--diagram-accent);
    }
    & .diagram-node[role="button"]:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 3px;
    }
    & .diagram-wire-label[data-edge-ref] {
      pointer-events: auto;
      cursor: pointer;
    }
  }
  .diagram-wire-hit {
    fill: none;
    stroke: transparent;
    stroke-width: 14;
    pointer-events: stroke;
    cursor: pointer;
  }
  @keyframes diagram-draw {
    from { stroke-dashoffset: 1; }
    to { stroke-dashoffset: 0; }
  }
  .diagram-controls {
    position: sticky;
    left: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.375rem;
    & .btn svg {
      inline-size: 0.875rem;
      block-size: 0.875rem;
    }
  }
  .diagram-status {
    margin-inline-start: auto;
    color: var(--diagram-muted);
    font: 0.6875rem/1.4 var(--font-mono);
  }
  /* -- delta (data-delta): before · changes · after ---------------------------- */
  .diagram-delta {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1.25rem;
    min-width: 0;
  }
  @container (min-width: 96rem) {
    .diagram[data-delta=""] .diagram-delta,
    .diagram[data-delta="auto"] .diagram-delta {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
  }
  .diagram[data-delta="columns"] .diagram-delta {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
  .diagram[data-delta="columns"] .diagram-panel .diagram-canvas {
    --diagram-col-min: 5.5rem;
    --diagram-node-w: 8rem;
    --diagram-gap-x: 1.75rem;
    --diagram-gap-y: 1.75rem;
    padding: 1.25rem;
  }
  .diagram[data-delta]:has(> .diagram-delta) > .diagram-canvas {
    display: none;
  }
  .diagram-panel {
    display: grid;
    align-content: start;
    gap: 0.5rem;
    min-width: 0;
  }
  .diagram-panel-title {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0;
    color: var(--diagram-muted);
    font: 500 0.625rem/1.4 var(--font-mono);
    letter-spacing: 0.18em;
    text-transform: uppercase;
  }
  .diagram-panel[data-panel="changes"] .diagram-panel-title {
    color: var(--diagram-ink);
  }
  .diagram-panel-count {
    padding: 0 0.375rem;
    border-radius: calc(var(--radius) * 4);
    background: var(--diagram-accent);
    color: var(--diagram-paper);
    letter-spacing: 0;
  }
  .diagram-panel-body {
    min-width: 0;
    overflow-x: auto;
    overscroll-behavior-x: contain;
  }
  [data-delta-hidden] {
    display: none !important;
  }
  .diagram-panel[data-panel="changes"] {
    & .diagram-canvas :is(.diagram-node, .diagram-group, td, th, tr, .diagram-cell, li)[data-change] {
      outline: 2px solid var(--diagram-changed);
      outline-offset: 2px;
      &[data-change="added"] { outline-color: var(--diagram-added); }
      &[data-change="removed"] {
        outline-color: var(--diagram-removed);
        outline-style: dashed;
        opacity: 0.62;
      }
    }
    & .diagram-canvas :is(li, td, th)[data-change] {
      outline-offset: -2px;
    }
    & .diagram-wire[data-change="added"] { color: var(--diagram-added); }
    & .diagram-wire[data-change="removed"] { color: var(--diagram-removed); }
    & .diagram-wire[data-change="removed"] .diagram-wire-line { stroke-dasharray: 5 4; }
    & .diagram-wire:is([data-change="changed"], [data-change="rewired"]) { color: var(--diagram-changed); }
    & .diagram-wire[data-change="rewired"] .diagram-wire-line { stroke-dasharray: 8 3 2 3; }
    & .diagram-wire-label[data-change="added"] { color: var(--diagram-added); }
    & .diagram-wire-label[data-change="removed"] { color: var(--diagram-removed); text-decoration: line-through; }
  }
  .diagram-badge {
    position: absolute;
    translate: -60% -40%;
    display: grid;
    place-items: center;
    inline-size: 1.125rem;
    block-size: 1.125rem;
    border-radius: 50%;
    background: var(--diagram-changed);
    color: var(--diagram-paper);
    font: 700 0.6875rem/1 var(--font-mono);
    &[data-change="added"] { background: var(--diagram-added); }
    &[data-change="removed"] { background: var(--diagram-removed); }
  }
  .diagram-ledger {
    display: grid;
    gap: 0.375rem;
    margin: 0.25rem 0 0;
    padding: 0;
    list-style: none;
    font-size: 0.75rem;
  }
  .diagram-ledger-item {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.125rem 0.5rem;
    padding: 0.375rem 0.5rem;
    border-inline-start: 2px solid var(--diagram-changed);
    background: var(--diagram-soft);
    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
    &[data-change="added"] { border-color: var(--diagram-added); }
    &[data-change="removed"] { border-color: var(--diagram-removed); border-inline-start-style: dashed; }
  }
  .diagram-ledger-kind {
    color: var(--diagram-changed);
    font: 600 0.5625rem/1.4 var(--font-mono);
    letter-spacing: 0.12em;
    text-transform: uppercase;
  }
  .diagram-ledger-item[data-change="added"] .diagram-ledger-kind { color: var(--diagram-added); }
  .diagram-ledger-item[data-change="removed"] .diagram-ledger-kind { color: var(--diagram-removed); }
  .diagram-ledger-note {
    flex-basis: 100%;
    color: var(--diagram-muted);
    font: 0.6875rem/1.4 var(--font-mono);
  }
  /* ===========================================================================
     Types - each a preset over the same primitives
     =========================================================================== */
  /* free canvas: --x / --y in percent (wardley, positioning) */
  .diagram-canvas[data-layout="free"],
  .diagram[data-type="wardley"] .diagram-canvas {
    display: block;
    aspect-ratio: var(--ratio, 16 / 10);
    min-width: 36rem;
    & > :is(.diagram-node, .diagram-cell, .diagram-tag, .diagram-note) {
      position: absolute;
      left: calc(var(--_inset-left) + (100% - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100);
      top: calc(var(--_inset-top) + (100% - var(--_inset-top) - var(--_inset-bottom)) * var(--y, 50) / 100);
      translate: -50% -50%;
    }
  }
  /* radial: stations around a hub (loop, topology) */
  .diagram-canvas[data-layout="radial"],
  .diagram[data-type="loop"] .diagram-canvas {
    display: block;
    aspect-ratio: var(--ratio, 1.55);
    min-width: 40rem;
    min-block-size: 30rem;
    & > .diagram-node {
      position: absolute;
      inline-size: var(--station, 9.5rem);
      left: calc(50% + var(--rx, 36%) * sin(calc(var(--i, 0) / var(--n, 6) * 1turn)));
      top: calc(50% - var(--ry, 37%) * cos(calc(var(--i, 0) / var(--n, 6) * 1turn)));
      translate: -50% -50%;
    }
    & > .diagram-node[data-hub] {
      left: 50%;
      top: 50%;
      inline-size: var(--hub, 11rem);
      text-align: center;
      align-items: center;
    }
  }
  /* wardley: value chain (y) × evolution (x), four bands */
  .diagram[data-type="wardley"] .diagram-canvas {
    /* the axes sit in the left / bottom padding, 1.25rem (diagram.ts CLEARANCE) off the border */
    padding: 1.5rem 1.5rem 3.5rem 4rem;
    background-image:
      linear-gradient(to right, transparent calc(25% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(25% - 0.5px) calc(25% + 0.5px), transparent calc(25% + 0.5px)),
      linear-gradient(to right, transparent calc(50% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)),
      linear-gradient(to right, transparent calc(75% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(75% - 0.5px) calc(75% + 0.5px), transparent calc(75% + 0.5px));
    background-size: auto;
    background-repeat: no-repeat;
    background-origin: content-box;
  }
  .diagram-axis {
    position: absolute;
    display: flex;
    justify-content: space-between;
    color: var(--diagram-muted);
    font: 500 0.5625rem/1.4 var(--font-mono);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    & > b {
      color: var(--diagram-ink);
      font-weight: 600;
    }
    &[data-axis="x"] {
      left: calc(4rem + var(--_inset-left));
      right: calc(1.5rem + var(--_inset-right));
      bottom: calc(1.25rem + var(--_inset-bottom));
      padding-top: 0.375rem;
      border-top: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent);
    }
    &[data-axis="x"] > span {
      flex: 1;
      text-align: center;
    }
    &[data-axis="y"] {
      top: calc(1.5rem + var(--_inset-top));
      bottom: calc(3.5rem + var(--_inset-bottom));
      left: calc(1.25rem + var(--_inset-left));
      inline-size: 2rem;
      flex-direction: column;
      align-items: flex-start;
      padding-inline-end: 0.375rem;
      border-inline-end: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent);
      font-size: 0.5rem;
      white-space: normal;
    }
  }
  /* timeline: one honest axis, events above and below */
  .diagram[data-type="timeline"] .diagram-canvas {
    display: block;
    block-size: var(--diagram-height, 17rem);
    min-width: 40rem;
    padding: 0;
    &::before {
      content: "";
      position: absolute;
      left: 1.5rem;
      right: 1.5rem;
      top: var(--axis, 55%);
      border-top: 1px solid var(--diagram-line);
    }
    & > .diagram-node {
      position: absolute;
      left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100);
      top: var(--axis, 55%);
      inline-size: 0.625rem;
      block-size: 0.625rem;
      min-block-size: 0;
      padding: 0;
      translate: -50% -50%;
      border-radius: 50%;
      background: var(--diagram-ink);
      border: 0;
      overflow: visible;
    }
    & > .diagram-node[data-tone="accent"] {
      inline-size: 0.875rem;
      block-size: 0.875rem;
      background: var(--diagram-accent);
      box-shadow: 0 0 0 3px var(--diagram-accent-tint);
    }
    & > .diagram-node > .diagram-node-body {
      position: absolute;
      left: 50%;
      bottom: 2.25rem;
      translate: -50% 0;
      display: grid;
      gap: 0.0625rem;
      inline-size: max-content;
      max-inline-size: 10rem;
      text-align: center;
    }
    & > .diagram-node > .diagram-node-body::after {
      content: "";
      position: absolute;
      left: 50%;
      top: 100%;
      block-size: 1.75rem;
      border-inline-start: 1px solid var(--diagram-rule);
    }
    & > .diagram-node[data-side="below"] > .diagram-node-body {
      bottom: auto;
      top: 2.25rem;
    }
    & > .diagram-node[data-side="below"] > .diagram-node-body::after {
      top: auto;
      bottom: 100%;
    }
    & > .diagram-node[data-tone="accent"] .diagram-node-name {
      color: var(--diagram-accent);
    }
    & > .diagram-tick {
      position: absolute;
      left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100);
      top: var(--axis, 55%);
      translate: -50% 0.375rem;
      color: var(--diagram-muted);
      font: 0.5625rem/1.4 var(--font-mono);
      letter-spacing: 0.08em;
      white-space: nowrap;
    }
    & > .diagram-tick::before {
      content: "";
      position: absolute;
      left: 50%;
      bottom: calc(100% + 0.125rem);
      block-size: 0.375rem;
      border-inline-start: 1px solid var(--diagram-line);
    }
    & > .diagram-span {
      position: absolute;
      left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 0) / 100);
      inline-size: calc((100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--w, 10) / 100);
      top: calc(var(--axis, 55%) - 0.375rem);
      block-size: 0.75rem;
      background: color-mix(in oklab, var(--diagram-accent) 18%, transparent);
      border-radius: var(--radius-sm);
    }
  }
  /* layers: full-width bands, one focal */
  .diagram[data-type="layers"] .diagram-canvas {
    --diagram-node-w: 100%;
    grid-template-columns: minmax(28rem, 1fr);
    row-gap: 0;
    & > .diagram-node {
      display: grid;
      grid-template-columns: 9.5rem 1fr auto;
      align-items: center;
      align-self: stretch;
      gap: 1rem;
      min-block-size: 3.75rem;
      margin-block-start: -1px;
      border-radius: 0;
      border-color: var(--diagram-rule);
    }
    & > .diagram-node:first-of-type {
      border-start-start-radius: var(--radius-md);
      border-start-end-radius: var(--radius-md);
    }
    & > .diagram-node:last-of-type {
      border-end-start-radius: var(--radius-md);
      border-end-end-radius: var(--radius-md);
    }
    & > .diagram-node[data-tone="accent"] {
      position: relative;
      z-index: 3;
      border-color: var(--diagram-accent);
    }
    & .diagram-node-name {
      font-size: 0.9375rem;
    }
  }
  /* nested containment: groups inside groups */
  .diagram[data-type="nested"] .diagram-canvas {
    grid-template-columns: minmax(24rem, 1fr);
  }
  .diagram[data-type="nested"] .diagram-group {
    padding: 2rem 1.75rem 1.75rem;
    border-style: solid;
    border-color: color-mix(in oklab, var(--diagram-ink) 22%, transparent);
  }
  .diagram[data-type="nested"] .diagram-group .diagram-group {
    border-color: color-mix(in oklab, var(--diagram-ink) 40%, transparent);
    background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent);
  }
  .diagram[data-type="nested"] .diagram-group .diagram-group .diagram-group {
    border-color: color-mix(in oklab, var(--diagram-ink) 65%, transparent);
  }
  .diagram[data-type="nested"] .diagram-group[data-tone="accent"] {
    border-color: var(--diagram-accent);
    background: var(--diagram-accent-tint);
  }
  .diagram-group[data-cols] {
    grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));
  }
  /* sequence: participants on top, one row per message */
  .diagram[data-type="sequence"] .diagram-canvas {
    grid-auto-rows: auto;
    row-gap: 0.5rem;
    & > .diagram-node {
      justify-self: center;
      min-inline-size: 7.5rem;
      text-align: center;
      align-items: center;
    }
    & > .diagram-edges {
      grid-column: 1 / -1;
      margin: 0;
      padding: 0 0 0.5rem;
      list-style: none;
      position: static;
    }
    & > .diagram-edges > .diagram-edge {
      block-size: 2.5rem;
      font-size: 0.75rem;
    }
  }
  .diagram[data-type="sequence"] .diagram-canvas:has(> .diagram-wires) > .diagram-edges > .diagram-edge {
    color: transparent;
  }
  .diagram[data-type="sequence"] .diagram-canvas:has(> .diagram-wires) > .diagram-edges > .diagram-edge::before {
    color: transparent;
  }
  /* quadrant (consultant 2×2): two driver ranges, four named scenarios */
  .diagram[data-type="quadrant"] .diagram-canvas {
    --diagram-node-w: 100%;
    grid-template-columns: max-content repeat(2, minmax(14rem, 1fr));
    grid-template-rows: repeat(2, minmax(9rem, auto)) auto;
    gap: 0;
    & > .diagram-node {
      align-self: stretch;
      justify-content: flex-start;
      gap: 0.375rem;
      padding: 1.25rem 1.5rem;
      border: 0;
      border-radius: 0;
      background: transparent;
    }
    /* scenarios in reading order: top-left, top-right, bottom-left, bottom-right */
    & > :nth-child(1 of .diagram-node) { grid-area: 1 / 2; }
    & > :nth-child(2 of .diagram-node) { grid-area: 1 / 3; }
    & > :nth-child(3 of .diagram-node) { grid-area: 2 / 2; }
    & > :nth-child(4 of .diagram-node) { grid-area: 2 / 3; }
    & > :is(:nth-child(1 of .diagram-node), :nth-child(3 of .diagram-node)) {
      border-inline-end: 1px solid var(--diagram-ink);
    }
    & > :is(:nth-child(1 of .diagram-node), :nth-child(2 of .diagram-node)) {
      border-block-end: 1px solid var(--diagram-ink);
    }
    & > .diagram-node[data-tone="accent"] {
      background: var(--diagram-accent-tint);
    }
    & .diagram-node-name {
      font: 400 1.25rem/1.2 var(--font-mono);
    }
    & .diagram-node[data-tone="accent"] .diagram-node-name {
      color: var(--diagram-accent);
    }
    & .diagram-node-meta {
      font-family: var(--font-mono);
      font-size: 0.75rem;
      line-height: 1.5;
    }
    & > .diagram-axis {
      position: static;
    }
    & > .diagram-axis[data-axis="x"] {
      grid-column: 2 / span 2;
      grid-row: 3;
      padding-top: 0.5rem;
      border: 0;
    }
    & > .diagram-axis[data-axis="x"] > span {
      flex: none;
    }
    & > .diagram-axis[data-axis="y"] {
      grid-column: 1;
      grid-row: 1 / span 2;
      inline-size: auto;
      align-items: center;
      padding: 0 0.875rem 0 0;
      border: 0;
      text-align: center;
    }
  }
  /* journey: stages × sentiment band × rows */
  .diagram[data-type="journey"] .diagram-canvas {
    grid-template-columns: 6rem repeat(var(--cols, 5), minmax(6.5rem, 1fr));
    row-gap: 1rem;
    & .diagram-node[data-shape="dot"] {
      align-self: start;
      margin-block-start: calc((1 - var(--sentiment, 0.5)) * (var(--band, 9rem) - 0.75rem));
    }
  }
  /* fishbone: categories above and below a spine into the effect */
  .diagram[data-type="fishbone"] .diagram-canvas {
    grid-template-rows: auto 3.5rem auto;
    column-gap: 2rem;
    & > .diagram-node[data-bone] {
      align-self: end;
      margin-inline-end: 1.5rem;
    }
    & > .diagram-node[data-bone][data-side="below"] {
      align-self: start;
    }
    & > .diagram-node[data-bone] .diagram-node-fields {
      border-top: 0;
      padding-block-start: 0;
    }
    & > .diagram-node[data-bone] .diagram-node-fields > li::before {
      content: "—";
      color: var(--diagram-muted);
    }
    & > .diagram-node[data-effect] {
      grid-row: 1 / span 3;
      align-self: center;
    }
  }
  /* story map: activities, steps, release bands, the cut */
  .diagram[data-type="story-map"] .diagram-canvas {
    --diagram-gap-x: 1rem;
    --diagram-gap-y: 0.75rem;
    grid-template-columns: 5.5rem repeat(var(--cols, 4), minmax(8.5rem, 1fr));
    & .diagram-node[data-shape="activity"] {
      background: var(--diagram-soft);
      border-color: var(--diagram-muted);
    }
    & .diagram-node {
      min-block-size: 2.5rem;
      align-self: start;
    }
    & .diagram-group[data-shape="lane"] {
      margin: -0.375rem -1.75rem;
    }
    & .diagram-group[data-shape="lane"] > .diagram-group-label {
      left: 1.75rem;
    }
  }
  /* matrix: a real table - permissions, RACI, probabilities, risk, scoring */
  .diagram-matrix {
    border-collapse: separate;
    border-spacing: 0;
    inline-size: 100%;
    font-size: 0.75rem;
    & :is(th, td) {
      padding: 0.5rem 0.625rem;
      border-block-end: 1px solid var(--diagram-rule);
      text-align: center;
      transition: opacity var(--diagram-step-ms) ease;
    }
    & thead th {
      vertical-align: bottom;
      color: var(--diagram-muted);
      font: 500 0.5625rem/1.4 var(--font-mono);
      letter-spacing: 0.12em;
      text-transform: uppercase;
      border-block-end-color: var(--diagram-line);
    }
    & thead th > small {
      display: block;
      color: var(--diagram-ink);
      font: 600 0.75rem/1.3 var(--font-mono);
      letter-spacing: 0;
      text-transform: none;
    }
    & tbody th {
      text-align: start;
      font-weight: 600;
      white-space: nowrap;
    }
    & tbody th > small {
      display: block;
      color: var(--diagram-muted);
      font: 400 0.625rem/1.4 var(--font-mono);
    }
    & td[style*="--v"] {
      background: color-mix(in oklab, var(--diagram-heat, var(--diagram-accent)) calc(var(--v, 0) * 62%), var(--diagram-paper));
      font-family: var(--font-mono);
      font-variant-numeric: tabular-nums;
    }
    & td[data-perm] {
      font: 600 0.625rem/1 var(--font-mono);
      letter-spacing: 0.08em;
    }
    & td[data-perm] > span {
      display: inline-grid;
      place-items: center;
      min-inline-size: 2.75rem;
      padding: 0.25rem 0.375rem;
      border-radius: var(--radius-sm);
      border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent);
    }
    & td[data-perm="full"] > span {
      background: var(--diagram-ink);
      border-color: var(--diagram-ink);
      color: var(--diagram-paper);
    }
    & td[data-perm="write"] > span {
      background: color-mix(in oklab, var(--diagram-ink) 18%, var(--diagram-paper));
    }
    & td[data-perm="read"] > span {
      border-style: solid;
    }
    & td[data-perm="none"] > span {
      border-style: dotted;
      color: var(--diagram-muted);
    }
    & td[data-perm][data-tone="accent"] > span {
      background: var(--diagram-accent-tint);
      border: 1px solid var(--diagram-accent);
      color: var(--diagram-accent);
    }
    & :is(td, th)[data-tone="accent"]:not([data-perm]) {
      box-shadow: inset 0 0 0 1.5px var(--diagram-accent);
      font-weight: 700;
    }
    & tfoot :is(th, td) {
      border-block-end: 0;
      border-block-start: 1px solid var(--diagram-line);
      font-weight: 600;
    }
  }
  .diagram-canvas:has(> .diagram-matrix) {
    grid-template-columns: minmax(0, 1fr);
  }
  .diagram-canvas:has(> .diagram-matrix:only-child) {
    background-image: none;
  }
  /* -- accessibility -------------------------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .diagram *,
    .diagram *::before,
    .diagram *::after {
      animation: none !important;
      transition: none !important;
    }
    .diagram-token {
      display: none;
    }
  }
  @media (prefers-contrast: more) {
    .diagram {
      --diagram-line: var(--foreground);
      --diagram-wire: color-mix(in oklab, var(--foreground) 85%, var(--card));
      --diagram-muted: color-mix(in oklab, var(--foreground) 85%, var(--card));
      --diagram-future-opacity: 0.2;
    }
    .diagram-group {
      border-color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .diagram {
      --diagram-paper: Canvas;
      --diagram-ink: CanvasText;
      --diagram-line: CanvasText;
      --diagram-wire: CanvasText;
      --diagram-muted: CanvasText;
      --diagram-accent: Highlight;
      --diagram-link: LinkText;
      --diagram-added: Highlight;
      --diagram-removed: Mark;
      --diagram-changed: Highlight;
    }
    .diagram-canvas {
      background-image: none;
    }
    .diagram-node,
    .diagram-group {
      forced-color-adjust: none;
      background: Canvas;
      color: CanvasText;
      border-color: CanvasText;
    }
    .diagram-wire-label,
    .diagram-group-label {
      forced-color-adjust: none;
      background: Canvas;
      color: CanvasText;
    }
    .diagram-node[data-shape="diamond"]::before {
      background: CanvasText;
    }
    .diagram-node[data-shape="diamond"]::after {
      background: Canvas;
    }
  }
}

§JavaScript view file

The engine every example converges on: wire routing, steps, delta panels, specs and the State API.

// -- Diagram --------------------------------------------------
// Diagrams from plain HTML: nodes and groups are elements on a CSS grid (or a
// free / radial canvas), their relationships are an ordered list of edges -
// readable as text without JavaScript. The component measures the laid-out
// nodes and draws the wires (rounded orthogonal elbows, straight lines,
// curves, self-loops, cycle back-edges, fishbone bones, sequence messages)
// into an SVG layer behind them, with masked labels and typed ends (arrows,
// UML triangles and diamonds, crow's feet). Every diagram can be played step
// by step (data-steps) and shown as a delta - before / changes / after -
// from one annotated source (data-delta + data-change). A JSON spec builds
// the same markup (df$.shadcn.diagram.build) - the parametric path.
//
// The visual grammar (paper + ink + one focal accent, mono eyebrows, masked
// connector labels, elbow connectors, the before/changes/after ledger, the
// policy-evaluation trace) is adapted from diagram-design by Cathryn Lavery
// (MIT, https://github.com/cathrynlavery/diagram-design) - re-implemented
// here as a standalone component; nothing of that project is loaded.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** A tone: a node's or an edge's color role (none: the ink). */
type DiagramTone = 'none' | 'accent' | 'link' | 'ink' | 'muted' | 'external' | 'warn' | 'ok' | 'danger';
/** What a delta (diff) marks on a node, a group or an edge. */
type DiagramChange = 'added' | 'removed' | 'changed' | 'moved' | 'rewired';
/** Where an element sits: grid cells, or free / radial positions. */
interface DiagramPlace {
  /** grid: the first column (1-based) */
  col?: number;
  /** grid: the first row (1-based) */
  row?: number;
  /** grid: how many columns it spans */
  span?: number;
  /** grid: how many rows it spans */
  rspan?: number;
  /** free canvas: the center's left, % of the canvas */
  x?: number;
  /** free canvas: the center's top, % of the canvas */
  y?: number;
  /** radial canvas: its index around the hub */
  i?: number;
  /** more inline CSS */
  style?: string;
}
/** One field row of a node (an entity's column, a class's attribute). */
interface DiagramField {
  /** its name */
  name: string;
  /** a key marker ('pk', 'fk', ...) - a wire to a field leaves and enters at its row */
  key?: string;
  /** its type, shown at the right */
  type?: string;
  /** a delta mark */
  change?: DiagramChange;
}
/** A node of a spec. */
interface DiagramNodeSpec extends DiagramPlace {
  /** its data-node: what edges and activation name it by */
  id: string;
  /** the main text */
  name?: string;
  /** the small text above the name */
  eyebrow?: string;
  /** the small text under the name */
  meta?: string;
  /** its shape (default 'box') */
  shape?: 'box' | 'pill' | 'diamond' | 'store' | 'circle' | 'dot' | 'bar' | 'note' | 'activity' | 'class' | 'start' | 'end' | 'ghost';
  /** its color role */
  tone?: DiagramTone;
  /** a status word (data-status) */
  status?: string;
  /** a corner badge */
  badge?: string;
  /** field rows (a string is a field name) */
  fields?: Array<string | DiagramField>;
  /** operation rows (a class's methods) */
  ops?: string[];
  /** the step it appears in (data-steps) */
  step?: number;
  /** the group it sits in */
  parent?: string;
  /** a delta mark */
  change?: DiagramChange;
  /** the delta's note on the change */
  note?: string;
  /** a delta: the text and place it had before */
  before?: Partial<Pick<DiagramNodeSpec, 'name' | 'eyebrow' | 'meta' | 'col' | 'row' | 'span' | 'rspan' | 'x' | 'y'>>;
}
/** A group (a zone, a lane, a column) of a spec. */
interface DiagramGroupSpec extends DiagramPlace {
  /** its data-node: what nodes name as their parent */
  id: string;
  /** its label */
  label: string;
  /** its shape ('column' for a kanban column, ...) */
  shape?: string;
  /** its color role */
  tone?: DiagramTone;
  /** the step it appears in */
  step?: number;
  /** the group it sits in */
  parent?: string;
  /** a delta mark */
  change?: DiagramChange;
}
/** An edge of a spec. */
interface DiagramEdgeSpec {
  /** its data-edge (default "from->to") */
  id?: string;
  /** the node it leaves ("node" or "node.field") */
  from: string;
  /** the node it reaches */
  to: string;
  /** its label */
  label?: string;
  /** its stroke (default 'solid') */
  line?: 'solid' | 'dashed' | 'dotted' | 'thick';
  /** its color role */
  tone?: DiagramTone;
  /** the end at `to` (default 'arrow') */
  head?: 'arrow' | 'open' | 'triangle' | 'diamond' | 'dot' | 'one' | 'many' | 'none';
  /** the end at `from` (default 'none') */
  tail?: 'arrow' | 'open' | 'triangle' | 'diamond' | 'dot' | 'one' | 'many' | 'none';
  /** its route (default 'elbow') */
  curve?: 'elbow' | 'straight' | 'curve' | 'around';
  /** the step it appears in */
  step?: number;
  /** data moving along it: true, or the number of tokens */
  flow?: boolean | string;
  /** a label at the `from` end (a cardinality) */
  fromLabel?: string;
  /** a label at the `to` end */
  toLabel?: string;
  /** a delta mark */
  change?: DiagramChange;
  /** a delta: its label or ends before */
  before?: { label?: string; from?: string; to?: string };
}
/** A diagram as JSON - the same figure an author writes as markup. */
interface DiagramSpec {
  /** the diagram type (data-type: architecture, flow, state, er, sequence, ...) */
  type?: string;
  /** the caption's title */
  title?: string;
  /** the caption's eyebrow */
  eyebrow?: string;
  /** the caption's text under the title */
  caption?: string;
  /** grid columns */
  cols?: number;
  /** more inline CSS on the canvas */
  style?: string;
  /** a phase header row (a string is a phase name) */
  phases?: Array<string | { name: string; span?: number }>;
  /** the groups */
  groups?: DiagramGroupSpec[];
  /** the nodes */
  nodes?: DiagramNodeSpec[];
  /** the edges */
  edges?: DiagramEdgeSpec[];
  /** reveal it step by step (data-steps; a number: the step time in ms) */
  steps?: boolean | number;
  /** play the steps on its own (data-autoplay; a number: the delay in ms) */
  autoplay?: boolean | number;
  /** clickable: boxes and wires activate (data-interactive) */
  interactive?: boolean;
}
/** Two specs - build() and markup() render the delta between them. */
interface DiagramDelta {
  /** the diagram before */
  before: DiagramSpec;
  /** the diagram after */
  after: DiagramSpec;
}
/** What is active - active() returns it, diagram-activate carries it (null fields when cleared). */
interface DiagramActivation {
  /** the reference: a node's data-node, an edge's reference */
  ref: string | null;
  /** what it is */
  kind: 'node' | 'edge' | null;
  /** its element */
  element: HTMLElement | null;
  /** its name (an edge: "from → to · label") */
  label: string;
  /** its data-detail, else a node's meta */
  detail: string;
}
/** What diagram-drawn carries. */
interface DiagramDrawnDetail {
  /** how many edges were drawn */
  edges: number;
  /** the delta panel drawn, null outside a delta */
  panel: 'before' | 'changes' | 'after' | null;
}
/** What diagram-step carries. */
interface DiagramStepDetail {
  /** the step shown now, 1-based */
  step: number;
  /** the number of steps */
  max: number;
  /** the step's label (data-step-label), '' when it has none */
  label: string;
  /** whether the steps are playing or paused */
  state: 'playing' | 'paused';
}
const diagramStates = ['default', 'playing', 'paused', 'active'];
/** setState() configs per state. */
export interface DiagramStateConfigs {
  /** The complete, static figure. */
  default: {};
  /** Playing the steps: the clock moves on once a step's elements have appeared. */
  playing: {
    /** the step on screen, 1-based (default 1) */
    step?: number;
  };
  /** Stopped on a step. */
  paused: {
    /** the step shown, 1-based (default: the last) */
    step?: number;
  };
  /** A node or an edge is active - the complete figure, the rest dimmed. */
  active: {
    /** the node's data-node, or the edge's data-edge (else "from->to") */
    ref: string;
  };
}
const SVG_NS = 'http://www.w3.org/2000/svg';
const RUNTIME = '.diagram-delta, .diagram-controls, .diagram-wire-labels, .diagram-wires';
const reducedMotion = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
// -- reading the source ---------------------------------------------------------------------
/** authored (not runtime-made) descendants of a scope matching a selector */
const authored = (scope, selector) => dfDollar(scope).find(selector).toArray().filter((n) => !n.closest(RUNTIME));
/** "api", "api:left", "orders#customer_id", "orders#customer_id:right" */
function parseEnd(value) {
  const [ref, side] = String(value || '').split(':');
  const [id, field] = ref.split('#');
  return { id, field: field || null, side: side || null };
}
/** the edge list a canvas draws: its own (a sequence keeps its messages inside) or the figure's */
function edgeListOf(root, canvas) {
  return dfDollar(canvas).children('.diagram-edges').get(0) ?? dfDollar(root).children('.diagram-edges').get(0) ?? null;
}
const edgesIn = (list) => (list ? dfDollar(list).find('.diagram-edge').toArray() : []);
/** the readable name of a node, group, edge or any changed element */
function nameOf(el) {
  if (!el) return '';
  if (el.dataset.changeLabel) return el.dataset.changeLabel;
  const named = dfDollar(el).find('.diagram-node-name, .diagram-group-label').get(0) ?? (el.tagName === 'LI' && el.children.length ? el.firstElementChild : null);
  const text = (named ?? el).textContent.replace(/\s+/g, ' ').trim();
  return text.length > 48 ? `${text.slice(0, 47)}…` : text;
}
// -- steps -----------------------------------------------------------------------------------
/**
 * The step plan of a diagram - which step each element enters at - read from
 * the markup alone, so render() computes it on a detached copy exactly like
 * the live element. Explicit: every [data-step] element (descendants follow
 * their ancestor). Automatic: nodes in document order, one a step (table rows
 * when there are no nodes); a group enters with its first node (scaffolding
 * without nodes is always shown); an edge enters with its later endpoint.
 */
function stepPlan(root) {
  const stepOf = new Map();
  const explicit = authored(root, '[data-step]');
  const nodes = authored(root, '.diagram-node');
  if (explicit.length) {
    explicit.forEach((n) => stepOf.set(n, Math.max(0, parseInt(n.dataset.step, 10) || 0)));
  } else {
    let k = 0;
    const sequence = root.dataset.type === 'sequence';
    const units = sequence ? [] : nodes.length ? nodes : authored(root, 'tbody > tr');
    if (sequence) nodes.forEach((n) => stepOf.set(n, 0));
    units.forEach((n) => stepOf.set(n, ++k));
    authored(root, '.diagram-group').forEach((g) => {
      const inner = units.filter((n) => g.contains(n)).map((n) => stepOf.get(n));
      stepOf.set(g, inner.length ? Math.min(...inner) : 0);
    });
  }
  const byId = new Map(authored(root, '[data-node]').map((n) => [n.dataset.node, n]));
  const stepOfEl = (el) => {
    for (let n = el; n && n !== root; n = n.parentElement) if (stepOf.has(n)) return stepOf.get(n);
    return 0;
  };
  authored(root, '.diagram-edge').forEach((edge, i) => {
    if (stepOf.has(edge)) return;
    const a = byId.get(parseEnd(edge.dataset.from).id);
    const b = byId.get(parseEnd(edge.dataset.to).id);
    // a sequence's messages play in list order
    stepOf.set(edge, root.dataset.type === 'sequence' ? i + 1 : Math.max(stepOfEl(a), stepOfEl(b)));
  });
  const max = Math.max(0, ...stepOf.values());
  return { stepOf, max };
}
/**
 * The markup of a state, for render() AND the live element: in 'playing' /
 * 'paused' the figure carries data-step-current and every stepped element a
 * data-step-state of past / current / future; 'default' is the complete,
 * static figure (no step attributes).
 */
function applyMarkup(root, state) {
  const { stepOf, max } = stepPlan(root);
  applyActivation(root, state.name === 'active' ? state.config?.ref ?? null : null);
  if (state.name === 'default' || state.name === 'active') {
    dfDollar(root).attr('data-step-current', null);
    stepOf.forEach((_s, el) => {
      dfDollar(el).attr('data-step-state', null);
      beat(el, null);
    });
    return;
  }
  const current = stateStep(state, max);
  dfDollar(root).attr('data-step-current', String(current));
  stepOf.forEach((s, el) => {
    dfDollar(el).attr('data-step-state', s <= 0 || s < current ? 'past' : s === current ? 'current' : 'future');
  });
  beats(root, stepOf, current);
}
/** an element's beat inside its step (--step-i), or none */
function beat(el, i) {
  if (i == null) el.style.removeProperty('--step-i');
  else el.style.setProperty('--step-i', String(i));
  if (el.getAttribute('style') === '') el.removeAttribute('style');
}
/**
 * Inside a step every element gets its own beat: boxes one after another in
 * markup order (--step-i 0, 1, 2 ..., each --diagram-element-ms after the last),
 * an edge just after its later end has appeared. Returns how many beats the
 * step takes - the clock holds the step that long.
 */
function beats(root, stepOf, current) {
  const items = [];
  stepOf.forEach((s, el) => {
    if (s === current && current > 0) items.push(el);
    else beat(el, null);
  });
  const boxes = items.filter((el) => !el.classList.contains('diagram-edge'));
  const index = new Map(boxes.map((el, i) => [el, i]));
  boxes.forEach((el, i) => beat(el, i));
  const nodeOf = (end) => authored(root, '[data-node]').find((n) => n.dataset.node === parseEnd(end).id);
  const at = (el) => {
    for (let n = el; n && n !== root; n = n.parentElement) if (index.has(n)) return index.get(n);
    return -1;
  };
  for (const edge of items.filter((el) => el.classList.contains('diagram-edge'))) {
    beat(edge, Math.max(at(nodeOf(edge.dataset.from)), at(nodeOf(edge.dataset.to)), -0.4) + 0.6);
  }
  return Math.max(1, boxes.length);
}
// -- activation (state 'active') -----------------------------------------------------------
/** an edge's reference: its data-edge, else "from->to" */
const edgeRef = (edge) => edge.dataset.edge || `${edge.dataset.from}->${edge.dataset.to}`;
/** the nodes and edges an activation can land on (groups and ghosts cannot) */
const activatable = (root) => authored(root, '[data-node]').filter((n) => !n.classList.contains('diagram-group') && n.dataset.shape !== 'ghost');
/** the element a reference names: a node (data-node) or an edge (edgeRef) */
function refTarget(root, ref): { kind: 'node' | 'edge'; el: HTMLElement } | null {
  if (ref == null || ref === '') return null;
  const node = activatable(root).find((n) => n.dataset.node === ref);
  if (node) return { kind: 'node', el: node };
  const edge = authored(root, '.diagram-edge').find((e) => edgeRef(e) === String(ref));
  return edge ? { kind: 'edge', el: edge } : null;
}
/**
 * The markup of an activation: the figure names it (data-active), the active
 * node or edge is 'active', what it touches 'related' (a node's edges and
 * their other ends, an edge's two ends), everything else 'dimmed'. Without a
 * reference every mark is removed. Interactive figures mirror it as
 * aria-pressed on their nodes.
 */
function applyActivation(root, ref) {
  const nodes = activatable(root);
  const edges = authored(root, '.diagram-edge');
  const target = refTarget(root, ref);
  dfDollar(root).attr('data-active', target ? String(ref) : null);
  const interactive = root.hasAttribute('data-interactive');
  if (!target) {
    for (const el of [...nodes, ...edges]) dfDollar(el).attr('data-active-state', null);
    if (interactive) for (const n of nodes) dfDollar(n).attr(pressedAttr(n), 'false');
    return;
  }
  const related = new Set();
  const idOf = (end) => parseEnd(end).id;
  if (target.kind === 'node') {
    const id = target.el.dataset.node;
    for (const e of edges) {
      if (idOf(e.dataset.from) !== id && idOf(e.dataset.to) !== id) continue;
      related.add(e);
      for (const end of [idOf(e.dataset.from), idOf(e.dataset.to)]) related.add(nodes.find((n) => n.dataset.node === end));
    }
  } else {
    for (const end of [idOf(target.el.dataset.from), idOf(target.el.dataset.to)]) related.add(nodes.find((n) => n.dataset.node === end));
  }
  for (const el of [...nodes, ...edges]) {
    dfDollar(el).attr('data-active-state', el === target.el ? 'active' : related.has(el) ? 'related' : 'dimmed');
  }
  if (interactive) for (const n of nodes) dfDollar(n).attr(pressedAttr(n), n === target.el ? 'true' : 'false');
}
// a table row (a matrix) keeps its row semantics: it is selected, not pressed
const tablePart = (el) => /^(TR|TD|TH)$/.test(el.tagName);
const pressedAttr = (el) => (tablePart(el) ? 'aria-selected' : 'aria-pressed');
/** what an activation is about - for outputs and the diagram-activate event */
function describe(root, ref): DiagramActivation | null {
  const target = refTarget(root, ref);
  if (!target) return null;
  const { kind, el } = target;
  if (el.tagName === 'TR') {
    // a matrix row: its header's own text, then every cell under its column's name
    const cells = dfDollar(el).children('th, td').toArray();
    const own = (c) => [...c.childNodes].filter((n) => n.nodeType === 3 || !/^(SMALL)$/.test(n.nodeName)).map((n) => n.textContent).join('').trim();
    const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray();
    const detail = cells.slice(1).map((c, i) => `${heads[i + 1] ? own(heads[i + 1]) : ''} ${c.textContent.trim()}`.trim()).join(' · ');
    return { ref: String(ref), kind, element: el, label: own(cells[0]), detail };
  }
  if (kind === 'node') {
    const meta = dfDollar(el).find('.diagram-node-meta').get(0)?.textContent.trim() ?? '';
    return { ref: String(ref), kind, element: el, label: nameOf(el), detail: el.dataset.detail || meta };
  }
  const name = (end) => {
    const n = activatable(root).find((x) => x.dataset.node === parseEnd(end).id);
    return n ? nameOf(n) : parseEnd(end).id;
  };
  const text = el.textContent.trim();
  return { ref: String(ref), kind, element: el, label: `${name(el.dataset.from)} → ${name(el.dataset.to)}${text ? ` · ${text}` : ''}`, detail: el.dataset.detail || '' };
}
/** the activation order: nodes in step order, then document order */
function activationOrder(root) {
  const { stepOf } = stepPlan(root);
  const stepFor = (n) => {
    for (let x = n; x && x !== root; x = x.parentElement) if (stepOf.has(x)) return stepOf.get(x);
    return 0;
  };
  return activatable(root).map((n, i) => ({ n, i, s: stepFor(n) })).sort((a, b) => a.s - b.s || a.i - b.i).map((x) => x.n.dataset.node);
}
/** move the activation by one node (wraps around); from nothing, the first or the last */
function stepActivation(root, by) {
  const order = activationOrder(root);
  if (!order.length) return;
  const state = root._shown ?? root.store?.value ?? { name: 'default' };
  const at = state.name === 'active' ? order.indexOf(String(state.config?.ref)) : -1;
  const next = at < 0 ? (by > 0 ? 0 : order.length - 1) : (at + by + order.length) % order.length;
  root.api.setState('active', { ref: order[next] });
}
/** is a state one of the step states (playing / paused)? */
const stepping = (state) => state.name === 'playing' || state.name === 'paused';
const clampStep = (step, max) => Math.min(max, Math.max(0, Math.round(+step)));
/** the step a state shows: its config.step - without one, playing starts at 1 and paused stops on the last */
const stateStep = (state, max) => {
  const step = state.config?.step;
  if (step != null && step !== '' && Number.isFinite(+step)) return clampStep(step, max);
  return state.name === 'playing' ? Math.min(1, max) : max;
};
/** the label of a step: its first element's data-step-label or name */
function stepLabel(root, step) {
  const { stepOf } = stepPlan(root);
  for (const [el, s] of stepOf) if (s === step) return el.dataset.stepLabel || nameOf(el);
  return '';
}
// -- geometry --------------------------------------------------------------------------------
const NORMAL = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
const horizontal = (side) => side === 'left' || side === 'right';
const ROUND = new Set(['dot', 'circle', 'start', 'end', 'ring', 'event']);
const r1 = (n) => Math.round(n * 10) / 10;
/** a point on a side of a rect; t in 0..1 along the side */
function sidePoint(r, side, t = 0.5) {
  if (side === 'top') return { x: r.x + r.w * t, y: r.y };
  if (side === 'bottom') return { x: r.x + r.w * t, y: r.y + r.h };
  if (side === 'left') return { x: r.x, y: r.y + r.h * t };
  return { x: r.x + r.w, y: r.y + r.h * t };
}
const center = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });
/** where the line from a rect's center toward a point leaves it */
function boundary(r, toward, round) {
  const c = center(r);
  const dx = toward.x - c.x;
  const dy = toward.y - c.y;
  if (!dx && !dy) return c;
  if (round) {
    const l = Math.hypot(dx, dy);
    const rad = Math.min(r.w, r.h) / 2;
    return { x: c.x + (dx / l) * rad, y: c.y + (dy / l) * rad };
  }
  const t = Math.min(r.w / 2 / Math.abs(dx || 1e-9), r.h / 2 / Math.abs(dy || 1e-9));
  return { x: c.x + dx * t, y: c.y + dy * t };
}
/** the sides two rects face each other with */
function facingSides(a, b) {
  const ca = center(a);
  const cb = center(b);
  const gapX = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w));
  const gapY = Math.max(b.y - (a.y + a.h), a.y - (b.y + b.h));
  const useX = gapX > 0 || gapY > 0 ? gapX >= gapY : Math.abs(cb.x - ca.x) >= Math.abs(cb.y - ca.y);
  if (useX) return cb.x >= ca.x ? ['right', 'left'] : ['left', 'right'];
  return cb.y >= ca.y ? ['bottom', 'top'] : ['top', 'bottom'];
}
/** does a polyline cross a rect (shrunk by 2px)? */
function crosses(pts, r) {
  const x0 = r.x + 2;
  const x1 = r.x + r.w - 2;
  const y0 = r.y + 2;
  const y1 = r.y + r.h - 2;
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1];
    const b = pts[i];
    if (Math.max(a.x, b.x) < x0 || Math.min(a.x, b.x) > x1 || Math.max(a.y, b.y) < y0 || Math.min(a.y, b.y) > y1) continue;
    return true; // axis-aligned segments: a bounding-box overlap is a crossing
  }
  return false;
}
/**
 * The sides an elbow leaves and enters by: every side pair is routed and
 * scored - crossing a node costs most, then bends and length; the pair the
 * rects face each other with wins ties. Fixed sides (api:left) are kept.
 */
function bestSides(a, b, fixedA, fixedB, obstacles, texts = []) {
  const [fa, fb] = facingSides(a.rect, b.rect);
  const sides = ['top', 'right', 'bottom', 'left'];
  let best = null;
  const pa0 = (sa) => sidePoint(a.rect, sa);
  const pb0 = (sb) => sidePoint(b.rect, sb);
  // a same-side route can also clear everything between the two ends: its outer line just past their extreme
  const clearing = (sa, sb) => {
    if (sa !== sb) return [null];
    const lo = Math.min(a.rect.x, b.rect.x) - 8;
    const hi = Math.max(a.rect.x + a.rect.w, b.rect.x + b.rect.w) + 8;
    const top = Math.min(a.rect.y, b.rect.y) - 8;
    const bot = Math.max(a.rect.y + a.rect.h, b.rect.y + b.rect.h) + 8;
    const between = obstacles.filter((o) => o !== a && o !== b && (horizontal(sa) ? o.rect.y < bot && o.rect.y + o.rect.h > top : o.rect.x < hi && o.rect.x + o.rect.w > lo));
    if (!between.length) return [null];
    if (sa === 'top') return [null, Math.min(...between.map((o) => o.rect.y), a.rect.y, b.rect.y) - 18];
    if (sa === 'bottom') return [null, Math.max(...between.map((o) => o.rect.y + o.rect.h), a.rect.y + a.rect.h, b.rect.y + b.rect.h) + 18];
    if (sa === 'left') return [null, Math.min(...between.map((o) => o.rect.x), a.rect.x, b.rect.x) - 18];
    return [null, Math.max(...between.map((o) => o.rect.x + o.rect.w), a.rect.x + a.rect.w, b.rect.x + b.rect.w) + 18];
  };
  for (const sa of fixedA ? [fixedA] : sides) {
    for (const sb of fixedB ? [fixedB] : sides) {
     for (const outer of clearing(sa, sb)) {
      const pts = elbow(pa0(sa), sa, pb0(sb), sb, 16, outer);
      let score = 0;
      for (let i = 1; i < pts.length; i++) score += Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y);
      score += (pts.length - 2) * 30;
      if (sa !== fa || sb !== fb) score += 12;
      for (const o of obstacles) {
        if (o === a || o === b || o.el.contains(a.el) || o.el.contains(b.el)) continue;
        const r = o.rect;
        if (crosses(pts, { x: r.x - 8, y: r.y - 8, w: r.w + 16, h: r.h + 16 })) score += 10000;
      }
      // nor through text (group labels, cells, a dot's label ...)
      for (const t of texts) if (crosses(pts, t)) score += 4000;
      // the route must not run back through its own ends
      if (crosses(pts.slice(1), a.rect) || crosses(pts.slice(0, -1), b.rect)) score += 5000;
      if (!best || score < best.score) best = { score, sa, sb, outer };
     }
    }
  }
  return [best.sa, best.sb, best.outer ?? null];
}
/** drop repeated and collinear points */
function simplify(points) {
  const out = [];
  for (const p of points) {
    const last = out[out.length - 1];
    if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5) continue;
    out.push(p);
    while (out.length >= 3) {
      const [a, b, c] = out.slice(-3);
      const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
      if (Math.abs(cross) > 0.5) break;
      out.splice(out.length - 2, 1);
    }
  }
  return out;
}
/** orthogonal route between two anchored points (sides give the exit direction) */
function elbow(a, sa, b, sb, gap = 16, outside = null) {
  const na = NORMAL[sa];
  const nb = NORMAL[sb];
  const pts = [a];
  if (horizontal(sa) && horizontal(sb)) {
    if (outside != null) {
      pts.push({ x: outside, y: a.y }, { x: outside, y: b.y });
    } else if (sa !== sb && (b.x - a.x) * na[0] > gap) {
      const mx = (a.x + b.x) / 2;
      pts.push({ x: mx, y: a.y }, { x: mx, y: b.y });
    } else if (sa === sb) {
      const x = sa === 'right' ? Math.max(a.x, b.x) + gap * 1.5 : Math.min(a.x, b.x) - gap * 1.5;
      pts.push({ x, y: a.y }, { x, y: b.y });
    } else {
      const x1 = a.x + na[0] * gap;
      const x2 = b.x + nb[0] * gap;
      const my = (a.y + b.y) / 2;
      pts.push({ x: x1, y: a.y }, { x: x1, y: my }, { x: x2, y: my }, { x: x2, y: b.y });
    }
  } else if (!horizontal(sa) && !horizontal(sb)) {
    if (outside != null) {
      pts.push({ x: a.x, y: outside }, { x: b.x, y: outside });
    } else if (sa !== sb && (b.y - a.y) * na[1] > gap) {
      const my = (a.y + b.y) / 2;
      pts.push({ x: a.x, y: my }, { x: b.x, y: my });
    } else if (sa === sb) {
      const y = sa === 'bottom' ? Math.max(a.y, b.y) + gap * 1.5 : Math.min(a.y, b.y) - gap * 1.5;
      pts.push({ x: a.x, y }, { x: b.x, y });
    } else {
      const y1 = a.y + na[1] * gap;
      const y2 = b.y + nb[1] * gap;
      const mx = (a.x + b.x) / 2;
      pts.push({ x: a.x, y: y1 }, { x: mx, y: y1 }, { x: mx, y: y2 }, { x: b.x, y: y2 });
    }
  } else if (horizontal(sa)) {
    const corner = { x: b.x, y: a.y };
    if ((corner.x - a.x) * na[0] > 0 && (corner.y - b.y) * nb[1] > 0) pts.push(corner);
    else {
      const a1 = { x: a.x + na[0] * gap, y: a.y };
      const b1 = { x: b.x, y: b.y + nb[1] * gap };
      pts.push(a1, { x: a1.x, y: b1.y }, b1);
    }
  } else {
    const corner = { x: a.x, y: b.y };
    if ((corner.y - a.y) * na[1] > 0 && (corner.x - b.x) * nb[0] > 0) pts.push(corner);
    else {
      const a1 = { x: a.x, y: a.y + na[1] * gap };
      const b1 = { x: b.x + nb[0] * gap, y: b.y };
      pts.push(a1, { x: b1.x, y: a1.y }, b1);
    }
  }
  pts.push(b);
  return simplify(pts);
}
// the elbow corner radius - set per draw from the theme's own rounding
let corner = 8;
/** an SVG path through points with rounded corners (radius r, default: the theme's) */
function roundedPath(pts, r = corner) {
  let d = `M${r1(pts[0].x)} ${r1(pts[0].y)}`;
  for (let i = 1; i < pts.length - 1; i++) {
    const p0 = pts[i - 1];
    const p = pts[i];
    const p1 = pts[i + 1];
    const l0 = Math.hypot(p.x - p0.x, p.y - p0.y);
    const l1 = Math.hypot(p1.x - p.x, p1.y - p.y);
    const rr = Math.min(r, l0 / 2, l1 / 2);
    if (rr < 0.5) {
      // a square theme: a plain corner
      d += ` L${r1(p.x)} ${r1(p.y)}`;
      continue;
    }
    const a = { x: p.x + ((p0.x - p.x) / l0) * rr, y: p.y + ((p0.y - p.y) / l0) * rr };
    const b = { x: p.x + ((p1.x - p.x) / l1) * rr, y: p.y + ((p1.y - p.y) / l1) * rr };
    d += ` L${r1(a.x)} ${r1(a.y)} Q${r1(p.x)} ${r1(p.y)} ${r1(b.x)} ${r1(b.y)}`;
  }
  const last = pts[pts.length - 1];
  return `${d} L${r1(last.x)} ${r1(last.y)}`;
}
/** the midpoint of a polyline's longest segment - where its label sits - and that segment's direction */
function labelSpot(pts) {
  let best = 0;
  let at = { x: pts[0].x, y: pts[0].y };
  let along: { x: number; y: number; len?: number } = { x: 1, y: 0 };
  for (let i = 1; i < pts.length; i++) {
    const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
    if (l > best) {
      best = l;
      at = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 };
      along = { x: Math.abs(pts[i].x - pts[i - 1].x) > 0.5 ? 1 : 0, y: Math.abs(pts[i].y - pts[i - 1].y) > 0.5 ? 1 : 0, len: l };
    }
  }
  return { at, along };
}
const unit = (from, to) => {
  const l = Math.hypot(to.x - from.x, to.y - from.y) || 1;
  return { x: (to.x - from.x) / l, y: (to.y - from.y) / l };
};
// -- ends ------------------------------------------------------------------------------------
// tip at 0,0 pointing +x; each part: [path, paint] - fill / stroke / hollow (paper fill + stroke)
const CROW = ['M-11 0L0 -6M-11 0L0 6M-11 0H0', 'stroke'];
const HEADS = {
  arrow: { parts: [['M0 0L-9 -4.5L-9 4.5Z', 'fill']], inset: 3 },
  open: { parts: [['M-8 -4.5L0 0L-8 4.5', 'stroke']], inset: 0 },
  triangle: { parts: [['M0 0L-12 -6.5L-12 6.5Z', 'hollow']], inset: 12 },
  'triangle-filled': { parts: [['M0 0L-12 -6.5L-12 6.5Z', 'fill']], inset: 4 },
  diamond: { parts: [['M0 0L-7 -4.5L-14 0L-7 4.5Z', 'fill']], inset: 4 },
  'diamond-open': { parts: [['M0 0L-7 -4.5L-14 0L-7 4.5Z', 'hollow']], inset: 14 },
  dot: { parts: [['M-7 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0', 'fill']], inset: 3 },
  one: { parts: [['M-7 -6V6', 'stroke']], inset: 0 },
  'one-one': { parts: [['M-7 -6V6M-12 -6V6', 'stroke']], inset: 0 },
  many: { parts: [CROW], inset: 0 },
  'one-many': { parts: [CROW, ['M-15 -6V6', 'stroke']], inset: 0 },
  'zero-many': { parts: [CROW, ['M-23 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'hollow']], inset: 0 },
  'zero-one': { parts: [['M-7 -6V6', 'stroke'], ['M-19 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'hollow']], inset: 0 },
  none: { parts: [], inset: 0 },
};
function svg(tag, attrs = {}) {
  const el = document.createElementNS(SVG_NS, tag);
  for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, String(v));
  return el;
}
function drawHead(group, kind, tip, dir) {
  const head = HEADS[kind] ?? HEADS.arrow;
  const angle = r1((Math.atan2(dir.y, dir.x) * 180) / Math.PI);
  for (const [d, paint] of head.parts) {
    group.append(svg('path', { class: 'diagram-wire-head', 'data-paint': paint, d, transform: `translate(${r1(tip.x)} ${r1(tip.y)}) rotate(${angle})` }));
  }
}
/** pull a polyline's end back along its last segment (the line stops at a hollow head) */
function inset(pts, by, atStart) {
  if (!by) return pts;
  const out = pts.map((p) => ({ ...p }));
  const [i, j] = atStart ? [0, 1] : [out.length - 1, out.length - 2];
  const u = unit(out[i], out[j]);
  const l = Math.hypot(out[j].x - out[i].x, out[j].y - out[i].y);
  const d = Math.min(by, l - 1);
  out[i] = { x: out[i].x + u.x * d, y: out[i].y + u.y * d };
  return out;
}
// -- drawing ---------------------------------------------------------------------------------
/** the panel a canvas belongs to ('before' / 'changes' / 'after'), or null */
const panelOf = (canvas) => canvas.closest('.diagram-panel')?.dataset.panel ?? null;
/** is an edge drawn in a panel, and between which ends */
function edgeIn(edge, panel) {
  const change = edge.dataset.change;
  if (panel === 'before' && change === 'added') return null;
  if (panel === 'after' && change === 'removed') return null;
  const before = panel === 'before';
  return {
    from: (before && edge.dataset.beforeFrom) || edge.dataset.from,
    to: (before && edge.dataset.beforeTo) || edge.dataset.to,
  };
}
function layer(canvas, cls, make, keep = false) {
  const existing = dfDollar(canvas).children(`.${cls}`).get(0);
  if (existing) {
    // keep: the children stay, marked stale, until reconcileWires() decides which survive
    if (keep) for (const c of dfDollar(existing).children().toArray()) c.setAttribute('data-stale', '');
    else dfDollar(existing).empty();
    return existing;
  }
  const el = make();
  dfDollar(canvas).append(el);
  return el;
}
/** measure every node of a canvas (canvas-local, unscaled px) */
function measure(canvas) {
  const box = canvas.getBoundingClientRect();
  const scale = box.width / (canvas.offsetWidth || box.width || 1) || 1;
  const ox = box.left + canvas.clientLeft * scale;
  const oy = box.top + canvas.clientTop * scale;
  const rectOf = (el) => {
    const r = el.getBoundingClientRect();
    return { x: (r.left - ox) / scale, y: (r.top - oy) / scale, w: r.width / scale, h: r.height / scale };
  };
  // a node mid enter-animation is painted 0.5rem low (diagram-enter translates it in); a wire
  // measured then ended low and jumped on the next draw - horizontal wires showed it. The stylesheet's
  // static translates (a dot's name, a tick) are layout and must count, so only that animation is
  // sampled at its end while measuring, then put back where it was - it keeps playing.
  // VERIFIED: (diagram.e2e "a node mid enter-animation") a wire drawn during the animation equals the one after it.
  const settling = [];
  for (const el of dfDollar(canvas).find('[data-step-state="current"]').toArray()) {
    for (const a of el.getAnimations()) {
      if ((a as CSSAnimation).animationName !== 'diagram-enter' || a.playState !== 'running' || !a.effect) continue;
      settling.push([a, a.currentTime]);
      a.currentTime = a.effect.getComputedTiming().endTime ?? 0;
    }
  }
  const nodes = new Map();
  for (const el of dfDollar(canvas).find('[data-node]').toArray()) {
    if (el.closest('.diagram-edges') || !el.getClientRects().length) continue;
    nodes.set(el.dataset.node, { el, rect: rectOf(el), round: ROUND.has(el.dataset.shape) });
  }
  for (const [a, t] of settling) a.currentTime = t;
  return { nodes, rectOf };
}
/**
 * A figure narrower than its diagram scales the canvas down to fit - never
 * below 0.7 (narrower still, the figure scrolls as before); data-fit="none"
 * keeps it 1:1. measure() already reads through a zoom, so the wires follow.
 */
const FIT_MIN = 0.7;
function fit(root, canvas) {
  if (canvas.parentElement !== root) return;
  canvas.style.zoom = '';
  if (root.dataset.fit === 'none') return;
  const natural = canvas.offsetWidth; // min-width: min-content - the diagram's own width
  const avail = root.clientWidth;
  const z = natural > avail + 1 ? Math.max(FIT_MIN, Math.floor((avail / natural) * 1000) / 1000) : 1;
  if (z < 1) canvas.style.zoom = String(z);
}
/**
 * Draw a canvas's wires: an SVG layer behind the nodes (z-index between groups
 * and nodes) and an HTML label layer above them. Re-run on every resize - the
 * layers are absolutely positioned, so drawing never changes the layout.
 */
function draw(root, canvas) {
  if (!canvas.isConnected || !canvas.getClientRects().length) return;
  // every draw starts from the stylesheet's padding: clear() below may grow it again
  if (!canvas._clearPass) {
    for (const side of SIDES) {
      canvas.style.removeProperty(`padding-${side}`);
      canvas.style.removeProperty(`--_inset-${side}`);
    }
  }
  fit(root, canvas);
  const { nodes, rectOf } = measure(canvas);
  const panel = panelOf(canvas);
  // what already entered: a wire or label that was 'current' before this draw. A draw can run
  // again within a step (a clearance pass, a resize, fonts loading) - a rebuilt element would
  // restart its enter animation (the wire drew twice, flickered); carrying data-step-entered
  // keeps it still. VERIFIED: (diagram.e2e "a redraw within a step") a redraw mid-step leaves
  // the current wire's animation count at zero and the wire fully drawn.
  const entered = new Set<string>();
  for (const el of dfDollar(canvas).find(':scope > .diagram-wires > .diagram-wire[data-step-state="current"], :scope > .diagram-wire-labels > [data-step-state="current"]').toArray()) {
    entered.add(`${el.tagName.toLowerCase()}|${el.dataset.edgeRef ?? ''}|${el.textContent ?? ''}`);
  }
  const wires = layer(canvas, 'diagram-wires', () => {
    const el = svg('svg', { class: 'diagram-wires', 'aria-hidden': 'true', focusable: 'false' });
    return el;
  }, true);
  const labels = layer(canvas, 'diagram-wire-labels', () => {
    const el = document.createElement('div');
    el.className = 'diagram-wire-labels';
    el.setAttribute('aria-hidden', 'true');
    return el;
  });
  wires.setAttribute('width', String(canvas.clientWidth));
  wires.setAttribute('height', String(canvas.clientHeight));
  const type = root.dataset.type;
  const list = edgeListOf(root, canvas);
  const drawn = [];
  // the elbow corners follow the theme's rounding (a square theme draws square elbows)
  const sample = dfDollar(canvas).find('.diagram-node:not([data-shape])').get(0) ?? canvas;
  corner = Math.min(14, Math.max(0, parseFloat(getComputedStyle(sample).borderTopLeftRadius) || 0) * 1.2);
  // text a wire or a label must not cover: node boxes, group labels, cells, tags, axes ...
  const texts = textRects(canvas, nodes, rectOf);
  const queue = [];
  // labels wait until every wire exists - then each finds a spot clear of nodes, text, other labels and other wires
  const label = (text, at, edge, cls = 'diagram-wire-label', along = null, g = null) => {
    if (text) queue.push({ text, at, edge, cls, along, g });
  };
  const wireGroup = (edge, extra: Record<string, unknown> & { beatOf?: HTMLElement } = {}) => {
    const g = svg('g', {
      class: 'diagram-wire',
      'data-line': edge?.dataset.line,
      'data-tone': edge?.dataset.tone,
      'data-step-state': edge?.dataset.stepState,
      'data-active-state': edge?.dataset.activeState,
      'data-edge-ref': edge ? edgeRef(edge) : null,
      'data-change': panel === 'changes' ? edge?.dataset.change : null,
      ...Object.fromEntries(Object.entries(extra).filter(([k]) => k !== 'beatOf')),
    });
    const i = (extra.beatOf ?? edge)?.style?.getPropertyValue('--step-i');
    if (i) g.style.setProperty('--step-i', i);
    if (g.dataset.stepState === 'current' && entered.has(`g|${g.dataset.edgeRef ?? ''}|`)) g.setAttribute('data-step-entered', '');
    wires.append(g);
    return g;
  };
  const interactive = root.hasAttribute('data-interactive') && !panel;
  const line = (g, d, solid) => {
    g.append(svg('path', { class: 'diagram-wire-line', d, pathLength: solid ? 1 : null }));
    // a wide invisible stroke: what a click on an interactive wire hits
    if (interactive && g.dataset.edgeRef) g.append(svg('path', { class: 'diagram-wire-hit', d }));
  };
  if (type === 'sequence') drawSequence(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn);
  else drawEdges(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn, texts);
  if (type === 'fishbone') drawBones(nodes, wireGroup, line);
  // the queued labels point at the groups just built - after the reconcile they must point at the
  // groups that stayed, or a label measures a detached wire (its own wire no longer excluded, it
  // landed elsewhere on every second draw, the clearance grew and shrank, the resize observer
  // drew again: a 3 Hz flicker on the deck's proof-loop slide)
  const kept = reconcileWires(wires);
  for (const item of queue) item.g = kept.get(item.g) ?? item.g;
  // a dot's name goes to the side its wires leave free; then the labels avoid it there
  sideDotLabels(nodes, wires, rectOf);
  labels._entered = entered;
  placeLabels(queue, labels, wires, nodes, textRects(canvas, nodes, rectOf));
  if (panel === 'changes') badges(canvas, labels, rectOf);
  flowTokens(labels, wires);
  if (clear(root, canvas, rectOf)) return; // grew its padding and drew again - that draw announced itself
  // Fires after a canvas's wires are drawn (init, resize, state change) - how many edges, and which delta panel ('before' / 'changes' / 'after', null outside a delta).
  root.dispatchEvent(new CustomEvent<DiagramDrawnDetail>('diagram-drawn', { detail: { edges: drawn.length, panel } }));
}
/**
 * A wire that did not change keeps its element. Every draw builds the wires anew, but the one
 * already on the layer that is identical to a new one (same edge, geometry, line, states) stays
 * and the new copy goes - its enter animation keeps running. A step's redraw (the next node
 * arriving) used to replace the wire still drawing in, which snapped it to complete; a resize or
 * fonts loading restarted it. Stale wires that differ (new geometry, another state) are removed.
 * VERIFIED: (diagram.e2e "a redraw within a step keeps the current wire element") the element
 * and its running animations are the same objects after a redraw.
 */
function reconcileWires(wires): Map<Element, Element> {
  // identity = the group's attributes (minus the two runtime marks) + its markup, read through df$
  const sig = (g) => [...g.attributes].filter((a) => a.name !== 'data-stale' && a.name !== 'data-step-entered').map((a) => `${a.name}=${a.value}`).join(' ') + '>' + dfDollar(g).html();
  const stale = new Map();
  const kept = new Map<Element, Element>(); // the dropped new group → the old one that stays
  for (const g of dfDollar(wires).children('[data-stale]').toArray()) stale.set(`${g.dataset.edgeRef ?? ''}|${sig(g)}`, g);
  for (const g of dfDollar(wires).children(':not([data-stale])').toArray()) {
    const key = `${g.dataset.edgeRef ?? ''}|${sig(g)}`;
    const old = stale.get(key);
    if (!old) continue;
    stale.delete(key);
    old.removeAttribute('data-stale');
    kept.set(g, old);
    g.remove();
  }
  for (const g of stale.values()) g.remove();
  return kept;
}
/**
 * The least distance (px, unscaled) anything drawn keeps from a framed
 * canvas's border. Zones reach into the canvas padding (their negative
 * margins wrap their nodes) and wire labels are placed around the boxes at
 * draw time, so no padding in the stylesheet can promise it for every
 * diagram. VERIFIED: (diagram.e2e "clearance") every framed fixture diagram
 * keeps it after a draw.
 */
const CLEARANCE = 20;
const SIDES = ['top', 'right', 'bottom', 'left'];
/**
 * After a draw: measure everything on the canvas - nodes, zones and their
 * labels, wire labels, wire paths - and grow the padding of each side that
 * comes closer than CLEARANCE, then draw once more on the new layout. A
 * frameless canvas (no border) has no edge to keep away from. True when it
 * redrew. Up to three passes: a canvas that fits its figure (zoom) re-scales
 * when its padding grows, so one pass can land a pixel short. Flow tokens
 * ride on their wires (animated, measured through the wire) - not counted.
 */
function clear(root, canvas, rectOf) {
  if ((canvas._clearPass ?? 0) >= 3) return false;
  const cs = getComputedStyle(canvas);
  if (!parseFloat(cs.borderTopWidth)) return false;
  const w = canvas.clientWidth;
  const h = canvas.clientHeight;
  const gap = { top: Infinity, right: Infinity, bottom: Infinity, left: Infinity };
  for (const el of dfDollar(canvas).find('*').toArray()) {
    if (el.matches('.diagram-wires, .diagram-wire-labels, .diagram-wire-hit, .diagram-token, g') || !el.getClientRects().length) continue;
    const r = rectOf(el);
    if (!r.w && !r.h) continue;
    gap.top = Math.min(gap.top, r.y);
    gap.left = Math.min(gap.left, r.x);
    gap.bottom = Math.min(gap.bottom, h - (r.y + r.h));
    gap.right = Math.min(gap.right, w - (r.x + r.w));
  }
  const grow = SIDES.filter((side) => gap[side] < CLEARANCE - 0.5);
  if (!grow.length) return false;
  // absolutely placed content ignores padding (its box is the padding box): grow the inset it is placed in
  const absolute = dfDollar(canvas).children().toArray().some((c) => getComputedStyle(c).position === 'absolute' && !c.matches('.diagram-wires, .diagram-wire-labels'));
  for (const side of grow) {
    const more = Math.ceil(CLEARANCE - gap[side]);
    if (absolute) canvas.style.setProperty(`--_inset-${side}`, `${Math.ceil(parseFloat(canvas.style.getPropertyValue(`--_inset-${side}`)) || 0) + more}px`);
    else canvas.style.setProperty(`padding-${side}`, `${Math.ceil(parseFloat(cs.getPropertyValue(`padding-${side}`))) + more}px`);
  }
  canvas._clearPass = (canvas._clearPass ?? 0) + 1;
  try {
    draw(root, canvas);
  } finally {
    canvas._clearPass -= 1;
  }
  return true;
}
/** the boxes of the text in a canvas that wires and labels keep clear of (canvas-local) */
function textRects(canvas, nodes, rectOf) {
  const out = [];
  for (const n of nodes.values()) {
    if (n.el.classList.contains('diagram-group') || n.el.dataset.shape === 'ghost') continue;
    if (ROUND.has(n.el.dataset.shape) || n.el.dataset.shape === 'event') {
      // a dot's label sits outside its box
      for (const t of dfDollar(n.el).find('.diagram-node-name, .diagram-node-eyebrow, .diagram-node-meta, .diagram-node-body').toArray()) if (t.getClientRects().length) out.push(rectOf(t));
    }
  }
  const sel = '.diagram-group-label, .diagram-cell, .diagram-tag, .diagram-tick, .diagram-rule > span, .diagram-axis > *, .diagram-phases > li, .diagram-band-labels > *, .diagram-note';
  for (const t of dfDollar(canvas).find(sel).toArray()) if (!t.closest(RUNTIME) && t.getClientRects().length) out.push(rectOf(t));
  return out;
}
/**
 * A dot (Wardley component, journey feeling) carries its name outside its
 * box - above by default. When a wire runs through it there, the name moves
 * below, left or right (data-label-side): the side that covers the fewest
 * wire points and nothing else. data-label-side in the markup is kept.
 */
function sideDotLabels(nodes, wires, rectOf) {
  const dots = [...nodes.values()].filter((n) => n.el.dataset.shape === 'dot' && dfDollar(n.el).children('.diagram-node-name').get(0));
  if (!dots.length) return;
  const points = [...wirePoints(wires).values()].flat();
  const others = [...nodes.values()].filter((n) => n.el.dataset.shape !== 'ghost' && !n.el.classList.contains('diagram-group'));
  for (const dot of dots) {
    if (dot.el.hasAttribute('data-label-side') && !dot.el._autoSide) continue;
    const text = dfDollar(dot.el).children('.diagram-node-name, .diagram-node-meta').toArray();
    let best = null;
    for (const side of ['above', 'below', 'right', 'left']) {
      dot.el.dataset.labelSide = side;
      let score = side === 'above' ? 0 : 1;
      for (const t of text) {
        const r = rectOf(t);
        for (const q of points) if (q.x > r.x - 2 && q.x < r.x + r.w + 2 && q.y > r.y - 2 && q.y < r.y + r.h + 2) score += 10;
        for (const o of others) {
          if (o === dot) continue;
          const ov = Math.max(0, Math.min(r.x + r.w, o.rect.x + o.rect.w) - Math.max(r.x, o.rect.x)) * Math.max(0, Math.min(r.y + r.h, o.rect.y + o.rect.h) - Math.max(r.y, o.rect.y));
          if (ov > 0) score += 20;
        }
      }
      if (!best || score < best.score) best = { side, score };
      if (score === 0) break;
    }
    dot.el._autoSide = true;
    dot.el.dataset.labelSide = best.side;
  }
}
/** points along every drawn wire, by wire group - what a label must not sit on */
function wirePoints(wires) {
  const out = new Map();
  for (const g of dfDollar(wires).children('.diagram-wire').toArray()) {
    const path = dfDollar(g).children<SVGPathElement>('.diagram-wire-line').get(0);
    if (!path?.getTotalLength) continue;
    const len = path.getTotalLength();
    const pts = [];
    for (let t = 0; t <= len; t += 5) pts.push(path.getPointAtLength(t));
    out.set(g, pts);
  }
  return out;
}
/**
 * Place the queued labels: each tries spots along its own segment (and just
 * beside it) and takes the one that covers the least - of nodes, text,
 * labels already placed and the OTHER wires (a label never sits on an arrow
 * that is not its own).
 */
function placeLabels(queue, layerEl, wires, nodes, texts) {
  if (!queue.length) return;
  const placed = [
    ...[...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost').map((n) => n.rect),
    ...texts,
  ];
  const points = wirePoints(wires);
  for (const item of queue) {
    const { text, at, edge, cls, along, g } = item;
    const span = document.createElement('span');
    span.className = cls;
    dfDollar(span).text(text);
    for (const name of ['tone', 'stepState', 'change', 'activeState']) if (edge?.dataset[name]) span.dataset[name] = edge.dataset[name];
    if (edge) span.dataset.edgeRef = edgeRef(edge);
    if (span.dataset.stepState === 'current' && layerEl._entered?.has(`span|${span.dataset.edgeRef ?? ''}|${text}`)) span.dataset.stepEntered = '';
    const i = edge?.style.getPropertyValue('--step-i');
    if (i) span.style.setProperty('--step-i', i);
    dfDollar(layerEl).append(span);
    const w = span.offsetWidth + 4;
    const h = span.offsetHeight + 2;
    const centered = cls === 'diagram-wire-label' || cls.includes('diagram-wire-end');
    const box = (q) => (centered ? { x: q.x - w / 2, y: q.y - h / 2, w, h } : { x: q.x, y: q.y - h / 2, w, h });
    const cover = (b) => {
      let sum = 0;
      for (const o of placed) sum += Math.max(0, Math.min(b.x + b.w, o.x + o.w) - Math.max(b.x, o.x)) * Math.max(0, Math.min(b.y + b.h, o.y + o.h) - Math.max(b.y, o.y));
      for (const [wg, pts] of points) {
        if (wg === g) continue;
        for (const q of pts) if (q.x > b.x - 1 && q.x < b.x + b.w + 1 && q.y > b.y - 1 && q.y < b.y + b.h + 1) sum += 40;
      }
      return sum;
    };
    let pos = at;
    const dir = along ?? { x: 1, y: 0, len: 0 };
    const step = dir.x ? w * 0.6 + 6 : h + 4;
    const across = dir.x ? { x: 0, y: h / 2 + 5 } : { x: w / 2 + 6, y: 0 };
    // slide only within the segment the label belongs to
    const room = Math.max(0, (dir.len ?? Infinity) / 2 - (dir.x ? w / 2 : h / 2));
    let least = Infinity;
    for (const [k, j] of [[0, 0], [1, 0], [-1, 0], [2, 0], [-2, 0], [0, -1], [0, 1], [1, -1], [-1, -1], [1, 1], [-1, 1], [3, 0], [-3, 0]]) {
      if (Math.abs(k * step) > room) continue;
      if (!along && (k || j)) continue;
      const q = { x: at.x + dir.x * k * step + across.x * j, y: at.y + dir.y * k * step + across.y * j };
      const c = cover(box(q)) + (k || j ? 1 : 0);
      if (c < least) {
        least = c;
        pos = q;
      }
      if (c <= 1) break;
    }
    placed.push(box(pos));
    span.style.left = `${r1(pos.x)}px`;
    span.style.top = `${r1(pos.y)}px`;
  }
}
/** every edge of a canvas: anchors, distributed ports, route, ends, label */
function drawEdges(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn, texts = []) {
  const plans = [];
  // nodes a route should not cross (groups are zones - wires pass over them)
  const fig = canvas.closest('.diagram');
  const busRoute = fig?.dataset.route === 'bus' || fig?.dataset.type === 'organigram';
  const obstacles = [...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost');
  for (const edge of edgesIn(list)) {
    const ends = edgeIn(edge, panel);
    if (!ends) continue;
    const from = parseEnd(ends.from);
    const to = parseEnd(ends.to);
    const a = nodes.get(from.id);
    const b = nodes.get(to.id);
    if (!a || !b) continue;
    const curve = edge.dataset.curve || 'elbow';
    const plan: { edge: HTMLElement; from: typeof from; to: typeof to; a: typeof a; b: typeof b; curve: string; sa?: string; sb?: string; outer?: boolean } = { edge, from, to, a, b, curve };
    if (a === b) plan.curve = 'self';
    else if (curve === 'around') {
      plan.sa = from.side || 'right';
      plan.sb = to.side || plan.sa;
    } else if (curve === 'elbow' && busRoute && !from.side && !to.side && Math.abs(center(a.rect).y - center(b.rect).y) > a.rect.h / 2) {
      // a bus (organigram): parents drop from the bottom, children are entered from the top
      [plan.sa, plan.sb] = center(b.rect).y > center(a.rect).y ? ['bottom', 'top'] : ['top', 'bottom'];
    } else if (curve === 'elbow') {
      [plan.sa, plan.sb, plan.outer] = bestSides(a, b, from.side, to.side, obstacles, texts);
    }
    plans.push(plan);
  }
  // distribute ports: edges sharing a node side fan out, ordered by where they go
  // (bus routing - an organigram - shares them: one trunk per parent)
  const bus = canvas.closest('.diagram')?.dataset.route === 'bus' || canvas.closest('.diagram')?.dataset.type === 'organigram';
  const ports = new Map();
  for (const plan of plans) {
    if (!plan.sa || bus) continue;
    for (const [end, node, side, other] of [['a', plan.a, plan.sa, plan.b], ['b', plan.b, plan.sb, plan.a]]) {
      if ((end === 'a' ? plan.from : plan.to).field) continue;
      const key = `${node.el.dataset.node}|${side}`;
      if (!ports.has(key)) ports.set(key, []);
      ports.get(key).push({ plan, end, node, side, toward: center(other.rect) });
    }
  }
  for (const group of ports.values()) {
    const along = horizontal(group[0].side) ? 'y' : 'x';
    group.sort((p, q) => p.toward[along] - q.toward[along]);
    const r = group[0].node.rect;
    const length = horizontal(group[0].side) ? r.h : r.w;
    const step = Math.min(18, (length * 0.8) / Math.max(1, group.length));
    group.forEach((p, i) => {
      const offset = (i - (group.length - 1) / 2) * step;
      const t = 0.5 + offset / length;
      p.plan[p.end === 'a' ? 'pa' : 'pb'] = sidePoint(r, p.side, t);
    });
  }
  const fieldPoint = (node, field, toward) => {
    const row = dfDollar(node.el).find(`[data-field="${field}"]`).get(0);
    if (!row) return null;
    const rr = rectOf(row);
    const left = toward.x < node.rect.x + node.rect.w / 2;
    return { point: { x: left ? node.rect.x : node.rect.x + node.rect.w, y: rr.y + rr.h / 2 }, side: left ? 'left' : 'right' };
  };
  const solid = [...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost');
  const outside = Math.max(0, ...solid.map((n) => n.rect.x + n.rect.w)) + 24;
  const outsideLeft = Math.min(...solid.map((n) => n.rect.x)) - 24;
  // pass 1: the orthogonal routes
  for (const plan of plans) {
    if (!plan.sa) continue;
    const { a, b } = plan;
    let pa = plan.pa ?? sidePoint(a.rect, plan.sa);
    let pb = plan.pb ?? sidePoint(b.rect, plan.sb);
    let sa = plan.sa;
    let sb = plan.sb;
    if (plan.from.field) {
      const f = fieldPoint(a, plan.from.field, center(b.rect));
      if (f) ({ point: pa, side: sa } = f);
    }
    if (plan.to.field) {
      const f = fieldPoint(b, plan.to.field, pa);
      if (f) ({ point: pb, side: sb } = f);
    }
    if (plan.from.field && plan.to.field && sa !== sb && (pb.x - pa.x) * NORMAL[sa][0] <= 16) {
      sb = sa; // two tables side by side: both leave on the near side
      pb = { x: sa === 'left' ? b.rect.x : b.rect.x + b.rect.w, y: pb.y };
    }
    // a near-straight route with one fanned end: move the lone end, not the line
    const facing = sa !== sb && horizontal(sa) === horizontal(sb);
    const axis = horizontal(sa) ? 'y' : 'x';
    if (facing && !plan.from.field && !plan.to.field && Math.abs(pa[axis] - pb[axis]) < 14) {
      const lone = (node, side) => (ports.get(`${node.el.dataset.node}|${side}`)?.length ?? 1) === 1;
      const within = (r, v) => (axis === 'y' ? v > r.y + 4 && v < r.y + r.h - 4 : v > r.x + 4 && v < r.x + r.w - 4);
      if (lone(a, sa) && within(a.rect, pb[axis])) pa = { ...pa, [axis]: pb[axis] };
      else if (lone(b, sb) && within(b.rect, pa[axis])) pb = { ...pb, [axis]: pa[axis] };
    }
    plan.raw = elbow(pa, sa, pb, sb, 16, plan.curve === 'around' ? outside : (sa === plan.sa && sb === plan.sb ? plan.outer ?? null : null));
    // an 'around' route without fixed sides takes the outer side that crosses fewer nodes
    if (plan.curve === 'around' && !plan.from.side && !plan.to.side) {
      const hits = (pts) => solid.filter((o) => o !== a && o !== b && crosses(pts, o.rect)).length;
      const left = elbow(sidePoint(a.rect, 'left'), 'left', sidePoint(b.rect, 'left'), 'left', 16, outsideLeft);
      if (hits(left) < hits(plan.raw)) plan.raw = left;
    }
  }
  if (!bus) spreadChannels(plans.filter((plan) => plan.raw));
  // pass 2: draw
  for (const plan of plans) {
    const { edge, a, b } = plan;
    let pts;
    let dEnd;
    let dStart;
    let at;
    let path;
    const head = edge.dataset.head || 'arrow';
    const tail = edge.dataset.tail || 'none';
    if (plan.curve === 'self') {
      const r = a.rect;
      const p1 = { x: r.x + r.w * 0.68, y: r.y };
      const p2 = { x: r.x + r.w * 0.32, y: r.y };
      path = `M${r1(p1.x)} ${r1(p1.y)} C${r1(p1.x + 6)} ${r1(r.y - 30)} ${r1(p2.x - 6)} ${r1(r.y - 30)} ${r1(p2.x)} ${r1(p2.y)}`;
      at = { x: p1.x + 8, y: r.y - 18 };
      plan.selfLabel = true;
      pts = [p1, p2];
      dEnd = { x: 0.25, y: 1 };
      dStart = { x: 0.25, y: 1 };
    } else if (plan.curve === 'straight' || plan.curve === 'curve') {
      const ca = center(a.rect);
      const cb = center(b.rect);
      if (plan.curve === 'straight') {
        const pa = boundary(a.rect, cb, a.round);
        const pb = boundary(b.rect, ca, b.round);
        pts = inset(inset([pa, pb], HEADS[tail]?.inset ?? 0, true), HEADS[head]?.inset ?? 0, false);
        path = roundedPath(pts);
        at = { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 };
        dEnd = unit(pa, pb);
        dStart = unit(pb, pa);
        pts = [pa, pb];
      } else {
        const len = Math.hypot(cb.x - ca.x, cb.y - ca.y) || 1;
        let n = { x: -(cb.y - ca.y) / len, y: (cb.x - ca.x) / len };
        const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 };
        const c0 = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 };
        if (n.x * (mid.x - c0.x) + n.y * (mid.y - c0.y) < 0) n = { x: -n.x, y: -n.y };
        const bend = parseFloat(edge.dataset.bend ?? '0.22');
        const ctrl = { x: mid.x + n.x * len * bend, y: mid.y + n.y * len * bend };
        const pa = boundary(a.rect, ctrl, a.round);
        const pb = boundary(b.rect, ctrl, b.round);
        dEnd = unit(ctrl, pb);
        dStart = unit(ctrl, pa);
        const ib = HEADS[head]?.inset ?? 0;
        const pbi = { x: pb.x - dEnd.x * ib, y: pb.y - dEnd.y * ib };
        path = `M${r1(pa.x)} ${r1(pa.y)} Q${r1(ctrl.x)} ${r1(ctrl.y)} ${r1(pbi.x)} ${r1(pbi.y)}`;
        at = { x: 0.25 * pa.x + 0.5 * ctrl.x + 0.25 * pb.x, y: 0.25 * pa.y + 0.5 * ctrl.y + 0.25 * pb.y };
        pts = [pa, pb];
      }
    } else {
      const raw = plan.raw;
      ({ at, along: plan.along } = labelSpot(raw));
      dEnd = unit(raw[raw.length - 2], raw[raw.length - 1]);
      dStart = unit(raw[1], raw[0]);
      pts = inset(inset(raw, HEADS[tail]?.inset ?? 0, true), HEADS[head]?.inset ?? 0, false);
      path = roundedPath(pts);
      pts = raw;
    }
    const g = wireGroup(edge);
    line(g, path, !edge.dataset.line);
    const tip = plan.curve === 'self' ? pts[1] : pts[pts.length - 1];
    if (head !== 'none') drawHead(g, head, tip, dEnd);
    if (tail !== 'none') drawHead(g, tail, pts[0], dStart);
    g.dataset.path = path;
    copyFlow(edge, g, '2400');
    label(panel === 'before' && edge.dataset.before != null ? edge.dataset.before : edge.dataset.label ?? edge.textContent.trim(), at, edge, plan.selfLabel ? 'diagram-wire-label diagram-wire-self' : undefined, plan.along ?? null, g);
    const endLabel = (text, p, dir) => {
      if (!text) return;
      const perp = { x: -dir.y, y: dir.x };
      label(text, { x: p.x - dir.x * 16 + perp.x * 9, y: p.y - dir.y * 16 + perp.y * 9 }, edge, 'diagram-wire-label diagram-wire-end', { x: Math.abs(perp.x) > 0.5 ? 1 : 0, y: Math.abs(perp.y) > 0.5 ? 1 : 0, len: 72 }, g);
    };
    endLabel(edge.dataset.fromLabel, pts[0], dStart);
    endLabel(edge.dataset.toLabel, tip, dEnd);
    drawn.push(edge);
  }
}
/**
 * Parallel middle segments that share a channel (the same gap between two
 * columns or rows) fan out 10px apart, so no two connectors overlap.
 */
function spreadChannels(plans) {
  const channels = new Map();
  for (const plan of plans) {
    const r = plan.raw;
    if (r.length !== 4) continue;
    const vertical = Math.abs(r[1].x - r[2].x) < 0.5;
    const key = vertical ? `v${Math.round(r[1].x / 8)}` : `h${Math.round(r[1].y / 8)}`;
    if (!channels.has(key)) channels.set(key, []);
    channels.get(key).push({ plan, vertical });
  }
  for (const group of channels.values()) {
    if (group.length < 2) continue;
    const along = group[0].vertical ? 'y' : 'x';
    group.sort((p, q) => Math.min(p.plan.raw[1][along], p.plan.raw[2][along]) - Math.min(q.plan.raw[1][along], q.plan.raw[2][along]));
    group.forEach(({ plan, vertical }, i) => {
      const offset = (i - (group.length - 1) / 2) * 10;
      const axis = vertical ? 'x' : 'y';
      plan.raw = plan.raw.map((pt, j) => (j === 1 || j === 2 ? { ...pt, [axis]: pt[axis] + offset } : pt));
    });
  }
}
/** a sequence: lifelines under the participants, one message per list row */
function drawSequence(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn) {
  const bottom = canvas.clientHeight - CLEARANCE;
  for (const node of nodes.values()) {
    if (node.el.dataset.shape === 'ghost') continue;
    const c = center(node.rect);
    const g = wireGroup(null, { 'data-lifeline': '', 'data-step-state': node.el.dataset.stepState, 'data-active-state': node.el.dataset.activeState, beatOf: node.el });
    line(g, `M${r1(c.x)} ${r1(node.rect.y + node.rect.h)} V${r1(bottom)}`, false);
  }
  for (const edge of edgesIn(list)) {
    const ends = edgeIn(edge, panel);
    if (!ends) continue;
    const a = nodes.get(parseEnd(ends.from).id);
    const b = nodes.get(parseEnd(ends.to).id);
    if (!a || !b) continue;
    const row = rectOf(edge);
    const y = row.y + row.h * 0.62;
    const x1 = center(a.rect).x;
    const x2 = center(b.rect).x;
    const g = wireGroup(edge);
    const head = edge.dataset.head || (edge.dataset.line === 'dashed' ? 'open' : 'arrow');
    if (a === b) {
      line(g, `M${r1(x1)} ${r1(y - 8)} H${r1(x1 + 30)} V${r1(y + 8)} H${r1(x1 + 4)}`, !edge.dataset.line);
      drawHead(g, head, { x: x1 + 1, y: y + 8 }, { x: -1, y: 0 });
      label(edge.textContent.trim(), { x: x1 + 36, y }, edge, 'diagram-wire-label diagram-wire-self', null, g);
    } else {
      const dir = x2 > x1 ? 1 : -1;
      const ib = HEADS[head]?.inset ?? 0;
      line(g, `M${r1(x1)} ${r1(y)} H${r1(x2 - dir * ib)}`, !edge.dataset.line);
      drawHead(g, head, { x: x2, y }, { x: dir, y: 0 });
      // a message label may slide along its message, clear of the lifelines it passes
      label(edge.textContent.trim(), { x: (x1 + x2) / 2, y: y - 11 }, edge, 'diagram-wire-label', { x: 1, y: 0, len: Math.abs(x2 - x1) - 16 }, g);
      g.dataset.path = `M${r1(x1)} ${r1(y)} H${r1(x2)}`;
      copyFlow(edge, g, '1600');
    }
    drawn.push(edge);
  }
}
/** a fishbone: the spine into the effect, one 60° bone per [data-bone] category */
function drawBones(nodes, wireGroup, line) {
  const effect = [...nodes.values()].find((n) => n.el.hasAttribute('data-effect'));
  if (!effect) return;
  const bones = [...nodes.values()].filter((n) => n.el.hasAttribute('data-bone'));
  const y = effect.rect.y + effect.rect.h / 2;
  const x0 = Math.min(effect.rect.x, ...bones.map((n) => n.rect.x)) - 8;
  const spine = wireGroup(null, { 'data-spine': '', 'data-tone': effect.el.dataset.tone });
  line(spine, `M${r1(x0)} ${r1(y)} H${r1(effect.rect.x - 3)}`, true);
  drawHead(spine, 'arrow', { x: effect.rect.x, y }, { x: 1, y: 0 });
  for (const bone of bones) {
    const above = bone.rect.y + bone.rect.h / 2 < y;
    const from = { x: bone.rect.x + bone.rect.w / 2, y: above ? bone.rect.y + bone.rect.h : bone.rect.y };
    const dy = Math.abs(y - from.y);
    const to = { x: from.x + dy * 0.577, y };
    const g = wireGroup(null, { 'data-bone': '', 'data-tone': bone.el.dataset.tone, 'data-step-state': bone.el.dataset.stepState, 'data-active-state': bone.el.dataset.activeState, beatOf: bone.el });
    line(g, `M${r1(from.x)} ${r1(from.y)} L${r1(to.x)} ${r1(to.y)}`, true);
  }
}
const BADGE = { added: '+', removed: '−', changed: 'Δ', moved: '→', rewired: '⇄' };
/** the change badges of the changes panel: one chip on each changed node or group */
function badges(canvas, labels, rectOf) {
  for (const el of dfDollar(canvas).find('[data-change]').toArray()) {
    if (el.closest('.diagram-edges, .diagram-wires, .diagram-wire-labels') || !el.getClientRects().length || !BADGE[el.dataset.change]) continue;
    const r = rectOf(el);
    const chip = document.createElement('span');
    chip.className = 'diagram-badge';
    chip.dataset.change = el.dataset.change;
    dfDollar(chip).text(BADGE[el.dataset.change]);
    chip.style.left = `${r1(r.x + r.w)}px`;
    chip.style.top = `${r1(r.y)}px`;
    dfDollar(labels).append(chip);
  }
}
/** an edge's flow settings onto its wire: duration (data-flow), tokens on it (data-flow-tokens), start offset (data-flow-delay) */
function copyFlow(edge, g, fallback) {
  if (edge.dataset.flow == null) return;
  g.dataset.flow = edge.dataset.flow || fallback;
  if (edge.dataset.flowTokens) g.dataset.flowTokens = edge.dataset.flowTokens;
  if (edge.dataset.flowDelay) g.dataset.flowDelay = edge.dataset.flowDelay;
}
/**
 * The flow tokens: data-flow-tokens dots (default 1) per data-flow wire,
 * evenly spaced, travelling its path - a whole pipeline can flow at once,
 * data-flow-delay offsets a wire so tokens hand over from one to the next.
 * Hidden on future steps and dimmed wires; never under reduced motion.
 */
function flowTokens(labels, wires) {
  if (reducedMotion()) return;
  for (const g of dfDollar(wires).children('[data-flow]').toArray()) {
    if (g.dataset.stepState === 'future' || g.dataset.activeState === 'dimmed') continue;
    const duration = Math.max(800, parseInt(g.dataset.flow, 10) || 2400);
    const count = Math.max(1, Math.min(8, parseInt(g.dataset.flowTokens, 10) || 1));
    const delay = parseInt(g.dataset.flowDelay, 10) || 0;
    for (let i = 0; i < count; i++) {
      const token = document.createElement('span');
      token.className = 'diagram-token';
      if (g.dataset.tone) token.dataset.tone = g.dataset.tone;
      token.style.offsetPath = `path('${g.dataset.path}')`;
      dfDollar(labels).append(token);
      const once = g.dataset.stepState === 'current';
      token.animate?.([{ offsetDistance: '0%', opacity: 0 }, { opacity: 1, offset: 0.08 }, { opacity: 1, offset: 0.92 }, { offsetDistance: '100%', opacity: 0 }], {
        duration,
        delay: once ? delay + (i * duration) / count : delay - (i * duration) / count,
        iterations: once ? 1 : Infinity,
        easing: 'linear',
        fill: 'backwards',
      });
    }
  }
}
// -- delta -----------------------------------------------------------------------------------
const PANEL_TITLES = ['Before', 'Changes', 'After'];
const CHANGE_WORD = { added: 'Added', removed: 'Removed', changed: 'Changed', moved: 'Moved', rewired: 'Rewired' };
/** one panel's copy of the canvas: what that snapshot shows */
function panelCanvas(source, panel) {
  const copy = source.cloneNode(true);
  for (const el of [copy, ...dfDollar(copy).find('[id]').toArray()]) el.removeAttribute('id');
  dfDollar(copy).find('.diagram-wires, .diagram-wire-labels').remove();
  for (const el of dfDollar(copy).find('[data-change]').toArray()) {
    const change = el.dataset.change;
    if ((panel === 'before' && change === 'added') || (panel === 'after' && change === 'removed')) el.setAttribute('data-delta-hidden', '');
  }
  if (panel === 'before') {
    for (const el of dfDollar(copy).find('[data-before]').toArray()) dfDollar(el).text(el.dataset.before);
    for (const el of [copy, ...dfDollar(copy).find('[data-before-style]').toArray()]) {
      if (el.dataset.beforeStyle != null) el.setAttribute('style', el.dataset.beforeStyle);
    }
    // a status, tone, badge or shape that changed: data-before-status="skip" ...
    for (const key of ['status', 'tone', 'badge', 'shape']) {
      for (const el of dfDollar(copy).find(`[data-before-${key}]`).toArray()) {
        const old = el.getAttribute(`data-before-${key}`);
        dfDollar(el).attr(`data-${key}`, old === '' ? null : old);
      }
    }
  }
  return copy;
}
/** the change ledger: one entry per changed element, edge or cell */
function ledger(root, canvas) {
  const names = new Map(authored(canvas, '[data-node]').map((n) => [n.dataset.node, nameOf(n)]));
  const endName = (v) => names.get(parseEnd(v).id) || parseEnd(v).id;
  const list = edgeListOf(root, canvas);
  const items = [...authored(canvas, '[data-change]'), ...(list && !canvas.contains(list) ? edgesIn(list).filter((e) => e.dataset.change) : [])];
  const entries = [];
  for (const el of items) {
    const change = el.dataset.change;
    if (!CHANGE_WORD[change]) continue;
    const isEdge = el.classList.contains('diagram-edge');
    let subject = nameOf(el);
    if (isEdge) subject = `${endName(el.dataset.from)} → ${endName(el.dataset.to)}${el.textContent.trim() ? ` (${el.textContent.trim()})` : ''}`;
    let note = el.dataset.changeNote || '';
    if (!note && change === 'changed') {
      note = [el, ...dfDollar(el).find('[data-before]').toArray()].filter((n) => n.dataset.before != null).map((n) => `${n.dataset.before} → ${n.textContent.trim()}`).join(' · ');
    }
    if (!note && change === 'rewired' && isEdge) note = `was ${endName(el.dataset.beforeFrom || el.dataset.from)} → ${endName(el.dataset.beforeTo || el.dataset.to)}`;
    entries.push({ change, subject, note });
  }
  return entries;
}
/**
 * The delta view (data-delta): three panels from the one annotated canvas -
 * Before (added things hidden, data-before values), Changes (everything,
 * changes marked, the ledger) and After (removed things hidden). Rebuilt on
 * every state change, so a played diagram plays in all three.
 */
function rebuildDelta(root) {
  const source = dfDollar(root).children('.diagram-canvas').get(0);
  let host = dfDollar(root).children('.diagram-delta').get(0);
  if (!root.hasAttribute('data-delta') || !source) {
    if (host) dfDollar(host).remove();
    return;
  }
  if (!host) {
    host = document.createElement('div');
    host.className = 'diagram-delta';
    source.after(host);
  }
  dfDollar(host).empty();
  const titles = (root.dataset.deltaLabels || '').split('|').map((s) => s.trim());
  const entries = ledger(root, source);
  ['before', 'changes', 'after'].forEach((panel, i) => {
    const section = document.createElement('section');
    section.className = 'diagram-panel';
    section.dataset.panel = panel;
    const title = document.createElement('h4');
    title.className = 'diagram-panel-title';
    dfDollar(title).text(titles[i] || PANEL_TITLES[i]);
    if (panel === 'changes') {
      const count = document.createElement('span');
      count.className = 'diagram-panel-count';
      dfDollar(count).text(String(entries.length));
      dfDollar(title).append(count);
    }
    const body = document.createElement('div');
    body.className = 'diagram-panel-body';
    dfDollar(body).append(panelCanvas(source, panel));
    dfDollar(section).append(title).append(body);
    if (panel === 'changes' && entries.length) {
      const ol = document.createElement('ol');
      ol.className = 'diagram-ledger';
      for (const entry of entries) {
        const li = document.createElement('li');
        li.className = 'diagram-ledger-item';
        li.dataset.change = entry.change;
        const kind = document.createElement('span');
        kind.className = 'diagram-ledger-kind';
        dfDollar(kind).text(`${BADGE[entry.change]} ${CHANGE_WORD[entry.change]}`);
        const subject = document.createElement('strong');
        dfDollar(subject).text(entry.subject);
        dfDollar(li).append(kind).append(subject);
        if (entry.note) {
          const note = document.createElement('span');
          note.className = 'diagram-ledger-note';
          dfDollar(note).text(entry.note);
          dfDollar(li).append(note);
        }
        dfDollar(ol).append(li);
      }
      dfDollar(section).append(ol);
    }
    dfDollar(host).append(section);
  });
  root._observer?.observe(host);
}
// -- controls --------------------------------------------------------------------------------
const ICONS = {
  prev: '<path d="m15 18-6-6 6-6"/>',
  next: '<path d="m9 18 6-6-6-6"/>',
  play: '<path d="M6 4l14 8-14 8z"/>',
  pause: '<path d="M7 4v16M17 4v16"/>',
  replay: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
  all: '<path d="M4 6h16M4 12h16M4 18h16"/>',
};
function button(action, text, label?) {
  const b = document.createElement('button');
  b.type = 'button';
  b.className = 'btn';
  b.dataset.variant = 'outline';
  b.dataset.size = 'sm';
  b.dataset.action = action;
  if (label) b.setAttribute('aria-label', label);
  const icon = svg('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
  for (const d of ICONS[action].match(/d="[^"]+"/g)) icon.append(svg('path', { d: d.slice(3, -1) }));
  b.append(icon);
  if (text) {
    const span = document.createElement('span');
    dfDollar(span).text(text);
    b.append(span);
  }
  return b;
}
/** the step toolbar (data-steps): previous / play-pause / next / replay / show all + a status line */
function makeControls(root) {
  const bar = document.createElement('div');
  bar.className = 'diagram-controls';
  bar.setAttribute('role', 'toolbar');
  bar.setAttribute('aria-label', 'Diagram steps');
  bar.append(button('prev', '', 'Previous step'), button('play', 'Play'), button('next', '', 'Next step'), button('replay', 'Replay'), button('all', 'Show all'));
  const status = document.createElement('output');
  status.className = 'diagram-status';
  status.setAttribute('aria-live', 'polite');
  bar.append(status);
  dfDollar(bar).on('click', (e) => {
    const action = (e.target as HTMLElement).closest?.<HTMLElement>('[data-action]')?.dataset.action;
    if (action) control(root, action);
  });
  dfDollar(bar).on('keydown', (e) => {
    const map = { ArrowLeft: 'prev', ArrowRight: 'next', Home: 'first', End: 'all', r: 'replay', R: 'replay', ' ': 'toggle' };
    const action = map[e.key];
    if (!action || (e.key === ' ' && (e.target as HTMLElement).closest?.('button'))) return;
    e.preventDefault();
    control(root, action);
  });
  const canvas = dfDollar(root).children('.diagram-canvas').get(0);
  const after = dfDollar(root).children('.diagram-delta').get(0) ?? canvas;
  if (after) after.after(bar);
  else dfDollar(root).append(bar);
  return bar;
}
function updateControls(root) {
  const bar = root._controls;
  if (!bar) return;
  const { max } = stepPlan(root);
  // the state being shown (the store records it only after the DOM work)
  const state = root._shown ?? root.store?.value ?? { name: 'default', config: {} };
  const playing = state.name === 'playing';
  const step = !stepping(state) ? max : stateStep(state, max);
  const play = dfDollar(bar).find('[data-action="play"]').get(0);
  if (play) {
    const swap = dfDollar(play).find('svg').get(0);
    if (swap) {
      dfDollar(swap).empty();
      for (const d of ICONS[playing ? 'pause' : 'play'].match(/d="[^"]+"/g)) swap.append(svg('path', { d: d.slice(3, -1) }));
    }
    dfDollar(play).find('span').text(playing ? 'Pause' : 'Play');
    play.setAttribute('aria-pressed', String(playing));
  }
  dfDollar(bar).find('[data-action="prev"]').prop('disabled', stepping(state) && step <= 1);
  dfDollar(bar).find('[data-action="next"]').prop('disabled', !stepping(state) || step >= max);
  const status = dfDollar(bar).find('.diagram-status');
  if (!stepping(state)) status.text(`Complete · ${max} ${max === 1 ? 'step' : 'steps'}`);
  else {
    const name = stepLabel(root, step);
    status.text(`Step ${step} of ${max}${name ? ` · ${name}` : ''}`);
  }
}
/** a toolbar action, through the State API */
function control(root, action) {
  const { max } = stepPlan(root);
  const state = root._shown ?? root.store?.value ?? { name: 'default', config: {} };
  const step = !stepping(state) ? max : stateStep(state, max);
  const api = root.api;
  if (action === 'toggle') action = state.name === 'playing' ? 'pause' : 'play';
  if (action === 'play' && state.name === 'playing') action = 'pause';
  if (action === 'pause') api.setState('paused', { step });
  else if (action === 'play') api.setState('playing', { step: state.name === 'paused' && step < max ? step + 1 : 1 });
  else if (action === 'replay') api.setState('playing', { step: 1 });
  else if (action === 'first') api.setState('paused', { step: 1 });
  else if (action === 'all') api.setState('default');
  else if (action === 'prev') api.setState('paused', { step: !stepping(state) ? max - 1 : Math.max(1, step - 1) });
  else if (action === 'next') api.setState('paused', { step: Math.min(max, step + 1) });
}
// -- state -----------------------------------------------------------------------------------
/** a time custom property in ms - '900ms', '0.9s' (a minifier's spelling) or a bare number */
function msOf(root, name, fallback) {
  const raw = getComputedStyle(root).getPropertyValue(name).trim();
  const n = parseFloat(raw);
  if (!Number.isFinite(n)) return fallback;
  return raw.endsWith('ms') ? n : raw.endsWith('s') ? n * 1000 : n;
}
/** how long a step stays: its elements appear one a beat, then it rests for --diagram-hold */
function holdOf(root, state) {
  const { stepOf, max } = stepPlan(root);
  const step = stateStep(state, max);
  let boxes = 0;
  stepOf.forEach((s, el) => {
    if (s === step && !el.classList.contains('diagram-edge')) boxes++;
  });
  const element = msOf(root, '--diagram-element-ms', 900);
  const appear = msOf(root, '--diagram-step-ms', 800);
  return Math.max(0, boxes - 1) * element + appear + msOf(root, '--diagram-hold', 1200);
}
/** redraw everything a state shows: the delta panels, every canvas's wires, the toolbar */
function sync(root) {
  rebuildDelta(root);
  for (const canvas of canvasesOf(root)) draw(root, canvas);
  updateControls(root);
}
function canvasesOf(root) {
  const delta = dfDollar(root).children('.diagram-delta').get(0);
  if (delta) return dfDollar(delta).find('.diagram-canvas').toArray();
  return dfDollar(root).children('.diagram-canvas').toArray();
}
/** UI side of setState (AGENTS.md "State API"): the state's markup, then the playback clock */
function triggerStateChange(root, state) {
  applyMarkup(root, state);
  clearTimeout(root._timer);
  const { max } = stepPlan(root);
  const step = stateStep(state, max);
  if (state.name === 'playing') {
    root._timer = setTimeout(() => {
      if (!root.isConnected || root.store?.value.name !== 'playing') return;
      if (step >= max) root.api.setState('paused', { step: max });
      else root.api.setState('playing', { step: step + 1 });
    }, holdOf(root, state));
  }
  const wasActive = root._shown?.name === 'active' ? root._shown.config?.ref ?? null : null;
  root._shown = state;
  sync(root);
  const ref = state.name === 'active' ? state.config?.ref ?? null : null;
  const about = describe(root, ref);
  updateOutputs(root, about);
  queueMicrotask(() => {
    if (state.name === 'playing' || state.name === 'paused') {
      // Fires on every step change - the step, the number of steps and the step's label.
      root.dispatchEvent(new CustomEvent<DiagramStepDetail>('diagram-step', { bubbles: true, detail: { step, max, label: stepLabel(root, step), state: state.name as DiagramStepDetail['state'] } }));
    }
    if (String(ref ?? '') !== String(wasActive ?? '')) {
      // Fires when the activation moves (a click, the keyboard, an outside control, setState) - the reference, 'node' or 'edge', the element, its label and detail; null detail fields when it is cleared.
      root.dispatchEvent(new CustomEvent<DiagramActivation>('diagram-activate', { bubbles: true, detail: about ?? { ref: null, kind: null, element: null, label: '', detail: '' } }));
    }
  });
}
/** outputs bound to a figure (<output data-diagram-for="id">) say what is active */
function updateOutputs(root, about) {
  if (!root.id) return;
  for (const out of dfDollar(`[data-diagram-for="${root.id}"]`).toArray()) {
    if (out.tagName !== 'OUTPUT') continue;
    if (!out.dataset.diagramEmpty) out.dataset.diagramEmpty = out.textContent.trim();
    dfDollar(out).text(about ? `${about.label}${about.detail ? ` - ${about.detail}` : ''}` : out.dataset.diagramEmpty);
  }
}
/** Registry-level API; pass the figure explicitly. Unknown names throw. */
export const diagramApi = componentState({
  component: 'diagram',
  states: diagramStates,
  apply: (root, state) => triggerStateChange(root, state),
  markup: (el, state) => applyMarkup(el, state),
});
df$.diagramApi = diagramApi;
df$.diagramStates = diagramStates;
// -- the parametric path: a spec → the same markup ------------------------------------------
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const attr = (name, value) => (value == null || value === false ? '' : value === true ? ` ${name}` : ` ${name}="${esc(value)}"`);
const place = (o) => {
  const css = [['--col', o.col], ['--row', o.row], ['--span', o.span], ['--rspan', o.rspan], ['--x', o.x], ['--y', o.y], ['--i', o.i]]
    .filter(([, v]) => v != null)
    .map(([k, v]) => `${k}:${v}`);
  if (o.style) css.push(o.style);
  return css.join(';');
};
function nodeMarkup(n, children = '') {
  const before = n.before ?? {};
  const fields = n.fields?.length
    ? `<ul class="diagram-node-fields">${n.fields.map((f) => {
      const field = typeof f === 'string' ? { name: f } : f;
      return `<li${attr('data-field', field.name)}${attr('data-key', field.key)}${attr('data-change', field.change)}><span>${esc(field.name)}</span>${field.type ? `<span>${esc(field.type)}</span>` : ''}</li>`;
    }).join('')}</ul>`
    : '';
  const ops = n.ops?.length ? `<ul class="diagram-node-ops">${n.ops.map((o) => `<li>${esc(o)}</li>`).join('')}</ul>` : '';
  const text = (cls, key) => (n[key] != null ? `<span class="${cls}"${attr('data-before', before[key])}>${esc(n[key])}</span>` : '');
  const beforeStyle = ['col', 'row', 'span', 'rspan', 'x', 'y'].some((k) => before[k] != null) ? place({ ...n, ...before }) : null;
  return `<div class="diagram-node"${attr('data-node', n.id)}${attr('data-shape', n.shape)}${attr('data-tone', n.tone)}${attr('data-status', n.status)}${attr('data-badge', n.badge)}${attr('data-step', n.step)}${attr('data-change', n.change)}${attr('data-change-note', n.note)}${attr('data-before-style', beforeStyle)}${attr('style', place(n) || null)}>${text('diagram-node-eyebrow', 'eyebrow')}${text('diagram-node-name', 'name')}${text('diagram-node-meta', 'meta')}${fields}${ops}${children}</div>`;
}
/**
 * The markup of a spec: { type, title, eyebrow, caption, cols, phases,
 * groups: [{ id, label, col, row, span, rspan, shape, tone, parent }],
 * nodes: [{ id, name, eyebrow, meta, col, row, span, shape, tone, badge,
 * fields, ops, step, parent, change, before }], edges: [{ from, to, label,
 * line, tone, head, tail, curve, step, flow, change, before }] }.
 */
function markupOf(spec) {
  const groups = spec.groups ?? [];
  const nodes = spec.nodes ?? [];
  const childrenOf = (id) => [
    ...groups.filter((g) => g.parent === id).map((g) => groupMarkup(g)),
    ...nodes.filter((n) => n.parent === id).map((n) => nodeMarkup(n)),
  ].join('');
  function groupMarkup(g) {
    return `<div class="diagram-group"${attr('data-node', g.id)}${attr('data-shape', g.shape)}${attr('data-tone', g.tone)}${attr('data-step', g.step)}${attr('data-change', g.change)}${attr('style', place(g) || null)}><span class="diagram-group-label">${esc(g.label)}</span>${childrenOf(g.id)}</div>`;
  }
  const caption = spec.title || spec.caption
    ? `<figcaption class="diagram-caption">${spec.eyebrow ? `<span class="diagram-eyebrow">${esc(spec.eyebrow)}</span>` : ''}${spec.title ? `<span class="diagram-title">${esc(spec.title)}</span>` : ''}${spec.caption ? `<span class="diagram-dek">${esc(spec.caption)}</span>` : ''}</figcaption>`
    : '';
  const phases = spec.phases?.length
    ? `<ol class="diagram-phases" style="--row:1;--col:1;--span:${spec.cols ?? spec.phases.length}">${spec.phases.map((p) => {
      const phase = typeof p === 'string' ? { name: p } : p;
      return `<li${attr('style', phase.span ? `--span:${phase.span}` : null)}>${esc(phase.name)}</li>`;
    }).join('')}</ol>`
    : '';
  const top = [...groups.filter((g) => !g.parent).map(groupMarkup), ...nodes.filter((n) => !n.parent).map((n) => nodeMarkup(n))].join('');
  const edges = (spec.edges ?? []).map((e) => {
    const before = e.before ?? {};
    return `<li class="diagram-edge"${attr('data-from', e.from)}${attr('data-to', e.to)}${attr('data-line', e.line)}${attr('data-tone', e.tone)}${attr('data-head', e.head)}${attr('data-tail', e.tail)}${attr('data-curve', e.curve)}${attr('data-step', e.step)}${attr('data-flow', e.flow === true ? '' : e.flow)}${attr('data-from-label', e.fromLabel)}${attr('data-to-label', e.toLabel)}${attr('data-change', e.change)}${attr('data-before', before.label)}${attr('data-before-from', before.from)}${attr('data-before-to', before.to)}>${esc(e.label)}</li>`;
  }).join('');
  return `${caption}<div class="diagram-canvas"${attr('style', [spec.cols ? `--cols:${spec.cols}` : '', spec.style ?? ''].filter(Boolean).join(';') || null)}>${phases}${top}</div>${edges ? `<ol class="diagram-edges">${edges}</ol>` : ''}`;
}
/**
 * Two specs → one annotated spec: nodes, groups and edges only in `after` are
 * added, only in `before` removed; a node whose text or kind differs is
 * changed (its old text kept as before), one whose place differs moved; an
 * edge with the same id but other ends rewired.
 */
function diffSpecs(before, after) {
  const key = (e) => e.id ?? `${e.from}->${e.to}`;
  const merge = (olds = [], news = [], keyOf, compare) => {
    const oldMap = new Map(olds.map((o) => [keyOf(o), o]));
    const newKeys = new Set(news.map(keyOf));
    const out = news.map((n) => {
      const o = oldMap.get(keyOf(n));
      if (!o) return { ...n, change: 'added' };
      return compare(o, n);
    });
    // removed items keep their place in the old order
    olds.forEach((o, i) => {
      if (!newKeys.has(keyOf(o))) out.splice(Math.min(i, out.length), 0, { ...o, change: 'removed' });
    });
    return out;
  };
  const TEXT = ['name', 'eyebrow', 'meta'];
  const PLACE = ['col', 'row', 'span', 'rspan', 'x', 'y'];
  const compareNode = (o, n) => {
    const text = TEXT.filter((k) => (o[k] ?? null) !== (n[k] ?? null));
    const kind = ['shape', 'tone', 'badge'].some((k) => (o[k] ?? null) !== (n[k] ?? null)) || JSON.stringify(o.fields ?? []) !== JSON.stringify(n.fields ?? []);
    const moved = PLACE.filter((k) => (o[k] ?? null) !== (n[k] ?? null));
    if (!text.length && !kind && !moved.length) return n;
    const prev = Object.fromEntries([...text, ...moved].map((k) => [k, o[k]]));
    return { ...n, change: text.length || kind ? 'changed' : 'moved', before: prev };
  };
  const compareEdge = (o, n) => {
    if (o.from !== n.from || o.to !== n.to) return { ...n, change: 'rewired', before: { from: o.from, to: o.to } };
    if ((o.label ?? '') !== (n.label ?? '') || (o.line ?? '') !== (n.line ?? '')) return { ...n, change: 'changed', before: { label: o.label ?? '' } };
    return n;
  };
  return {
    ...before,
    ...after,
    groups: merge(before.groups, after.groups, (g) => g.id, (o, n) => ((o.label !== n.label || PLACE.some((k) => o[k] !== n[k])) ? { ...n, change: 'changed' } : n)),
    nodes: merge(before.nodes, after.nodes, (n) => n.id, compareNode),
    edges: merge(before.edges, after.edges, key, compareEdge),
  };
}
const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);
/** { before, after } - two specs for a delta, not one diagram */
const isDelta = (spec: DiagramSpec | DiagramDelta): spec is DiagramDelta => !!(spec as DiagramDelta)?.before && !!(spec as DiagramDelta)?.after;
/** a spec's figure switches: steps, autoplay, interactive (the same data-* an author writes) */
function specFlags(root, spec) {
  for (const flag of ['steps', 'autoplay', 'interactive']) if (spec[flag]) root.setAttribute(`data-${flag}`, typeof spec[flag] === 'number' ? String(spec[flag]) : '');
}
// -- properties: a node or an edge as plain JSON (an inspector, the Studio) ---------------------
const TONES = ['none', 'accent', 'link', 'muted', 'external', 'warn', 'ok', 'danger'];
const SHAPES = ['box', 'pill', 'diamond', 'store', 'circle', 'dot', 'bar', 'note', 'activity', 'class', 'start', 'end'];
const NODE_TEXT = [['eyebrow', 'diagram-node-eyebrow'], ['name', 'diagram-node-name'], ['meta', 'diagram-node-meta']];
/** a cell's own text (a matrix chip keeps its span) */
const cellText = (c) => (dfDollar(c).children('span').get(0) ?? c).textContent.trim();
/** a column's header text, without its small print */
const headText = (c) => [...c.childNodes].filter((n) => n.nodeType === 3 || n.nodeName !== 'SMALL').map((n) => n.textContent).join('').trim();
function propertiesOf(root, ref) {
  const target = refTarget(root, ref);
  if (!target) return null;
  const { el, kind } = target;
  if (el.tagName === 'TR') {
    const cells = dfDollar(el).children('th, td').toArray();
    const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray();
    const out = { row: headText(cells[0]) };
    cells.slice(1).forEach((c, i) => {
      out[heads[i + 1] ? headText(heads[i + 1]) : `column ${i + 2}`] = cellText(c);
    });
    return out;
  }
  if (kind === 'node') {
    const out: Record<string, string> = { id: el.dataset.node };
    for (const [key, cls] of NODE_TEXT) out[key] = dfDollar(el).find(`.${cls}`).get(0)?.textContent.trim() ?? '';
    out.tone = el.dataset.tone || 'none';
    out.shape = el.dataset.shape || 'box';
    return out;
  }
  return {
    from: el.dataset.from,
    to: el.dataset.to,
    label: el.textContent.trim(),
    line: el.dataset.line || 'solid',
    tone: el.dataset.tone || 'none',
    head: el.dataset.head || 'arrow',
    tail: el.dataset.tail || 'none',
    curve: el.dataset.curve || 'elbow',
  };
}
function propertySchemaOf(root, ref) {
  const target = refTarget(root, ref);
  if (!target) return {};
  if (target.el.tagName === 'TR') return { row: { readOnly: true } };
  if (target.kind === 'node') return { id: { readOnly: true }, tone: { options: TONES }, shape: { options: SHAPES } };
  const ends = Object.keys(HEADS);
  return {
    from: { readOnly: true },
    to: { readOnly: true },
    line: { options: ['solid', 'dashed', 'dotted', 'thick'] },
    tone: { options: ['none', 'accent', 'link', 'ink', 'ok', 'danger'] },
    head: { options: ends },
    tail: { options: ends },
    curve: { options: ['elbow', 'straight', 'curve', 'around'] },
  };
}
/** write properties back - only what differs - and redraw */
function setPropertiesOf(root, ref, props) {
  const target = refTarget(root, ref);
  if (!target || !props) return false;
  const { el, kind } = target;
  const attr = (name, value, none) => dfDollar(el).attr(`data-${name}`, value == null || value === '' || value === none ? null : String(value));
  if (el.tagName === 'TR') {
    const cells = dfDollar(el).children('th, td').toArray();
    const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray();
    cells.slice(1).forEach((c, i) => {
      const key = heads[i + 1] ? headText(heads[i + 1]) : `column ${i + 2}`;
      if (key in props && cellText(c) !== String(props[key])) dfDollar(dfDollar(c).children('span').get(0) ?? c).text(String(props[key]));
    });
  } else if (kind === 'node') {
    // text parts: the node's own spans (a timeline event keeps them in its body)
    const host = dfDollar(el).children('.diagram-node-body').get(0) ?? el;
    for (const [key, cls] of NODE_TEXT) {
      if (!(key in props)) continue;
      const value = String(props[key] ?? '');
      let span = dfDollar(el).find(`.${cls}`).get(0);
      if (span && !value) dfDollar(span).remove();
      else if (span && span.textContent !== value) dfDollar(span).text(value);
      else if (!span && value) {
        span = document.createElement('span');
        span.className = cls;
        dfDollar(span).text(value);
        // eyebrow first, meta after the name
        const name = dfDollar(host).children('.diagram-node-name').get(0);
        if (key === 'eyebrow') host.prepend(span);
        else if (key === 'meta' && name) name.after(span);
        else host.append(span);
      }
    }
    if ('tone' in props) attr('tone', props.tone, 'none');
    if ('shape' in props) attr('shape', props.shape, 'box');
  } else {
    if ('label' in props && el.textContent !== String(props.label ?? '')) dfDollar(el).text(String(props.label ?? ''));
    if ('line' in props) attr('line', props.line, 'solid');
    if ('tone' in props) attr('tone', props.tone, 'none');
    if ('head' in props) attr('head', props.head, 'arrow');
    if ('tail' in props) attr('tail', props.tail, 'none');
    if ('curve' in props) attr('curve', props.curve, 'elbow');
  }
  for (const canvas of canvasesOf(root)) draw(root, canvas);
  return true;
}
df$.diagram = {
  /**
   * Build a diagram from a spec (see markup) into a .diagram figure, replacing
   * what it holds - or from { before, after } (two specs → one delta diagram,
   * see diff).
   * @param target - the .diagram figure or its selector
   * @param spec - the diagram, or { before, after } for a delta
   * @returns the figure, null when the target matches none
   */
  build(target: string | HTMLElement, spec: DiagramSpec | DiagramDelta): HTMLElement | null {
    const root = resolve(target);
    if (!root) return null;
    const delta = isDelta(spec);
    const merged = isDelta(spec) ? diffSpecs(spec.before, spec.after) : spec;
    if (merged.type) dfDollar(root).attr('data-type', merged.type);
    specFlags(root, merged);
    if (delta) dfDollar(root).attr('data-delta', root.getAttribute('data-delta') ?? '');
    root._controls?.remove();
    root._controls = null;
    dfDollar(root).children('.diagram-delta').remove();
    dfDollar(root).html(markupOf(merged));
    setup(root);
    if (root.api) triggerStateChange(root, root.store?.value ?? { name: 'default', config: {} });
    return root;
  },
  /**
   * The HTML of a spec - the same markup an author writes (server-side rendering, copy-paste).
   * @param spec - the diagram, or { before, after } for a delta
   * @returns the figure's inner markup (caption, canvas, edge list)
   */
  markup: (spec: DiagramSpec | DiagramDelta): string => markupOf(isDelta(spec) ? diffSpecs(spec.before, spec.after) : spec),
  /**
   * Two specs → one spec annotated with added / removed / changed / moved / rewired (the delta source).
   * @param before - the diagram before
   * @param after - the diagram after
   * @returns one spec: the after state, every element marked with its change
   */
  diff: (before: DiagramSpec, after: DiagramSpec): DiagramSpec => diffSpecs(before, after),
  /**
   * Measure the nodes again and redraw every wire (after you moved or resized nodes yourself).
   * @param target - the .diagram figure or its selector
   */
  redraw(target: string | HTMLElement): void {
    const root = resolve(target);
    if (root) for (const canvas of canvasesOf(root)) draw(root, canvas);
  },
  /**
   * Play the steps from the first (or from `step`).
   * @param target - the .diagram figure or its selector
   * @param step - the step to start at, 1-based
   */
  play: (target: string | HTMLElement, step: number = 1): void => { resolve(target)?.api.setState('playing', { step }); },
  /**
   * Pause on the current step.
   * @param target - the .diagram figure or its selector
   */
  pause: (target: string | HTMLElement): void => { control(resolve(target), 'pause'); },
  /**
   * One step forward (pauses).
   * @param target - the .diagram figure or its selector
   */
  next: (target: string | HTMLElement): void => { control(resolve(target), 'next'); },
  /**
   * One step back (pauses).
   * @param target - the .diagram figure or its selector
   */
  prev: (target: string | HTMLElement): void => { control(resolve(target), 'prev'); },
  /**
   * The complete figure again (state 'default').
   * @param target - the .diagram figure or its selector
   */
  reset: (target: string | HTMLElement): void => { resolve(target)?.api.setState('default'); },
  /**
   * Activate a node (its data-node) or an edge (its data-edge, else "from->to"); null clears.
   * @param target - the .diagram figure or its selector
   * @param ref - null or '' clears; else a node (its data-node) or an edge (its data-edge, else "from->to")
   */
  activate(target: string | HTMLElement, ref: string | null): void {
    const root = resolve(target);
    if (!root) return;
    if (ref == null || ref === '') root.api.setState('default');
    else root.api.setState('active', { ref: String(ref) });
  },
  /**
   * Activate the next node in the activation order (step order, then markup order; wraps around).
   * @param target - the .diagram figure or its selector
   */
  activateNext: (target: string | HTMLElement): void => { stepActivation(resolve(target), 1); },
  /**
   * Activate the previous node in the activation order (wraps around).
   * @param target - the .diagram figure or its selector
   */
  activatePrev: (target: string | HTMLElement): void => { stepActivation(resolve(target), -1); },
  /**
   * What is active: { ref, kind ('node' / 'edge'), element, label, detail } - or null.
   * @param target - the .diagram figure or its selector
   * @returns the active node or edge, null when nothing is active
   */
  active(target: string | HTMLElement): DiagramActivation | null {
    const root = resolve(target);
    const state = root?._shown ?? root?.store?.value;
    return state?.name === 'active' ? describe(root, state.config?.ref) : null;
  },
  /**
   * The activation order - the node references next / previous walk through.
   * @param target - the .diagram figure or its selector
   * @returns the nodes' data-node values, in that order
   */
  order: (target: string | HTMLElement): string[] => activationOrder(resolve(target)),
  /**
   * A node's or an edge's properties as plain JSON - a node: { id, eyebrow,
   * name, meta, tone, shape }; an edge: { from, to, label, line, tone, head,
   * tail, curve }; a matrix row: { row, <column>: text ... }. null when the
   * reference names nothing. Feed it to a property grid to inspect it.
   * @param target - the .diagram figure or its selector
   * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to")
   * @returns its properties as strings, null when the reference names nothing
   */
  properties: (target: string | HTMLElement, ref: string): Record<string, string> | null => propertiesOf(resolve(target), ref),
  /**
   * The property grid sourceConfig for properties(): the options for tone, shape, line, ends, curve; ids and ends read-only.
   * @param target - the .diagram figure or its selector
   * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to")
   * @returns per property: readOnly, or the options to choose from ({} when the reference names nothing)
   */
  propertySchema: (target: string | HTMLElement, ref: string): Record<string, { readOnly?: boolean; options?: string[] }> => propertySchemaOf(resolve(target), ref),
  /**
   * Write properties back onto the node / edge / row (only what differs) and redraw - a property grid's change, applied.
   * @param target - the .diagram figure or its selector
   * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to")
   * @param props - the properties to write (properties() names them)
   * @returns false when the reference names nothing
   */
  setProperties: (target: string | HTMLElement, ref: string, props: Record<string, string>): boolean => setPropertiesOf(resolve(target), ref, props),
  /**
   * The step plan: { max, current } - current is max in the complete figure.
   * @param target - the .diagram figure or its selector
   * @returns the number of steps and the step shown
   */
  steps(target: string | HTMLElement): { max: number; current: number } {
    const root = resolve(target);
    const { max } = stepPlan(root);
    const state = root.store?.value ?? { name: 'default' };
    return { max, current: !stepping(state) ? max : stateStep(state, max) };
  },
};
// -- init ------------------------------------------------------------------------------------
/** the per-figure wiring that a rebuild (build()) needs again: spec, controls, observers */
function setup(root) {
  if (root.hasAttribute('data-steps') && !root._controls) root._controls = makeControls(root);
  if (!root._observer) {
    root._observer = new ResizeObserver(() => {
      if (root._queued) return;
      root._queued = true;
      queueMicrotask(() => {
        root._queued = false;
        for (const canvas of canvasesOf(root)) draw(root, canvas);
      });
    });
    root._observer.observe(root);
  }
  for (const canvas of dfDollar(root).find('.diagram-canvas').toArray()) root._observer.observe(canvas);
  if (root.hasAttribute('data-interactive')) makeInteractive(root);
}
/**
 * data-interactive: a click (or Enter / Space on a focused node) activates a
 * node or an edge - its wire or its label; a click on the active one, on the
 * empty canvas or Escape clears; the arrow keys move the activation along the
 * activation order. Nodes become focusable buttons.
 */
function makeInteractive(root) {
  for (const n of activatable(root)) {
    if (n.getAttribute('tabindex') == null) dfDollar(n).attr('tabindex', '0');
    if (!n.getAttribute('role') && !tablePart(n)) dfDollar(n).attr('role', 'button');
    if (n.getAttribute(pressedAttr(n)) == null) dfDollar(n).attr(pressedAttr(n), 'false');
  }
  if (root._interactive) return;
  root._interactive = true;
  const current = () => {
    const state = root._shown ?? root.store?.value ?? { name: 'default' };
    return state.name === 'active' ? String(state.config?.ref ?? '') : null;
  };
  const toggle = (ref) => (current() === ref ? root.api.setState('default') : root.api.setState('active', { ref }));
  dfDollar(root).on('click', (e) => {
    const t = e.target as HTMLElement | null;
    if (!t?.closest || t.closest('.diagram-controls, .diagram-delta, .diagram-edges, .diagram-caption, .diagram-legend')) return;
    const wire = t.closest<SVGElement>('[data-edge-ref]');
    if (wire) return toggle(wire.dataset.edgeRef);
    const node = t.closest<HTMLElement>('[data-node]');
    if (node && activatable(root).includes(node)) return toggle(node.dataset.node);
    if (t.closest('.diagram-canvas') && current() != null) root.api.setState('default');
  });
  dfDollar(root).on('keydown', (e) => {
    const node = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-node]');
    if (!node || !activatable(root).includes(node)) return;
    const focusActive = () => activatable(root).find((n) => n.dataset.node === current())?.focus();
    if (e.key === 'Enter' || e.key === ' ') toggle(node.dataset.node);
    else if (e.key === 'Escape') root.api.setState('default');
    else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      stepActivation(root, 1);
      focusActive();
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      stepActivation(root, -1);
      focusActive();
    } else return;
    e.preventDefault();
  });
}
// outside controls, declared in markup: <button data-diagram-for="id" data-diagram-action="next">
// actions - next / prev / clear (the activation), play / pause / step-next / step-prev / reset (the steps)
let controlsBound = false;
function bindOutsideControls() {
  if (controlsBound) return;
  controlsBound = true;
  dfDollar(document).on('click', (e) => {
    const trigger = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-diagram-for][data-diagram-action]');
    if (!trigger) return;
    const root = dfDollar(`#${CSS.escape(trigger.dataset.diagramFor)}`).get(0);
    if (!root?.api) return;
    const action = trigger.dataset.diagramAction;
    if (action === 'next') stepActivation(root, 1);
    else if (action === 'prev') stepActivation(root, -1);
    else if (action === 'clear' || action === 'reset') root.api.setState('default');
    else if (action === 'activate') root.api.setState('active', { ref: trigger.dataset.diagramRef });
    else if (action === 'play') root.api.setState('playing', { step: 1 });
    else if (action === 'pause') control(root, 'pause');
    else if (action === 'step-next') control(root, 'next');
    else if (action === 'step-prev') control(root, 'prev');
  });
}
function init() {
  bindOutsideControls();
  dfDollar('.diagram:not([data-init])').toArray().forEach((root) => {
    root.dataset.init = '';
    // a parametric diagram: <script type="application/json" class="diagram-spec">
    const spec = dfDollar(root).children('script.diagram-spec').get(0);
    if (spec && !dfDollar(root).children('.diagram-canvas').get(0)) {
      try {
        const parsed = JSON.parse(spec.textContent);
        const merged = parsed.before && parsed.after ? diffSpecs(parsed.before, parsed.after) : parsed;
        if (parsed.before && parsed.after && !root.hasAttribute('data-delta')) root.setAttribute('data-delta', '');
        if (merged.type && !root.dataset.type) root.dataset.type = merged.type;
        specFlags(root, merged);
        const holder = document.createElement('div');
        dfDollar(holder).html(markupOf(merged));
        while (holder.firstChild) dfDollar(root).append(holder.firstChild);
      } catch (error) {
        console.error('[diagram] invalid spec', error);
      }
    }
    setup(root);
    // el.store + el.api: `$('#arch').api.setState('paused', { step: 2 })`
    bindComponent(root, diagramApi, { name: 'default', config: {} });
    triggerStateChange(root, { name: 'default', config: {} });
    document.fonts?.ready.then(() => root.isConnected && diagramRedraw(root));
    if (root.hasAttribute('data-autoplay') && root.hasAttribute('data-steps')) {
      // data-autoplay="1000": wait that long after it comes into view. On a
      // presentation slide it replays on every arrival (hidden until then);
      // anywhere else it plays once.
      const delay = Math.max(0, Number(root.getAttribute('data-autoplay')) || 0);
      const onSlide = !!root.closest('[data-slide]');
      let seen = false;
      const io = new IntersectionObserver((entries) => {
        const shown = entries.some((e) => e.isIntersecting);
        if (!shown) {
          if (seen && onSlide) clearTimeout(root._autoplay);
          return;
        }
        if (seen && !onSlide) return;
        const first = !seen;
        seen = true;
        if (!onSlide) io.disconnect();
        if (reducedMotion() || (first && root.store?.value.name !== 'default')) return;
        clearTimeout(root._autoplay);
        if (delay || !first) root.api.setState('paused', { step: 0 });
        root._autoplay = setTimeout(() => root.isConnected && root.api.setState('playing', { step: 1 }), delay);
      }, { threshold: 0.35 });
      io.observe(root);
    }
  });
}
const diagramRedraw = (root) => {
  for (const canvas of canvasesOf(root)) draw(root, canvas);
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub