Theme
Design your own
On this page (20)

§Security matrix

§Platform access matrix

Permissions are text plus a chip style - never color alone. The accent marks the one grant that should not exist.

§Bayesian matrix

Belief before and after evidence: each hypothesis row carries its prior, the likelihood of the data, their product and the normalized posterior.

§Which coin is it?

Heat is relative to the column maximum (--v). The posterior column sums to 1.

§Base rates and a positive test

Joint probabilities per 1,000 - the posterior P(ill | +) is the accent: 8.8%, not 95%.

§Step by step

The Bayesian update computed column by column: data-step on the header and the cells of each column - prior, likelihood, their product, the posterior.

§A Bayesian update, column by column

Explicit steps on cells: a table can play by column as well as by row.

§More matrices

§RACI

One accountable per row - the accent marks it.

§Risk matrix

--v = likelihood × impact / 25 on every cell; the risk IDs sit where they were assessed.

§Weighted decision matrix

Scores as heat, the weighted total in the footer.

§Traceability matrix

The row with no coverage is the finding.

§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.

§Matrix, 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.

§Matrix, inspect and edit

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

§Delta

§Q3 access review

data-change on cells and rows; data-before on the cell text; data-change-label names the cell in the ledger.

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