# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.26.1] - 2026-07-05 — docs consistency + error-catalog completeness (no runtime change)

A polish patch. **No rendered-output change** — a document renders
byte-identically under 0.26.0 and 0.26.1 (`SCRIBA_VERSION` stays 19).

### Fixed
- **Error catalog completeness** — `ERROR_CATALOG` gained the four codes it was
  missing: E1491/E1492 (`\trace` needs ≥2 cells / bad arrowhead) and E1183/E1184
  (`\cursor` binding). These are raised/emitted by the parser but were absent
  from the catalog. `ERROR_CATALOG` is an internal registry read only by the
  parity test and doc tooling — no code reads it at raise time (messages are
  inline), so user-facing error messages and rendered output are unchanged.
  Every animation-raised/warned code is now catalogued (0 missing of 57).
- **A-2 motion-kind registry** — `docs/spec/motion-ruleset.md` listed only 10
  closed kinds; corrected to 11 (`cursor_move` was missing from the enumeration
  though the code has always emitted it).

### Docs
- Swept every doc to 0.26.0 reality: primitive count → **21** (Bar/TraceTable/
  Equation), inner-command count → **23** (`environments.md` §3 refreshed to
  document all 23 with accurate contexts/E-codes), semantic states → **9**.
- Purged the phantom `§9.2` state names (`focus`/`update`/`reject`/`accept`/
  `hint` — which exist nowhere in code) from the matrix/plane2d/stack/metricplot
  pages, replacing them with the real nine `scriba-state-*` classes.

## [0.26.0] - 2026-07-05 — the teacher's board (CP teaching-gesture census)

A 6-slice BMAD census (`investigations/teaching-*.md`) mapped what a CP
teacher does on a whiteboard / in a YouTube lecture against scriba's grammar.
scriba already nailed the "animate the current structural state" axis (the
killer code-line-sync move works); this release closes the three axes it was
thin on — the accumulating/arranged **board-as-record**, **math as an evolving
object**, and **camera/attention** — with 2 new primitives, layout/zoom/decorate
verbs, and **0 new motion kinds** (the closed 11-kind registry held across
every gap). Every design: `investigations/design-*.md`.

### Added — decoration / marking verbs (no SCRIBA_VERSION bump)
- **`\annotate{x}{strike=true}`** — strike-but-keep: a diagonal cross-out
  that leaves the element's state colour intact (the census's one hard-MISSING
  marker verb — pruning was a lossy recolor-or-remove binary). E1119.
- **`\note{id}{text=…, at=<compass>}`** — a free margin callout not tied to any
  shape (8-way anchor), keyed `note[id]-solo`. E1120/E1121.
- **`\focus{x}{scope=board}`** — board-wide spotlight, dims every *other* shape
  (default `scope=shape` unchanged). E1122.
- **`\trace{Graph|Tree}{cells=[nodes]}`** — the BFS/DFS "follow the edges" sweep
  (E1118 lifted for node sequences).

### Added — viewport: deliberate layout + camera (no SCRIBA_VERSION bump)
- **`at=[row,col]` on `\shape`** — an opt-in grid board (array top / tree below /
  recurrence right); no `at=` = today's centered stack, byte-identical.
  E1540/E1541/E1542.
- **`\zoom{target}`** — a per-frame viewBox crop, the camera twin of `\focus`
  (ephemeral, auto-restore, magnify-not-shrink). Discrete cut, scriba.js
  untouched. E1543.

### Added — accumulation surfaces
- **TraceTable primitive (#20)** — the dry-run trace table (variables = pinned
  columns × steps = downward-growing rows). `\apply{t}{row=[…]}` appends a row
  (auto-current). Structural-prescan envelope → viewBox byte-invariant across
  row growth (R-32). No bump (Bar precedent). E1520–E1522.
- **Live `\invariant`** — an `\invariant{sum = ${s}}` body now interpolates
  `${bindings}` per frame (was a static literal). Static invariants stay
  byte-identical.

### Added — math as an evolving object (SCRIBA_VERSION 18→19)
- **Equation primitive (#21)** — makes math sub-terms and aligned lines natively
  addressable, so a teacher can tint a term and reveal a derivation line-by-line.
  `\shape{E}{Equation}{tex="…\term{id}{body}…"}` / `lines=[…]`; selectors
  `E.term[id]` / `E.line[i]`; `\term` compiles to a KaTeX `\htmlClass` under a
  narrow selective trust (`\href` stays gated). Rides recolor/value_change — 0
  new motion kinds. Adds only the `.scriba-term.scriba-state-*` colour CSS
  (opt-in-inert), which — together with the live-invariant runtime swap — is
  the sole reason for the 18→19 bump. E1530–E1532.

### Changed
- `SCRIBA_VERSION` 18→19 (Equation's `.scriba-term` CSS + the live-invariant
  `scriba.js` swap — both shared inlined assets; documents using neither are
  otherwise byte-identical at the markup layer). Primitive count 19→21.

### Fixed / hardening
- `\substory` parent-mutation persistence is now locked by a content-asserting
  regression test (it is the documented, golden-relied-upon contract — see
  `investigations/verify-substory-leak.md`, suspected leak REFUTED) and the
  misleading "ephemeral" comment corrected.

## [0.25.0] - 2026-07-05 — grammar-completeness vocabulary + Tier-D fixes

### Added — grammar-completeness vocabulary (post-0.24 census)
- **Bar primitive** — variable-height columns (`\shape{h}{Bar}{data=[...]}`,
  `h.bar[i]`, height ∝ value) for largest-rectangle, skyline,
  monotonic-stack-on-heights and sorting-as-bars. `\apply value` rides
  the existing `value_change` motion (no new kinds); the bounding box is
  a prescanned envelope so it stays R-32-invariant. E1488–E1490.
- **Graph manual positions** — `positions=[(node,x,y),...]` pins nodes to
  author coordinates (FFT-butterfly, planar, geometric graphs); force /
  hierarchical graphs stay byte-identical. E1475.
- **Plane2D `rotate_point` / `rotate_segment` / `rotate_line`** — angular
  motion for rotating calipers, angular sweeps and Burnside rings; rides
  `position_move` as a chord-glide (no new motion kinds). E1437/E1467.

### Fixed — Tier-D investigation follow-ups
- **BST rotations pick the right side** — `reparent={node,parent,index}`
  places the moved node (0 = leftmost); two shipped editorials
  (bst_operations, splay) rendered the wrong child side.
- **`plane2d` geometry namespace works in `\compute`** —
  intersect/cross/hull/half_plane/lower_envelope were documented and
  acceptance-tested but never injected into the Starlark worker (E1151).
- **Arithmetic list subscript is loud** — `${vals[i+1]}` raised a clean
  E1159 instead of silently pasting the whole list.
- **Out-of-range `\trace` vertex warns (E1115)** instead of the whole
  trace vanishing silently.
- **`\recolor{state=${s}}` gives a clean E1109** instead of leaking the
  `InterpolationRef` repr.

## [0.24.0] - 2026-07-05 — JudgeZone pass-3 capabilities + hardening

### Fixed — 0.24.0 hardening (adversarial test pass)
- **Wrong-type construction params raise a clean E-code, not a Python
  traceback** — `bits="three"`, `nodes=5`, `capacity="x"`,
  `kind=heap data=5`, `remove_circle="xyz"`, plus pre-existing
  Array/Matrix/Graph and ragged 2-D Matrix data, all now surface
  E1510/E1508-9/E1440/E1438/E1401/E1421/E1470/E1422/E1437 via
  type-checking coercion helpers.
- **`${...}` resolves in `row`/`col`/`subset`/`link` and every generic
  `name[index]` selector** — these went through a NamedAccessor that
  froze the interpolation ref, so `\foreach{i}{...}\recolor{m.row[${i}]}`
  silently painted nothing. New IndexedAccessor/LinkAccessor keep the
  index live; an unbound `${x}` there now raises the same loud E1159 as
  `cell`.
- **`\trace` on a primitive that can't draw traces raises E1118** (was a
  silent no-op on Deque/Forest/Matrix/Stack/… — only Array/Grid/DPTable/
  NumberLine support it).
- **`\group` label paints over the nodes** so a node at the hull corner
  can't overdraw it into an unreadable pill.
- **Unquoted dotted param value gets a hinted E1005** naming the key
  (`\combine{...}{into=c.cell[0][1]}` was a cryptic "expected IDENT").
- **A circle/arc/wedge whose radius reaches past the viewport warns
  (E1463)** — the check previously only looked at the center.
- **Dark-mode edge contrast raised to WCAG 3:1** (idle stroke
  #313538 → #62696d) and Hypercube lattice edges made theme-aware.
- **Consistent `highlighted` manifest casing** (`"true"` everywhere,
  was `"True"` on one path) and `render.py --dump-frames` no longer
  crashes on a `None` state.
- **Keyboard nav stays alive at the first/last frame** — a disabled
  prev/next button no longer drops focus outside the widget.

### Added — capability Wave 4 (new substrates + flow readability)
- **Hypercube primitive** — the subset lattice for bitmask DP / SOS /
  inclusion-exclusion: `\shape{L}{Hypercube}{bits=4}` renders all
  2^bits subsets as a Hasse diagram by popcount with one-bit edges;
  `subset[i]` decimal addressing, value overrides, bits capped 1..5
  (E1510). `\sweep` and `0b` literals stay deferred by design.
- **Forest primitive** — multi-root DSU: `\apply{f}{union={a,b}}`
  reparents root(b) under root(a) and the merged tree glides into
  place (node keys are pure ids, so identity survives every union; the
  viewBox is a prescanned monotonic envelope that never jumps).
  E1508/E1509.
- **Deque primitive** — two-ended queue as a strict superset of Queue
  (enqueue/dequeue still work): push/pop at both ends with count-based
  pops, loud overflow/underflow (E1442/E1443), deque verbs on a plain
  Queue rejected with a hint (E1444). Queue output stays
  byte-identical (subclass, untouched code path). Unlocks
  monotonic-deque sliding-window editorials.
- **Antiparallel edge pairs curve apart** on directed graphs — a
  forward edge and its residual render as symmetric arcs (~24px apart)
  with labels on opposite sides, making Dinic/max-flow walkthroughs
  readable. Single edges keep their exact straight-line bytes.

### Added — capability Wave 3 (bridges, clusters, automata)
- **`\link{A <-> B}` + `\combine{s1, s2}{into="D"}`** — the first
  cross-shape construct: a stage-level bridge between anchors on two
  different shapes (Euler-tour subtree ↔ array range; matrix-multiply
  row+column → cell). Persistent or `ephemeral=true`; `\combine` is
  N ephemeral links converging on one target. Motion rides the existing
  annotation fades (zero new kinds, runtime untouched). E1497/E1498.
- **`\group{G}{nodes=[...], id=...}` / `\ungroup`** — rounded hull
  overlays for node clusters on Graph (Kruskal components, SCCs,
  blocks). The layout stays pinned (browser-measured <0.6px) and the
  node-set never changes — a growing component is the same id re-issued
  with more nodes. E1506/E1507; group-free documents byte-identical.
- **Tree grows automata support**: char-labeled edges
  (`edges=[(0,1,"a")]`, `add_node char=`) and a second dashed link
  class (`links=`, `add_link`/`remove_link`, selector `T.link[(u,v)]`)
  that never affects layout — trie, Aho-Corasick fail links, and
  suffix-automaton transitions (suffix-link tree as the spine) with no
  new primitive.

### Added — capability Wave 2 (motion: elements glide)
- **`position_move` now glides to the NEW seat** (the cursor_move
  geometry) instead of ending at the old one and relying on the
  fullscreen snap — curing the "visible lurch" the A-4 card documented.
  Reverse (Prev) glides back; the kind stays self-inverse; registry
  stays at 11 kinds. All interactive goldens re-blessed (runtime bytes).
- **Plane2D `move_point` / `move_line` / `move_segment`** —
  mutate-in-place ops that keep the element's identity, so a sweep line
  *slides* across the plane instead of teleporting
  (`\apply{p}{move_line={i=0, to_x=1.0}}`; slanted lines → E1467,
  tombstoned targets → E1437). Positions feed the differ in math-local
  coordinates (browser-verified against the scaled transform group).
- **Array `reorder`** — `\apply{a}{reorder=[3,0,1,4,2]}` permutes values
  across fixed slots while elements keep a stable identity from their
  starting slot and glide to their new seats (one command per sorting
  pass). Slots remain the annotation/recolor anchors (R-42); mixing with
  insert/remove raises new E1404; arrays that never reorder emit
  byte-identical output.
- **Sweep-line recipe** (§12): three shapes, one clock — `\step` already
  synchronizes every shape, so a sweep is one event per step with no new
  machinery.

### Added — capability Wave 1 (JudgeZone pass-3 Tier A/B4/heap)
- **`row[i]` / `col[j]` / `diag` selectors** on Grid, DPTable-2D and
  Matrix — sugar over `block` expanded at the generic selector site, so
  a Gaussian row op or matrix-multiply pass is one command. Annotation
  pills anchor at the row/col centre.
- **Matrix value mutation**: `\apply{m.cell[r][c]}{value=X}` updates the
  cell and re-drives the heatmap fill through the frozen `vmin`/`vmax`
  range (fill stays a colorscale lookup, never a state-fill). Closes the
  `docs/primitives/matrix.md` drift that promised this.
- **Plane2D `circle` / `arc` / `wedge` element kinds** — full family
  parity with the existing five (add/remove, tombstones, selectors,
  annotation anchors, E1466/E1467). Radii are math-units; under
  `aspect="auto"` a circle honestly renders the pixel-space locus.
- **Tree `kind=heap`** — complete binary tree derived from
  `data=[...]` (node `i` → children `2i+1`/`2i+2`, label = value);
  sift swaps are two `\apply` value writes. New E1438 for empty data.
- **Docs for shipped-but-undocumented capabilities** (the census's A1 +
  C2a): Tree per-node relabel `\apply{T.node[i]}{value=}` (tree-DP
  overlays, ~26 problems) and flow-edge `f/c` labels, plus a segtree
  lazy-propagation recipe — no new verbs, everything render-proofed.

### Fixed
- **Value-only `\apply` no longer clobbers expanded-selector states**
  (the Gauss trap: `\recolor{m.row[0]}` + `\apply{m.cell[0][1]}{value=}`
  silently dropped the row colour on that cell). `ShapeTargetState.state`
  now defaults to `None` ("never recolored") so value-only entries carry
  no state to the merge. Manifest `from_val` for previously
  highlight-only entries becomes `"idle"` instead of `null`, which also
  fixes the runtime class-replace miss (smooth recolor, no snap lurch).
  `SCRIBA_VERSION` 17→18.

### Added — docs-audit generality batch
- **Computed selector subscripts**: `${list[k]}` in a selector index now
  resolves when `k` is a `\compute` binding or literal
  (`\recolor{a.cell[${idx[k]}]}`), instead of silently collapsing to the
  whole list and dropping the command. Any still-unresolvable index —
  unknown binding, `\foreach` var used as a subscript, or a
  whole-container result — raises loud **E1159** with a hint.
- **Bulk watch writes feed binding carets**: `\apply{w}{i=N}` now mirrors
  each parameter into the targeted `w.var[i]` slot, so bulk and targeted
  forms are interchangeable for `\cursor at="w.var[i]"` / `\ref`.
- **`state=hidden` works on all 15 primitives**: a universal
  `.scriba-state-hidden{display:none}` rule hides slot-keeping groups on
  the primitives that don't omit hidden elements at emit
  (previously Graph/Tree/Plane2D only).
- **Matrix `block[r0:r1][c0:c1]`**: the 2-D area selector (recolor /
  annotate / `bracket=true`) now covers Matrix, closing the last 2-D
  primitive gap (R-35). DPTable-2D gains the same position-independent
  block box, so `bracket=true` draws a real outline there too instead of
  silently degrading to a plain pill.

### Changed
- `docs/SCRIBA-TEX-REFERENCE.md` overhauled per the three docs audits
  (author / brevity / generality): one-screen cheat-sheet + top-6
  footguns up front, computed-indexing rules table, cursor hop-vs-pin
  split, self-contained E-code table (+14 rows), spec-internals and
  duplicate examples removed — 2005 → ~1800 lines with no
  author-relevant knowledge dropped.

### Fixed
- `SCRIBA_VERSION` errata → 17: 0.23.1 changed rendered bytes (new
  `.scriba-sentinel`/`.scriba-ref-mark` CSS) while still carrying marker
  16. The marker now moves; treat 0.23.0 and 0.23.1 outputs as distinct
  cache keys regardless.

## [0.23.1] - 2026-07-04 — LinkedList frame stability + v1.1 polish

### Fixed
- **LinkedList no longer shifts horizontally on insert/remove** (the
  110px live-bbox jump excavated in
  `investigations/anim-reflow-sentinel.md`): its bounding box now follows
  a monotonic `_envelope_n` max-node-count envelope, grown by
  `apply_command` AND by a new opt-in structural prescan pass that
  replays insert/remove `apply_params` before the first frame is
  measured — R-32 now explicitly covers LinkedList.

### Added — v1.1 polish batch
- Binding carets can **park on Array sentinels**:
  `\cursor{a}{id=i, at="before"}` / `at="after"`.
- **`\playeach` supports NumberLine** (per-element `tick[i]` targets).
- **`\focus` with a nonexistent part warns (E1115) and skips it**
  instead of dimming the whole shape.
- **`\ref` baked ring** (R-39 v1.1): the referenced element's border
  gains a dash+weight ring (`scriba-ref-mark`) so the tinted word and
  the element read as one unit; state colour stays authoritative.
- **`\playeach` unknown action keys now fail fast** (`E1496`) instead
  of being silently ignored.

## [0.23.0] - 2026-07-04 — Animation clarity: reverse tween, carets, narration binding, playeach, reflow

### Added — animation-clarity Phase A (`SCRIBA_VERSION` →16)
- **Reverse & jump navigation now animate** (A-2): Prev/ArrowLeft tween by
  inverting the step's delta manifest (all 10 transition kinds are closed
  under inversion — probe-proven); multi-step jumps snap and then
  delta-emphasize what changed. Previously half of all navigation hard-
  snapped.
- **Delta-emphasis on arrival** (A-3): the exact targets that changed
  pulse briefly (compositor-only transform, ≤8 targets, honours
  `prefers-reduced-motion`, opt-out via `SCRIBA_NO_EMPHASIS=1`).
- **Named binding carets** (R-38): `\cursor{a}{id=i, at="w.var[i]",
  color="state:current"}` — multiple ▲ carets coexist, re-resolve a
  VariableWatch value each frame, and SLIDE between cells via the new
  `cursor_move` transition; legacy `\cursor{targets}{index}` unchanged.
- **Two dormant runtime gaps fixed**: `annotation_recolor` finally has a
  JS handler (a `\reannotate` color change no longer depends on the
  full-sync path), and position-pill add/remove/recolor tweens find their
  element through a key fallback (differ keys `-solo` vs emitter
  `-position-*` had silently no-opped them since 0.11).
- **`docs/spec/motion-ruleset.md`** — the A-0..A-8 motion invariants
  (identity-diff motion, mandatory inverses, emphasis-vs-state
  separation, marker identity, macro-frame indistinguishability, layout
  mutation via prescan, narration binding, reduced-motion ground truth);
  `check_ruleset_sync` now verifies BOTH rulesets (39 R + 8 A cards).
  Case files: `investigations/anim-{runtime-reverse,multicursor,
  narrate-focus,unified-motion-model}.md`.

### Added — animation-clarity Phase B (narration binding)
- **`\ref{sel}{text}` inside `\narrate`** (R-39): the word renders in the
  exact color of the referenced element's CURRENT state, re-resolved each
  frame — the "names it but doesn't point at it" class of confusion (35%
  of audited steps) is now structurally impossible. Unknown targets
  degrade to plain text + `E1322` warning; math inside the text arg
  survives; print/no-JS keeps the tinted span.
- **`\focus{sel}`** (R-40): spotlight the active set — everything else
  in that shape dims to 0.35 opacity; ephemeral, so the next step
  auto-restores. Range/block selectors expand as usual; undeclared shape
  → `E1116`.
- **`\step[title="..."]`**: a 3-5-word heading above the narration
  (doubles as the SVG title); byte-identical when absent.
- **`\invariant{...}`** (prelude-only, `E1058` after `\step`): a pinned
  predicate panel rendered once per widget, KaTeX-capable, visible in
  print.

### Added — animation-clarity Phases C & D
- **`\playeach`** (A-5): expand a collection-write into per-element auto
  frames — `\playeach{fac.range[1:5]}{state=done, cursor=w,
  narrate="Ghi $fac[${i}]$"}` generates one stepped frame per element
  (recolor sweep + optional caret + templated narration; `${i}` 1-D,
  `${r}`/`${c}` 2-D blocks). Expansion happens at the parser, so the
  generated frames are **byte-for-byte identical** to hand-written
  `\step`s (pinned by test). Frame cap 64 (`E1493`); `E1494`/`E1495`
  for bad selectors/actions.
- **Array insert/remove + sentinel slots** (R-42, slot-identity model):
  `\apply{a}{insert={at=2, value=7}}` / `{remove=2}` shift values within
  the fixed grid — cell positions never move (R-32 holds by
  construction), the freed tail renders as an empty cell, and the differ
  emits a plain value-change cascade (zero new runtime kinds). Insert
  into a full array → `E1403` (no silent data loss); partial-fill
  `data=` shorter than `size=` is now legal (`E1402` only fires on
  overflow). `sentinels=true` adds addressable `a.before`/`a.after`
  dashed slots so out-of-range iterators have somewhere to land.

## [0.22.2] - 2026-07-04 — Diagram-clarity decorations: trace, block, state colors

### Added
- **`block[r0:r1][c0:c1]` selector** (P0 of the unified decoration plan,
  R-35): the inclusive 2-D twin of `range[lo:hi]` for Grid and 2-D
  DPTable. `\recolor`/`\highlight` expand it to the full cell product;
  `\annotate` anchors its pill at the block's center (union AABB feeds
  the placer), computed from the dynamic content-based cell pitch.
  `${...}` interpolation works in all four indices for free (flat
  dataclass fields). Out-of-bounds/reversed degrade softly like every
  selector (E1115 path, no new E-code). Case files:
  `investigations/feat-grid-block-selector.md`,
  `unified-{decoration-model,conflict-audit,spec}.md`.
- **`bracket=true` annotate glyph for blocks** (P1): a no-fill dashed
  rounded outline hugging the block 3px outward, stroke colour-matched to
  `color=`, emitted in its own `data-annotation="…-block-bracket"` group
  (extent/differ/runtime see it like any decoration). The label pill
  keeps the standard placement pipeline. Single-cell targets ignore the
  flag.
- **`color="state:X"` label↔state binding + `leader=true`** (P2, R-36):
  a label can now carry the exact identifying color of the recolor state
  it describes (current/done/dim/good/error/path — previously only
  good/info/warn-family, so a blue `current` region was unlabelable in
  its own color). Quoted form required; inks are dedicated
  `--scriba-annotation-state-*` tokens, WCAG-AA on the white pill and
  dark-adapted; class rules cover text/lines/arrowhead/pill-border.
  `leader=true` draws a dotted connector + anchor dot from any pill to
  its cell; arc pills force the built-in leader instead of doubling.
- **`\trace` — an arrow that follows a sequence of cells** (P3, R-37):
  `\trace{g}{cells=[[2,0],[2,1],[2,2]], color=good, label="lớp lẻ"}` draws
  a rounded polyline through the cell centers with an arrowhead — the
  fill-direction/traversal is SHOWN, not inferred from cell numbering.
  Works on Grid, DPTable (1-D/2-D), Array, NumberLine; vertices track the
  dynamic content-based pitch; the interactive widget draw-ons the arrow
  along its path on the step it appears (stroke-dashoffset via the
  existing `annotation_add` machinery — zero new JS — inheriting the
  rapid-nav generation guard and reduced-motion/print static fallbacks).
  Traces persist like annotations (`ephemeral=true` to clear); `<2`
  points → E1491; out-of-range cells soft-drop. Paints above cell bodies
  (an under-stroke would vanish behind filled cells), below pills; digits
  keep their global halo.

## [0.22.1] - 2026-07-03 — Exact label math, grow-don't-clip, content-based cells

### Fixed
- **Mixed text+`$math$` annotation labels render intact** (`SCRIBA_VERSION`
  →14). Label/caption/tick widths containing math are now measured by a
  KaTeX advance-sum over the vendored font metric tables
  (`scripts/build_katex_metrics.py` → `katex_advances.json`, ~18 KB,
  stdlib-only at runtime): p50 |err| 0.06% / p95 0.66% vs Chromium, where
  the old strip-`$`-×1.15 heuristic sat at p50 43% and measured
  pure-command fragments (`$\to$`, `$\alpha$`) as **0 px**. Fragments the
  linear model can't see (`\frac`, `\sqrt`, unknown commands — 0.6% of a
  620-file corpus) fall back to the over-estimating heuristic, which only
  pads. Downstream fixes shipping together:
  - per-line label `foreignObject`s flip `overflow:hidden` →
    `overflow="visible"` — a rare under-measure now paints past its box
    instead of amputating glyphs;
  - the single-line math label drops its `display:flex` wrapper (flex
    swallowed the spaces around inline math and added a `gap` the measurer
    can't see) and shares the multi-line inline `nowrap` model;
  - `.scriba-annot-label` divs pin to `--scriba-annotation-font` — they
    inherited the embedding page's body font while their `<text>` siblings
    rendered mono, so the same label measured and painted differently;
  - the painted-extent parsers (production + test twin) now ingest
    `foreignObject` boxes — they previously matched only
    `g|rect|circle|polygon|polyline|line|path`, so every math label was
    invisible to the painted⊆bbox honesty pins.
  Labels get narrower (the heuristic over-reserved +40% on average), so
  pills, wrap points and reserved lanes tighten across every labelled
  scene. Consumer caches keyed on rendered output MUST invalidate
  (`SCRIBA_VERSION` 13→14). Case files:
  `investigations/folabel-{fonts,measure,emit-honesty}.md`.
- **Family sweep follow-up (3 BMAD case files:
  `investigations/folabel-sweep-{fo-surface,measure-callers,katex-cells}.md`):**
  - **Caption tall math no longer clips vertically.** The fixed 18px caption
    line box amputated 16/20 bench fragments (`$\frac{a+b}{c-d}$` lost half
    its denominator). Line heights are now adaptive via a browser-pinned
    tall-math ladder (`math_tall_extra`: `\frac`-family +10px, big-operator
    limits +9, stacked scripts +6, `\sqrt` +4, superscripts +3 at 11px,
    scaling with font size), caption lines stack cumulatively, the reserve
    (`_caption_block_height`) uses the same per-line formula, and the boxes
    are `overflow:visible` as a final safety. Pill line boxes take the same
    ladder for genuinely tall math (>=3px, beyond their padding).
  - **No-KaTeX pill sizing sizes what it paints.** Without a tex renderer
    the emitters fall back to painting the raw `$...$` string, but pills
    were sized by the KaTeX model — `$dp_{i}$` painted 40.5px into a 29px
    pill. `pill_dimensions`/`_wrap_label_lines` now take `math_rendered`
    and measure the raw string when no callback exists.
  - Matrix row/column labels and graph edge-weight KaTeX boxes stop
    clipping: labels flip to `overflow:visible`; edge weights size their
    foreignObject to the weight pill instead of the 80x30 default.
  - **Every foreignObject div now paints "Scriba Sans"** (new
    `--scriba-fo-font-family` token, inlined per div): cell values, axis,
    legend and tick labels previously inherited the embedding page's body
    font while their `<text>` siblings rendered the shipped face.
  - **No-KaTeX fallbacks paint the stripped form** (`$\max(y,x)$` →
    `max(y,x)`, no `$`/backslash noise) at every surface — pills, caption
    tspans, cell values — and `measure_value_text` sizes cells as
    `max(KaTeX model, stripped fallback)`: deterministic across both
    paint modes, and drops the raw-`$` over-reserve (a 129px cell for a
    16px glyph tightens to its real content).
  - **Caption math boxes are one px taller than their line box**
    (`line_height_px` decoupled from `fo_height`), absorbing KaTeX's
    fractional ink rounding so `scrollHeight <= clientHeight` holds
    everywhere — the caption bench is 20/20 clean.
  - **DPTable/Grid cells size to their content** (frame-stable): the fixed
    60px box clipped anything wider (`1000000` and `$\max(0,i)$` alike).
    Both primitives now port the Queue/Array monotonic `_cell_width`
    pattern (seed from init data, grow in `set_value`); the existing
    prescan pushes the max across the whole timeline before measure/emit,
    so frame 0 is already as wide as the widest future value — cells never
    breathe between steps, and annotation anchors track the wider pitch.
    Matrix floors `cell_size` to its widest formatted value when
    `show_values` is on (init-time; matrix data is static). Narrow content
    keeps the old floors — existing documents are byte-identical.
  - **Caption/diagram superscripts no longer jump a full line up**: the
    `<text>`-styling rules for `.scriba-primitive-label` /
    `.scriba-index-label` carried `dominant-baseline: central|hanging`,
    which — when the same class rides a KaTeX foreignObject — rebases
    every HTML inline box inside and threw vlist superscripts ~18px above
    their line (the static-diagram caption `$(m-1)^2$` painted its ² on
    the row above, over the words). Both rules are now `text.`-scoped,
    and label-class FO divs inline the mono label family so the wrapped
    caption's plain and math lines match.
  - **No more raw `$...$` flash during value transitions**: the runtime's
    `value_change` tween wrote the raw string into the cell `<text>`
    mid-animation before the frame swap restored the KaTeX render; math
    values now pulse without rewriting text.
    (`investigations/fixedbox-content-sizing.md`)

## [0.22.0] - 2026-07-03 — Exact text metrics, one runtime, every script

### Added
- **Every-script rung 0** (`SCRIBA_VERSION` →13): Thai/Devanagari/… names
  work in selectors, VariableWatch and `\step` labels (identifier charsets
  now accept combining marks — Python's `\w` excludes Mn/Mc, which the
  0.21.1 Unicode pass missed); spaceless-script labels (Thai/Lao/Khmer)
  wrap cluster-safely instead of overflowing as one unbreakable token;
  RTL-bearing SVG text gets `unicode-bidi:plaintext` (mixed "نتيجة
  (result) = 42" no longer mis-mirrors) and every narration `<p>` carries
  `dir="auto"`; complex-script widths warn once per script (`W1301`) that
  they are safe over-estimates. CJK stays exact at 1em by construction.
  The `[intl]`/`[shaping]` exactness ladder is designed in
  `investigations/allscript-{architecture,shaping,render-audit}.md`.

- **The smart-label forbidden-pattern backlog is zero, and gated there.**
  All annotation paths route through the shared dispatcher (FP-6 — also
  repairing a latent measure≠paint asymmetry on NumberLine/Plane2D); each
  primitive's content labels share ONE placement registry
  (`register_decorations`) exposed to the scorer via
  `resolve_self_content_rects` (FP-2 — fixes a confirmed point-label /
  line-label overlap on Plane2D); duplicated layout constants are
  consolidated with two live drifts reconciled (FP-3 — plane2d CJK
  line-label width, graph weight-pill pads 5/2/3→6/3/4). Guards:
  a constant-parity registry (`test_layout_constant_sync`), a
  module-level FP-3 birth check, and the lint ceiling held at 0.
- **Diagram env options are wired** (`label=` → `aria-label`,
  `width=`/`height=` → max-size), the ruleset sync guard is structural and
  lives in the suite, and the `grid=`/`side=` phantom options are gone
  from the docs (probes proved `side=` inert; `position=` is the knob).

### Changed
- **Cell/node text is now measured exactly against a shipped font**
  (`SCRIBA_VERSION` 11→12 — rendered bytes change across every labelled
  scene). scriba embeds a 34 KB Inter subset (full Vietnamese coverage,
  OFL) as `"Scriba Sans"`, pins it first in `--scriba-cell-font` /
  `--scriba-node-font`, and computes the 14px surface's width from the
  font's own advance table (baked at vendor time by
  `scripts/build_text_font.py`; runtime stays stdlib-only). The old
  0.62em/char heuristic measured −38%..+154% per string; advance-sum
  measures the browser to ~0.1–0.5% (see
  `investigations/text-width-bench.md`). Digits honour the `tabular-nums`
  the cell CSS forces (a raw hmtx sum is 13% narrow); input is
  NFC-normalized so decomposed Vietnamese measures identically. The
  heuristic remains as the fallback for CJK/out-of-subset codepoints, ZWJ
  clusters, the monospace label surfaces, and any build without the
  vendored table.


### Changed
- **One runtime source.** The inline widget `<script>` is now DERIVED from
  `static/scriba.js` (sentinel-sliced at import, token substitution — no
  `str.format`); `_script_builder.py` authors no runtime JS anymore. The two
  hand-maintained copies had drifted bidirectionally: the asset's A#03
  narration-defer a11y fix never shipped on default (inline) pages, and the
  inline's annotation fade-on-snap never reached the asset. Both features now
  ship everywhere (`_fadeInNewAnnotations` lives in `scriba.js`), and a
  byte-equality test pins the derivation so the copies cannot drift again.
- **Scene layout is measured by one shared replay** (`measure_scene_layout`):
  the viewBox and the per-primitive stacking offsets derive from the same
  per-frame checkpoints, replaying structural push/pop AND mid-timeline
  captions. Previously the offsets replayed neither — a Stack growing above
  an Array overlapped it by up to 90px, and a late `\apply{p}{label=…}`
  caption painted past the viewBox and into the next primitive.

### Fixed
- **Late captions and growing primitives reserve their space.** The scene
  viewBox and the stacking offsets now come from ONE shared replay
  (`measure_scene_layout`) covering structural push/pop and mid-timeline
  `\apply{p}{label=…}` captions — a Stack growing above an Array
  overlapped it by up to 90px, and a late caption painted past the
  viewBox into the next primitive.
- **The standalone theme-toggle script is derived from `scriba.js`**
  (sentinel slice) instead of a third hand-maintained copy; a stale
  0.9-era CHANGELOG claim that inline-runtime stopped being the default
  carries a dated correction (it never did).
- **MetricPlot gridlines render again (and Plane2D labels get their font).**
  `render.py` collected artifact-declared CSS only for TeX gaps and shipped a
  hardcoded sheet list for widgets, so `scriba-metricplot.css` /
  `scriba-plane2d.css` never reached the page (gridlines computed
  `stroke: none`). Widget artifacts' `css_assets` now join the same
  base-subtracted, sorted bundle as the TeX assets — and `DiagramRenderer`
  now declares primitive CSS exactly like `AnimationRenderer` (it hardcoded
  the 2 base sheets, starving every `\begin{diagram}`+Plane2D document).
- **Orphaned staggered transition no longer corrupts a superseded frame.**
  A `setTimeout(_runPhase2)` scheduled during the phase-1→phase-2 stagger
  survived a supersede/cancel and applied phase-2 recolors/value-changes onto
  the snapped stage (a quick Next→Prev left frame 1 showing frame-2 values).
  A generation token (`_gen`, bumped in `_cancelAnims`) now aborts orphaned
  callbacks at the top of `_runPhase2` and `_finish` — covering all four
  async completion paths.

## [0.21.2] - 2026-07-02 — Exact reservation, content-aware pills, math everywhere

### Changed
- **Rendered HTML/SVG bytes differ from 0.21.1** (`SCRIBA_VERSION` bumped 9→11,
  two byte-shape changes in one release). Consumer caches keyed on rendered
  output MUST invalidate.
- **The annotation lane above every primitive is now the EXACT painted extent**
  instead of a heuristic upper bound. `annotation_height_above` runs the real
  annotation emitters into a scratch buffer and measures the output
  (closed-form cubic-Bézier extrema, stroke folded in), so reserved space
  shrinks to what is painted — and grows where the old estimate under-reserved
  (self-loop arrows were clipped). The same exactness applies horizontally and
  below: painted ⊆ declared bounding box within 0.01px on all four sides.
  Substory scenes and print frames receive the reservation for the first time.
- **All pill placement goes through one scorer** (`_place_pill`); the legacy
  first-fit loops and seven height/width estimators are retired. Multi-line
  pills center their text block; math pill labels wrap like plain labels and
  render KaTeX per line.
- **Captions clear their content by a uniform 8px gap** on every primitive and
  every branch (single-line, wrapped, math). Grid/Matrix/NumberLine captions sat
  at −0.6px (glyphs touching the cells); Queue captions shared the index-label
  baseline; VariableWatch-family captions painted past the bounding box when an
  arrow lane was reserved (double-counted `arrow_above`).
- **TeX page regions are wrapped in `.scriba-tex-content`** and the standalone
  page CSS bundle ships whatever the renderer artifacts declare. Previously the
  wrapper was missing and the content stylesheet was never bundled, so all 71
  typography rules and the 53 pygments rules silently never matched — code
  blocks had no background/highlighting and a long code line stretched the
  whole page on narrow viewports instead of scrolling inside its block.

### Added
- **Pills are content-aware (spec R-33/R-34).** A primitive's own cells join
  the placement scoring as soft obstacles, arc labels wrap to the primitive's
  content span instead of a fixed character budget, the auto side-hint covers
  all four quadrants, and short arrows deep inside a grid escape to the clear
  lane above/below the content — previously the pill parked on the cells at its
  natural anchor (covering, for short vertical arrows, its own target cell).
- **`$…$` math renders on every user-visible text surface**, now pinned by a
  guard matrix (cell values, `\apply value=`, watch names/values, matrix
  row/col labels, stack items, hashmap entries, plane2d point/line labels,
  codepanel captions, graph edge weights incl. `split_labels`). The one real
  engine gap — graph split edge labels escaping `$` raw — is fixed; the
  canonical KaTeX-sites list in `docs/spec/environments.md` §8.4 is rewritten
  (12 site families plus the deliberately-verbatim list).
- **Animation env options are honoured end-to-end:** `label=` becomes the
  widget `aria-label`, `width=`/`height=` become max-size constraints,
  `layout=` lands as `data-layout`; the documented-but-dead `grid` option is
  rejected with `E1004`.
- **Pygments light + dark sheets ship as a pair** — the dark sheet is entirely
  `[data-theme="dark"]`-guarded, so code token colors follow the runtime theme
  toggle instead of staying light-on-dark.

### Fixed
- **Rapid Next/Prev clicks no longer swallow frames.** The runtime committed
  the current-frame index only after the ~250ms transition settled, so clicks
  landing mid-animation stepped from a stale index (4 fast Next clicks advanced
  2-3 frames; Next then a quick Prev did nothing). Both runtime copies (external
  asset and inline builder) are patched and pinned to the same contract.
- Mixed text+math labels in a `foreignObject` no longer clip vertically or have
  their spaces eaten by flex layout; FO text carries an explicit font-size
  matching its plain-text twin; graph/tree math node labels overflow like plain
  node text instead of clipping.
- Wrapped `<tspan>` lines keep a trailing space so copied text keeps word
  breaks; diagram-mode widgets no longer emit a dangling `aria-labelledby`;
  single-line caption anchoring is CSS-independent.
- Array captions route through the shared caption helper (raw `$` no longer
  leaks in the bespoke multi-line branch).
- R-19 degraded-placement warnings are measured against the score minus the
  unavoidable content-cell floor, restoring the threshold's meaning in dense
  grids (13 false warnings → 0).

## [0.21.1] - 2026-07-01 — Unicode identifier support

### Added
- **Unicode-letter `\step` labels are now valid HTML frame ids.** A label with a
  non-ASCII leading letter (e.g. Vietnamese `đáp`) is emitted as
  `id="{scene}-đáp"` and participates in `\hl{…}` cross-references and JS
  deep-linking, instead of being silently downgraded to the `frame-N` fallback.
  The SCRIBA-TEX-REFERENCE §5.3 label charset is widened to `[^\W\d][\w.-]*`.
  `SCRIBA_VERSION` is unchanged: ASCII output is byte-identical — only
  previously-degraded non-ASCII output changes.

### Fixed
- **VariableWatch `var[…]` selectors accept non-ASCII-leading variable names.**
  A name like `đáp` rendered as a row but its value updates were silently dropped
  (`E1115`) because `_VAR_RE` used an ASCII-only leading class while the lexer is
  Unicode-aware. Aligned to `[^\W\d]\w*`.
- **`\foreach` `${…}` interpolation binding-check scans Unicode-leading refs.** An
  undefined non-ASCII `${đáp}` reference in a foreach iterable is now reported
  instead of silently skipped — the scanner regex was ASCII-only while the
  loop-variable and binding checks were already Unicode-aware.

## [0.21.0] - 2026-06-30 — Annotation & caption legibility across all primitives

### Changed
- **Rendered HTML/SVG bytes differ from 0.20.x** (`SCRIBA_VERSION` bumped 8→9).
  Consumer caches keyed on rendered output MUST invalidate.
- **`--scriba-diagram-font-scale` now scales the whole diagram uniformly** (the
  factor is applied to the `<svg>` viewport `max-width`; font sizes are fixed
  in user-coordinate space). Text and geometry scale by the same ratio, so text
  can never overflow its shapes at any factor — superseding the previous
  text-only behavior where large factors could overflow (per-text
  `font-size:calc(... * var(...))` → fixed `Npx`; the `<svg>` gains
  `max-width:calc(... * var(...))`).

### Fixed
- **Captions fit their figure on every primitive.** Long `label=` captions wrap
  and their width *and* height are folded into the bounding box across all 13
  caption-bearing primitives (Array, Queue, Stack, Matrix, HashMap, LinkedList,
  VariableWatch, Grid, NumberLine, DPTable, Tree, Graph, and CodePanel via title
  ellipsis), so a caption can no longer clip at the figure edge or overlap
  content. (Layer A.)
- **Position annotations are no longer silently dropped.** `range[a:b]` and
  `position=below` targets now resolve and render on every data-structure
  primitive — previously dropped on Array, DPTable, NumberLine, Queue, Stack,
  Matrix, HashMap, LinkedList, VariableWatch, Grid, and CodePanel. (Layer B/C.)
- **`position=below` labels sit in a callout lane** beneath the index/caption
  stack — wrapped compact, never overlapping the element, each with a leader
  line back to its cell; the annotated element is a collision blocker; the
  bounding box reserves the lane so nothing clips. NumberLine below-pills clear
  the tick labels.
- **Wide pills reserve horizontal space.** `position=left`/`right` and wide
  above/below pills extend the bounding box (left overhang + right reach) so a
  pill can no longer clip at the figure's left or right edge.
- **Competing above-annotations stack instead of dropping.** A spanning
  `range[a:b]` above-label and a single-cell above-label that contend for the
  same zone are placed at different heights (range gets a span bracket + leader),
  so neither is dropped or overlapped.
- **`range[a:b]` position labels show a span bracket** across the range's cells
  (with a stem to the label), so a range annotation's extent is unambiguous;
  single-cell labels get none (no clutter).
- **Cross-primitive obstacle avoidance restored.** Position-pill placement again
  treats cross-primitive segment obstacles (e.g. neighboring Plane2D lines) as
  hard blocks — the merged obstacle set was computed but dropped before reaching
  the placement scorer, leaving avoidance inert.

## [0.20.0] - 2026-06-24 — Compact embed widget (overlay controls, tidy spacing, overlap-safe annotations)

### Changed
- **Rendered HTML/SVG bytes differ from 0.19.x** (`SCRIBA_VERSION` bumped 7→8).
  Consumer caches keyed on rendered output MUST invalidate.
- Interactive widget chrome is far more compact: the step controls now float as
  a translucent pill over the stage instead of a full top bar, and the narration
  is a borderless caption (no bordered box, no `min-height`).
- Controls are language-neutral: prev/next render as chevron glyphs (❮ ❯) and the
  step counter dropped the "Step"/"Sub-step" word (just "N / M"). `aria-label`s
  keep the English description for screen readers.
- Inter-primitive spacing tightened: `_PRIMITIVE_GAP` 50→20 and `_PADDING` 16→12;
  stage padding trimmed.

### Added
- The stage `<svg>` now carries an intrinsic `max-width` equal to its viewBox
  width, so a viewBox-only drawing is never upscaled past its natural size when
  embedded in a wide column — rendered height stays bounded. CSS gains a
  `max-height` / `overflow` guard.
- Every annotation-bearing primitive's `bounding_box` now reserves
  `position=below` pill headroom (via `position_label_height_below`), and
  `arrow_height_above` is now a verified upper bound on the real arrow-arc +
  label extent (long arrows, stroke/halo, upward nudge). Together these make the
  tightened inter-primitive gap overlap-safe.

### Removed
- The progress-dots row in the main interactive widget — the step counter is the
  sole progress indicator now (substory widgets keep their dots).

### Fixed
- VariableWatch table borders no longer break: the outer border, column divider
  and row dividers are drawn on top of the value-cell fills (previously the idle
  fill painted over them), the fills are clipped to the rounded outline, and the
  name column is centered to match the value column.

## [0.19.0] - 2026-06-03 — Graph layout stability (pinning, isolated-node lane, auto-seed)

### Changed
- **Rendered output bytes differ from 0.18.x** for graphs (cache-busting;
  `SCRIBA_VERSION` bumped 6→7). Graphs that mutate edges, contain isolated nodes,
  or rely on the default `layout_seed` now render different SVG. Consumer caches
  keyed on rendered HTML/SVG MUST invalidate.
- **`layout_seed` is now an optional/advanced knob.** Authoring guidance in
  `docs/SCRIBA-TEX-REFERENCE.md` §7.4 now recommends declaring full topology +
  `\recolor` (or `layout="stable"`) instead of seed-guessing; `layout_seed` is
  documented as cosmetic/reproducibility only.

### Fixed
- **Graph node positions are pinned across edge mutations.** `\apply` `add_edge`/
  `remove_edge` no longer re-solve the whole layout each frame — nodes keep their
  construction-time coordinates and only the edge geometry/weight changes. This
  removes the "nodes teleport every step" instability (the node set never changes
  for a Graph). `set_weight` already did not relayout.
- **Isolated (degree-0) nodes no longer get flung to a corner.** They are placed
  in a deterministic, evenly-spaced lane inside a reserved inner band, disjoint
  from the connected-node region (no overlap, not on the border).
- **`remove_edge` clears persistent edge decorations.** A `\recolor` on an edge
  that is later removed no longer triggers a spurious `E1115 invalid selector`
  warning in the frame where the edge is gone — the decoration is dropped when
  the edge is removed (rendered output unchanged; this removes warning noise).

### Added
- **Auto-seed selection for `layout="force"`.** When the author does not pin a
  `layout_seed`/`seed`, the Graph constructor sweeps candidate seeds (0–7), scores
  each layout with a new readability scorer (`graph_layout_score.score_layout` —
  penalises edge crossings, border-hugging, poor spread), and keeps the best
  (tie-break smallest seed). An explicit seed always wins with byte-identical
  output; `stable`/`hierarchical` layouts are unaffected.
- **Isolated-node warning.** A Graph that declares edges but leaves a node
  unconnected now emits a soft, one-time `UserWarning` naming the isolated node(s)
  and pointing to the fix. A pure node-set with no edges does not warn.

## [0.18.0] - 2026-06-02 — render-content fixes (narration interpolation, captions, recolor, defs)

### Changed
- **Rendered output bytes differ from 0.17.x** (cache-busting; `SCRIBA_VERSION` bumped 5→6). Consumer caches keyed on rendered HTML/SVG MUST invalidate. Drivers of the change are the fixes below: state classes now appear on the NumberLine axis and MetricPlot root, directed graphs emit one arrow-marker `<defs>` instead of two, narration text resolves `${...}`, and `\apply`/`\reannotate`/`position=inside` now produce their intended markup.
- **Trust-renderer reconciliation of two documented "gotchas"** (the renderer behaviour was the desirable one; docs/`.expect` were corrected): a Graph `int` node can be addressed as a string (`G.node["1"]` matches `G.node[1]`), and a Stack/Queue item recoloured in the **same** `\step` as its `push`/`enqueue` now applies to the committed frame. Updated §13.1 and the Graph node-id note in `docs/SCRIBA-TEX-REFERENCE.md`.

### Fixed
- **`\narrate` resolves `${name}` interpolation.** A `\compute` binding referenced in narration (e.g. `\narrate{fib(6)=${result}.}`) now renders its value instead of leaking the literal `${result}`. An unknown name is left verbatim (narration never errors); scope follows the compute rules (prelude bindings reach every step, step-local bindings do not leak forward).
- **`${...}` no longer collides with `$...$` math.** An unresolved `${name}` adjacent to a stray `$` is shielded before math parsing, so it stays literal instead of being mis-parsed into `$...$` (e.g. `${y}$` no longer renders as KaTeX). A properly-closed `$...$` is always its own math. See §13.4.
- **`\apply{p}{label="..."}` renders the caption.** A bare-shape label was previously dropped; it now updates the primitive's caption.
- **`\reannotate` re-points the arc and replaces the text per §5.9.** `arrow_from` now replaces the arc source (it was mistakenly treated as a match filter) and `label` replaces the annotation text (it was unparsed). `color` still replaces as before.
- **`\annotate{...}{position=inside}` is now distinct.** The pill is centred on the cell instead of falling through to the `above` placement.
- **`\recolor` is no longer inert on the NumberLine axis or MetricPlot.** `\recolor{nl.axis}{state=...}` and `\recolor{plot.all}{state=...}` now emit the `scriba-state-*` class (the axis was hardcoded idle; MetricPlot had no state emission).
- **`{\large text}` group-scoped size switch no longer leaks braces.** The brace-before-command form (`{\tiny a}{\small b}…`) now scopes correctly instead of leaking literal `{`/`}` and mis-sizing trailing text.
- **Directed graphs emit the arrow-marker `<defs>` once.** The Graph primitive no longer emits its own copy on top of the shared root `<defs>`, removing a duplicate `id="scriba-arrow-fwd"` (invalid SVG).

## [0.17.1] - 2026-06-01 — fix indented `\compute` blocks

### Fixed
- **Multi-line `\compute{...}` blocks with an indented body no longer fail with `E1150` ("unexpected indent").** The parser now `textwrap.dedent`s the compute body before handing it to Starlark, removing the common leading whitespace while preserving relative indentation inside `for`/`if` blocks. The naturally-indented examples in `docs/SCRIBA-TEX-REFERENCE.md` §5.2 (filtered comprehension and the nested-loop matrix-chain DP) now render. (`SCRIBA_VERSION` unchanged — this is a parse fix and does not alter rendered output bytes.)

## [0.17.0] - 2026-06-01 — fail-loud validation, render fixes, reference overhaul

### Added
- New author-facing error codes, replacing silent failures: **E1159** (a `${name}` selector index outside `\foreach` with no matching `\compute` binding), **E1321** (`\hl` references an unknown step-id), **E1467** (malformed Plane2D `add_*` element spec), and **E1421** is now raised for an unknown Matrix `colorscale` (only `viridis` is supported).

### Changed
- **Rendered output bytes differ from 0.16.x** (cache-busting; `SCRIBA_VERSION` bumped 4→5): the animation viewBox is now sized to the **maximum extent across all frames**, so size-changing primitives (Stack, Queue) are no longer clipped when they grow; and `CodePanel` renders its `label` as a **top header bar** (IDE-tab style) instead of a bottom caption. Consumer caches keyed on rendered HTML/SVG MUST invalidate.
- **Tree node ids are normalized to string.** A standard tree built from numeric literals (`nodes=[8,3,10]`) now matches both `T.node[8]` and `parent="3"`-style string refs — numeric and string node references are interchangeable for Tree. (Graph keeps strict node-id typing.)
- Fail-loud where Scriba previously silently dropped a command or rendered wrong output: unknown `colorscale`, malformed Plane2D add-specs, unknown `\hl` step-ids, and unbound `${name}` selector indices now raise instead of no-opping. Sources relying on the old silent behavior will now error.

### Fixed
- **`\apply` value interpolation now resolves.** `\apply{...}{value=${x}}` and the subscript form `${dp_vals[i]}` previously leaked an `InterpolationRef` repr into the cell; they now resolve against `\compute` bindings. `${var}` also resolves in selector-index positions outside `\foreach`.
- **`\hl` cross-reference spans are no longer HTML-escaped** in narration — the highlight markup now renders as a real `<span>` instead of literal `&lt;span&gt;` text.

### Docs
- Major overhaul of `docs/SCRIBA-TEX-REFERENCE.md`: added a table of contents and an index-by-task, a full parameter table for every primitive, corrected field specs (`colorscale`, Plane2D `add_region`/`add_line`, Matrix/Grid `data` shape, NumberLine `ticks`, `label` vs `labels`), moved phantom/forward-compat items into an appendix, and fixed stale claims (graph layouts, Tree vs Graph node-id rules, interpolation reliability, error-code meanings).

## [0.16.0] - 2026-05-31 — embedder font-scale knob + boundary validation

### Added
- **`--scriba-diagram-font-scale` CSS knob**: consumers embedding Scriba output can resize **all** diagram text with one declaration, e.g. `:root { --scriba-diagram-font-scale: 1.3; }`. Every inline SVG `font-size` is now emitted as `calc(Npx * var(--scriba-diagram-font-scale, 1))`, and the CSS role rules (cell/node/index/label/annotation, numberline tick, metricplot legend) honour it too. Default of `1` leaves rendered sizes unchanged. See [`spec/animation-css.md`](docs/spec/animation-css.md) §2.1a and the new [`guides/embedding-in-website.md`](docs/guides/embedding-in-website.md).
- New E-codes documented in `spec/error-codes.md`: **E1013** (tex source-size cap), **E1014** (NUL byte), **E1015** (TeX structural validation failure), **E1117** (math-item cap).

### Changed
- **Rendered output bytes differ from 0.15.x** (cache-busting; `SCRIBA_VERSION` bumped 3→4): SVG `font-size` values are now `calc(...)` expressions, and substory widget ids are deterministic (`sub-{frame_id}-{substory_id}`) rather than counter-based. Default-scale rendering is *visually* identical. Consumer caches keyed on rendered HTML/SVG MUST invalidate.
- `TexRenderer.render_block` now runs the structural validator (`scriba/tex/validate.py`) before the KaTeX worker. Structurally invalid TeX (unbalanced braces, mismatched/unknown environments, odd `$` parity) fails fast with `ValidationError(code="E1015")` instead of reaching the worker. The validator was previously exposed but unwired.

### Fixed
- **Cross-render state leaks removed.** The module-level `_substory_counter` in `_html_stitcher.py` (never reset) made in-process multi-render non-deterministic; widget ids are now derived from stable inputs. The module-level `_collected` warnings buffer in `graph_layout_stable.py` (drained only by tests, leaked across renders) was removed in favour of the per-render `RenderContext` collector.
- Boundary `ValidationError` raises (NUL byte, source-size cap, math-item cap) now carry E-codes for scheme consistency.
- Operator-supplied `--lang` is now HTML-escaped before the `<html lang="...">` attribute (defense-in-depth).

### Internal
- Deduplicated suffix selector regexes into `scriba/animation/primitives/_types.py`; registered `unit`/`integration`/`e2e` pytest markers; added `tests/e2e/test_cli_smoke.py` and removed the empty `tests/regression/` scaffold.

## [0.15.3] - 2026-04-24 — README status fix

### Fixed
- **Stale README on PyPI**: 0.15.2 was published with a README still showing `Status: v0.9.0`. 0.15.3 syncs README + CONTRIBUTING + packaging.md to the current version. No code changes from 0.15.2. 0.15.2 has been yanked; prefer 0.15.3.

## [0.15.2] - 2026-04-24 — sdist size fix (yanked)

### Fixed
- **sdist bloat**: 0.15.1 shipped a 48.5 MiB source tarball because the default hatch sdist target bundled `docs/archive/**` (including 1 MiB per HTML research artifact), `.claude/**`, `tmp_audit/`, and other VCS-tracked content. 0.15.2 whitelists `scriba/`, `tests/`, and user-facing metadata only — sdist drops to ~1 MiB (48× reduction). No code changes from 0.15.1.

### Yanked
- 0.15.2 was yanked on 2026-04-24 because the README on the PyPI landing page still showed `Status: v0.9.0`. Replaced by 0.15.3.

## [0.15.1] - 2026-04-24 — GEP v2.0.0 + Sugiyama + R-32 + audits

### Fixed
- **Reflow flash eliminated.** Annotation-induced vertical displacement (measured up to +56 px y-jump across 10 rendered examples — most severe: `dp_optimization` step 2→3, `convex_hull_trick` step 0→1, `houses_schools` step 1→2, `kruskal_mst` step 4→5) no longer snaps between frames. Layout envelope is now reserved per-scene at the max of `bounding_box()` over all frames, so primitive y-offsets are constant across frames regardless of which frames carry annotations.
- **L1 landmine** in `_html_stitcher.py`: the `set_min_arrow_above` guard used `hasattr(prim, "_arrow_height_above")` which always returned `False` (the function is module-level, not an instance method). `Array` and `DPTable` silently got `max_ah=0` — now fixed by calling `arrow_height_above` directly.

### Added
- **R-32 Annotation Stable Layout** ruleset entry — `ruleset.md` §8.9. Six normative sub-invariants: intra-bbox stability (R-32.1), inter-primitive y-cursor stability (R-32.2), max-envelope reservation (R-32.3), `bounding_box()` purity (R-32.4), reduced-motion parity (R-32.5), determinism (R-32.6).
- Error codes `R32-01`..`R32-05` (`error-codes.md`) surfaced by the conformance suite.
- `_html_stitcher._build_reserved_offsets()` — per-scene max-bbox pre-scan producing `dict[shape_name, (x_off, y_cursor)]`. Threaded through `emit_substory_html` and the print-substory block of `emit_interactive_html`.
- `_frame_renderer._emit_frame_svg()` gained `reserved_offsets` kwarg; when supplied, replaces per-frame `y_cursor` accumulation with direct lookup. Falls back to legacy accumulation when `None`.
- WAAPI opacity fade for newly-added annotations on frame navigation (`_script_builder._fadeInNewAnnotations`): 220 ms cubic-bezier(0.16,1,0.3,1), gated on `_canAnim`. Wired in `snapToFrame` only (`animateTransition` already animates draw-in via the `annotation_add` path).
- Conformance suite `tests/conformance/test_r32_annotation_stable_layout.py` — 18 tests, 725 lines, `@pytest.mark.conformance`. Covers R-32.1 × 6 primitives, R-32.2/R-32.3 × 4 scenes, R-32.4 × 6, R-32.5 × 1, R-32.6 × 1.
- `tests/animation/test_differ.py` regression for L4 hyphen-collision in `f"{target}-{arrow_from}"` composite key — `xfail(strict=True)` tombstone documenting the deferred fix (would require coordinated emitter format change + 6 golden re-pins).

### Changed
- `bounding_box()` purity (R-32.4) is now a normative contract — see `primitives.md` §2.6. Affects `Array`, `DPTable`, `Queue`, `Plane2D`, `Tree`, `Graph`. Pre-scan in `_build_reserved_offsets` restores `set_annotations([])` after probing.
- L3 purity fix in `_frame_renderer.compute_viewbox`: annotation state is now cleared after the bbox-probe loop.

### Docs
- `ruleset.md` §8.9 Annotation Stable Layout (R-32).
- `error-codes.md` — R32-01..R32-05.
- `svg-emitter.md` §3.4 — `reserved_offsets` contract.
- `primitives.md` §2.6 — `bounding_box()` purity contract.
- Research archive `docs/archive/annotation-reflow-flash-2026-04-23/` — 11 files: archaeology, cross-primitive survey, approaches A/B/C, R-32 spec, implementation plan.

### Affected commits
- `272d5d6` — research archive + R-32 spec + impl plan.
- P0.1 — L1 fix.
- P1.1 — `_build_reserved_offsets` + `reserved_offsets` thread-through.
- P1.2 — R-32 conformance tests.
- P2 — WAAPI annotation fade.
- P3 — L3 purity fix.
- P4 — multi-hat review.
- `2cd03b7` — P4 follow-ups (substory threading + L4 xfail).

### Performance
- `_build_reserved_offsets` pre-scan benchmark (ship criterion < 50 ms):
  worst-case **0.52 ms** on `houses_schools` (19 frames × 3 primitives).
  `dp_optimization` 7×2 = 0.09 ms, `convex_hull_trick` 9×3 = 0.16 ms,
  `kruskal_mst` 11×3 = 0.15 ms. ~96× under budget; no memoization needed.

### Graph edge pill (GEP v2.0.0)
- Phases 0–7: node-circle guard + determinism sort, edge-aligned rotation, on-edge placement (v1.1), along-edge shift + rotated-AABB steric (v1.2), saturate probe (v1.3), side-preferred perp (v1.3.1), on-stroke invariant (v1.3.2), leader-line fallback (v1.4.0), pre-layout auto-expansion (v2.0.0-P5), typography hierarchy + soft tint (P6), simulated-annealing global refinement (P7).
- GEP-01..GEP-13 ruleset in `docs/spec/graph-edge-pill-ruleset.md`.

### Graph hierarchical layout
- Real Sugiyama longest-path layerer + barycentric crossing reduction behind `layout="auto"` (new default for directed graphs). `orientation="LR"|"TB"` supported; legacy `stable` retained.
- `_MIN_LAYER_GAP` 100 → 140 so pills clear the arrowhead zone.

### Reference + examples audits
- **TEX-REFERENCE** 47-issue 4-agent audit closed: Tier 1+2+3 fixes + AI-authoring callout in README. Integer node-ID quoting, `state=highlight` via `\recolor`, `layout="stable"+directed=true` gotcha, optional env bracket now documented.
- **Examples audit** (4-agent scout+A/B/C): cleared E1115/E1462 warnings (union_find_tree reparent cascade, segtree_editorial topology, Stack pop state); consolidated corpus 113→111 (2 dup deletes, 2 moves); split into smoke/demos/fixtures for bucketed testing; `ACCEPTED_PARAMS` aligned with actual behavior; Plane2D `xlabel`/`ylabel` dropped (never rendered).

### Arrow
- End stem/path at arrowhead base, not tip.

## [0.15.0] - 2026-04-23 — Leader-line visual-gap gate

### Changed
- **Leader-line gate rewritten** (`emit_arrow_svg` / `_emit_label_and_pill`): the emit decision now uses a visual-gap metric (`‖pill_center − curve_mid‖`) instead of algorithmic displacement (`‖final − natural‖`). Single threshold: `pill_h/2 + 4 + pill_h × 1.0`. Leaders now fire for **every** colour when the pill is visually offset from its arrow.
- **Rules R-27 and R-27b superseded** by R-27c (visual-gap gate). Colour-gate restriction (leaders only for `warn`/`error`) is gone; any colour emits.
- Warn-only `stroke-dasharray="3,2"` cosmetic preserved.
- R-08 perimeter endpoint clipping preserved.

### Deprecated
- `_LEADER_DISPLACEMENT_THRESHOLD` and `_ARROW_LEADER_FAR_FACTOR` constants in `_svg_helpers.py` — retained for import-stability, unused by the gate.

### Fixed
- DP-optimization leader audit case #6 (`dp.cell[0][6]-dp.cell[4][6]`, `final merge`): 103 px visual gap between pill and arrow now correctly emits a leader. See `docs/archive/dp-optimization-leader-analysis-2026-04-23/README.md`.

### Tests
- `test_w3_batch1.py`: `test_scale_relative_formula_applied` → `test_visual_gap_formula_applied`; `test_leader_emitted_when_displacement_exceeds_scale_threshold` → `test_leader_emitted_when_visual_gap_exceeds_threshold`. 86 pass.
- Golden fixtures regenerated (see D10 commit).

## [0.14.0] - 2026-04-22 — Phase D: flow-as-scoring-hint + CellMetrics everywhere

### Changed (arrow geometry)

- `_compute_control_points` no longer takes a `flow` kwarg. The
  stagger-flip sentinel now reads `cell_metrics is not None`. One fewer
  redundant signal threaded through the hot path; the `FlowDirection`
  classification is deferred to scoring only.
- `Graph` and `Tree` now construct a `CellMetrics` (node-diameter proxy:
  `cell_width = cell_height = 2 * node_radius`, `grid_cols = len(nodes)`,
  `grid_rows = 1`) and pass it into the emit pipeline. Stacked
  annotations on the same target node now fan symmetrically around the
  source-destination line via the 2D stagger-flip.

### Changed (scoring)

- `_ScoreContext` gained a `flow: FlowDirection | None` field, populated
  inside `_emit_label_and_pill` via `classify_flow(dx, dy, cell_metrics)`
  when `cell_metrics` is supplied. P7 edge-occlusion contributions from
  `annotation_arrow`-kind segment obstacles are multiplied by `0.75`
  when `ctx.flow is not None` — flow is a scoring hint, not a geometry
  override. Non-`annotation_arrow` obstacles (pills, cell borders,
  arrow shafts from prior passes) are unaffected.
- Golden scenes (Array / DPTable / Queue) emit zero
  `annotation_arrow`-kind segment obstacles, so net scoring drift = 0.
  Verified: no golden regen needed.

### Refactor

- `emit_arrow_svg` signature tightened: `layout`, `shorten_src`,
  `shorten_dst`, `placed_labels`, `_debug_capture`,
  `primitive_obstacles`, `cell_metrics` are now keyword-only (the `*`
  marker moved from before `cell_metrics` to after
  `render_inline_tex`). All existing call sites already pass these by
  keyword, so no caller churn.
- `CellMetrics.grid_cols` / `grid_rows` / `origin_x` / `origin_y` marked
  with a `TODO(v0.15.0)` comment — unused by scoring + geometry hot
  paths; retained for the field-value regression suite and potential
  debug tooling.

### Tests

- New `tests/unit/test_cell_metrics_regression.py` (6 cases): pins
  `CellMetrics` field values passed into `emit_annotation_arrows` for
  Array, DPTable-1D, DPTable-2D, Queue, Graph, Tree. Catches silent
  drift in `cell_width` / `cell_height` / `grid_cols` / `grid_rows` /
  `origin_x` / `origin_y` without a full golden SVG regen.
- `test_scoring_unit.py` gained `TestP7FlowGatedAnnotationArrowScale`
  (2 cases) pinning the `flow=None` baseline and the `flow=set` × 0.75
  scale on `annotation_arrow`-kind obstacles only.
- `test_flow_direction.py` updated: `_cp` helper now takes
  `cell_metrics=` instead of `flow=` (D/1 signature change).
- `3189 pass` across the filtered suite (1 pre-existing starlark fail
  unrelated).

## [0.13.0] - 2026-04-22 — Phase C: flow-aware 2D stagger + CellMetrics/FlowDirection

### Added

- `CellMetrics` NamedTuple exposing `(cell_width, cell_height, grid_cols,
  grid_rows, origin_x, origin_y)`. Carries grid context from call sites into
  arrow geometry so curve direction can be made flow-aware.
- `FlowDirection` IntEnum with 8 sectors matching
  `round(atan2(dy, dx) / (π/4)) % 8` (RIGHTWARD=0, SE=1, DOWNWARD=2, SW=3,
  LEFTWARD=4, NW=5, UPWARD=6, NE=7). `IntEnum` so members behave as int
  sector indices in sets/dict keys.
- `classify_flow(dx, dy, cell_metrics=None)` helper. Degrades to pure
  `atan2` when `cell_metrics=None`; otherwise normalises by cell
  dimensions so non-square grids classify correctly.

### Changed (arrow geometry)

- `_compute_control_points` grew keyword-only `flow` and `cell_metrics`
  params (both default `None` → no behaviour change). When `flow is not
  None and layout == "2d"`, odd-indexed stacked arrows flip the
  perpendicular direction so dense 2D stacks distribute symmetrically
  instead of forming a one-sided fan.
- `emit_arrow_svg` and `PrimitiveBase.emit_annotation_arrows` accept a
  keyword-only `cell_metrics`. `emit_arrow_svg` classifies flow
  internally via `classify_flow(dx, dy, cell_metrics)` after
  shortening and forwards `flow + cell_metrics` into
  `_compute_control_points`.
- Array, DPTable, and Queue now construct a `CellMetrics` record from
  their known cell dimensions and pass it into the emit pipeline.
  Behaviour is preserved because all three use
  `layout="horizontal"` — the 2D stagger-flip gate never triggers
  for 1D stacks.
- Graph, Tree, Plane2D, Grid, HashMap, LinkedList, VariableWatch, and
  NumberLine stay on `cell_metrics=None`. They can opt in later if
  flow-aware stacking becomes relevant.

### Tests

- New `tests/unit/test_flow_direction.py` (22 cases): 8-sector mapping,
  zero-vector degenerate, tiny-nonzero stability, non-square
  `CellMetrics` normalisation, and NamedTuple value-type semantics.
- `3175 pass` across the full suite — same as the v0.12.2 baseline.

## [0.12.2] - 2026-04-22 — Phase B: perfect-arrows bow+stretch port

### Changed (arrow geometry)

- Ported the [steveruizok/perfect-arrows](https://github.com/steveruizok/perfect-arrows)
  bow+stretch arc-amplitude formula into `_compute_control_points`. The curve
  amplitude is now `arc = bow + modulate(euclid, [stretch_min, stretch_max], [1, 0]) * stretch`
  with defaults tuned for scriba's DP-grid use (`bow=0.05`, `stretch=0.20`,
  `stretch_max=1000.0` — raised from perfect-arrows' 420 default so long
  cross-grid arrows still bow visibly).
- Cubic control points are now derived from a single quadratic control
  point via the standard quad→cubic identity
  (`cp1 = P0 + 2/3*(Q − P0); cp2 = P1 + 2/3*(Q − P1)`). This replaces the
  ad-hoc plateau-cubic (cp1/cp2 sharing the peak y) with a more elliptical
  curve shape.
- Added `_modulate(value, from_range, to_range, clamp=True)` helper.
- Added perpendicular-offset floor (`cell_height * 0.5`) so short arrows
  stay visibly bowed even when `arc * euclid` is tiny.
- `arrow_height_above` (Manhattan-based headroom estimator) is deliberately
  **not** touched — it remains a conservative upper bound.

### Goldens

- Regenerated `tests/golden/smart_label/bug-B/expected.svg` (+ sha256).
  The self-loop arrow's control points shift from `(28, 60)` → `(32, 66)`:
  6 px flatter, label position unchanged. `ok-simple` and
  `critical-2-null-byte` goldens are unchanged.

### Version

- `scriba.__version__`: 0.12.0 → 0.12.2 (0.12.1 was an internal refactor tag).

## [0.12.0] - 2026-04-22 — Smart-label v2.1.0-rc (W3-α segment obstacles)

### Breaking

- Plane2D annotation pill positions may shift when plotted lines or axis spines are
  present in a frame. `emit_annotation_arrows` now passes `primitive_obstacles` (segment
  kind) to `emit_plain_arrow_svg` / `emit_arrow_svg`, changing scores for any Plane2D
  annotation that sits near a visible line. No golden fixtures existed for annotated
  Plane2D scenes at v0.12.0 so no corpus re-pin is needed for this wave; W3-β will
  ship with a corpus expansion covering plane2d/graph/tree/numberline.

### Added

- **R-31 (W3-α)**: `Plane2D.resolve_obstacle_segments()` returns plotted lines
  (`p.line[*]`) and axis spines in SVG pixel coordinates. Segments tagged
  `state="current"` carry `severity="MUST"` (hard-block); `state="dim"` / `"done"`
  carry `severity="SHOULD"` (`_W_EDGE_OCCLUSION = 8.0` penalty term P7).
- `_segment_to_obstacle()` converter in `_svg_helpers.py` maps `ObstacleSegment` fields
  to `_Obstacle(kind="segment", ...)` for the P7 edge-occlusion scoring term.
- `primitive_obstacles: tuple[_Obstacle, ...] | None` parameter added to
  `emit_plain_arrow_svg` and `emit_arrow_svg` (default `None` — no change for all
  non-Plane2D primitives).
- `PrimitiveBase.emit_annotation_arrows` collects `self.resolve_obstacle_segments()`
  once per frame and passes the converted tuple to both emitters.

### Ruleset

- `docs/spec/smart-label-ruleset.md` bumped to v2.1.0-rc: R-31 rule card added,
  conformance matrix §8 updated (R-31 Status=Shipped, v0.12.0 column).
  `scripts/check_ruleset_sync.py` now expects 31 rules.
- `docs/archive/smart-label-edge-avoidance-2026-04-22/R-31-plan.md` archived
  (edge-avoidance design doc; triaged from convex_hull_trick step 5 user report).

### Added (R-31 ext — prior-annotation arrow-stroke obstacles)

- **Arrow-stroke obstacles (R-31 ext)**: prior-annotation arcs now contribute to
  pill scoring. When a primitive has multiple `\annotate` calls, each annotation's
  arrow geometry is sampled into `ObstacleSegment` instances (`kind="annotation_arrow"`,
  `severity="SHOULD"`) and passed as `primitive_obstacles` to all subsequent pill
  placement calls. This prevents later pills from landing on earlier arc strokes
  (observed failures: `convex_hull_trick.html` step 5, `frog.html` step 5).
- `_sample_arrow_segments()` in `_svg_helpers.py`: samples a cubic Bézier arc into
  N=8 evenly-spaced t-values (7 chord segments) or a straight stem into 1 segment.
  Closed-form, D-1 deterministic. Kind `"annotation_arrow"`, severity always `"SHOULD"`.
- `emit_arrow_svg` / `emit_plain_arrow_svg` return type changed `None → list[ObstacleSegment]`
  (backward-compatible: call sites that ignore the return value are unaffected).
- `PrimitiveBase.emit_annotation_arrows` accumulates `prior_arrow_segments` across the
  annotation loop and merges them into `primitive_obstacles` for each subsequent call.
- `ObstacleSegment.kind` Literal extended with `"annotation_arrow"`.
- `_score_candidate`: `"annotation_arrow"` added to `_SEGMENT_KINDS` set so it
  participates in P7 edge-occlusion scoring and is excluded from P1 AABB area and
  `boundary_clearance` AABB loop.

### Changed (scoring tuning)

- `_W_DISPLACE` raised `1.0 → 2.0` (prefer tighter-to-natural candidates post-nudge-48).
- P1 touch sentinel raised `1.0 → 3.0` so touching cost (~30 pt) exceeds P2 displacement
  cost for a 0.5×pill_h gap (~19 pt) after `_W_DISPLACE` bump.
- `_DEGRADED_SCORE_THRESHOLD` raised `50.0 → 200.0` (farthest clear nudge can score
  ~135.6 pt after `_W_DISPLACE` bump; 200.0 keeps gap below fully-blocked ≥10 000 pt).
- **P7**: `_W_EDGE_OCCLUSION` weight `8.0 → 40.0`, saturated against pill short-side so
  arrows grazing long pills don't dominate scoring.
- `_nudge_candidates`: expanded 4 steps (32 candidates) → 6 steps (48 candidates) to
  clear dense annotation stacks (`convex_hull_trick` step 5).

### Fixed

- `emit_arrow_svg`: curve-height math replaced Manhattan with Euclidean distance
  (fixes diagonal over-puff ~√2×); cap now scales with distance (`max(1.2×cell_h,
  0.18×euclid)`) so long cross-grid arrows curve visibly instead of rendering
  near-straight; stagger for same-target arrows capped at `min(arrow_index, 4)` so
  dense stacks don't fly off-canvas.
- **R-27b**: leader line now also emits when displacement ≥ `pill_h` for any color
  (bypasses the `_LEADER_DISPLACEMENT_THRESHOLD=20` floor so mid-sized pills with
  `pill_h~19` still trigger), in addition to the prior `warn`/`error`-only gate.
- CSS: `array` / `grid` / `dptable` cell `rect` rules switched from child combinator
  (`>`) to descendant combinator for the outer step, so an intermediate
  `<g transform="translate(...)">` wrapper (added by `ArrayPrimitive` when annotations
  need headroom) no longer breaks the cell `rx` / `stroke-linecap` / `stroke-linejoin`
  chain. Same class as Bug C/D in the Wave 8 audit — text rule was fixed, this one
  was missed, causing annotated rows to render as sharp-cornered rects.

### Version

- `scriba/_version.py`: `__version__` bumped `0.9.1 → 0.12.0`. `SCRIBA_VERSION`
  unchanged at `3` (no core-API break; annotation scoring output bytes change, which
  is captured by the golden re-pins in v0.11.0/v0.12.0 W3-α, not by `SCRIBA_VERSION`).

## [0.11.0] - 2026-04-22 — Smart-label v2.0.0 (W3 batch)

### Breaking

- Smart-label golden corpus re-pinned (byte-breaking placement changes for all arc-annotated
  scenes due to R-01 natural position fix, R-08 perimeter endpoint, R-22 side_hint
  auto-inference, R-27 leader gating).
- `aria-label` on annotation `<g>` is now in speech form, not raw LaTeX — raw LaTeX moved
  to `aria-description`. AT consumers reading `aria-label` verbatim will see transliterated
  math instead of LaTeX tokens.

### Added

- **R-07**: `_LEADER_DISPLACEMENT_THRESHOLD` scale-relative constant extracted from
  hard-coded 30 px; formula `max(pill_h, 20)` used in `emit_arrow_svg`.
- **R-11**: `aria-description` attribute on annotation `<g>` preserves raw LaTeX for AT
  consumers (e.g. NVDA math mode). `aria-label` now holds speech-friendly form.
- **R-13**: `stroke-dasharray="3,2"` on `warn` arrow `<path>` unconditionally; `"1,3"` on
  `muted` arrow `<path>` unconditionally. Both mirrored to pill border `<rect>`.
- **R-14**: `aria-roledescription="annotation"` on all annotation `<g>` elements in
  `emit_arrow_svg`, `emit_plain_arrow_svg`, and `emit_position_label_svg`.
- **R-15**: `<title>` as first child of each `<svg>` root in `_frame_renderer.py`.
- **R-16**: Step-1 `aria-live` narration region pre-populated in static HTML output.
- **R-19**: `scriba:label-placement-degraded` emitted to stderr unconditionally (was gated
  behind `SCRIBA_DEBUG_LABELS`). Error code E1248 documented.
- **R-22**: `side_hint` auto-inferred from arrow direction vector in `emit_arrow_svg` when
  no explicit `side=` / `position=` key is present.

### Changed

- **R-01**: Arc-label natural position formula corrected to
  `label_ref_y = mid_y_val − pill_h // 2 − 4` (arc clearance gap). Previous formula
  `mid_y_val − 4` caused pill overlap with arc stroke for pills taller than 4 px.
- **R-08**: Leader line endpoint now terminates at pill perimeter (AABB intersection),
  not pill center `(fi_x, fi_y)`. Removes "same-side confusion" visual artefact.
- **R-12**: `info` opacity 0.45 → 0.7 (measured 3.07:1 non-text contrast); `muted` opacity
  0.30 → 0.7 (measured 3.24:1). Both now satisfy WCAG 2.2 SC 1.4.11.
- **R-25**: Dark-mode `--scriba-annotation-path` → `#a78bfa` (violet, ~9:1 on dark bg),
  distinct from `--scriba-annotation-info` (`#70b8ff`). Pre-fix both were `#0b68cb`.
- **R-27**: Leader lines (`<circle>` origin dot + `<polyline>`) emitted only for
  `color in ("warn", "error")`. Leaders removed from displaced `good`/`info`/`muted`/`path`.

### Ruleset

- `docs/spec/smart-label-ruleset.md` final v2.0.0: 30 unified rules (R-01..R-30) replacing
  legacy axis IDs (A-*/B-*/C-*/D-*/G-*). Alias table in Appendix A. (Superseded by
  v2.1.0-rc in v0.12.0 which adds R-31.)
- Code-ref and test-ref fields updated for all 13 landed rules.

## [0.9.1] - 2026-04-18

### Fixed

- 5 example `.tex` files passed unsupported `row_labels=` /
  `col_labels=` to `DPTable` (rejected by `ACCEPTED_PARAMS`),
  causing E1114 build failures: `algorithms/dp/dp_optimization`,
  `algorithms/dp/interval_dp`, `cses/houses_schools`,
  `editorials/knapsack_editorial`, `integration/test_reference_dptable`.
- `examples/build.sh`: switched to process-substitution + redirect
  stdin from `/dev/null` so renders cannot accidentally read piped
  input.
- `Graph.ACCEPTED_PARAMS`: added `layout_lambda` (real param
  consumed by `graph_layout_stable.py` but missing from the
  frozenset).
- 4 broken xrefs in `docs/guides/tex-plugin.md` and 7 broken
  internal links across `docs/README.md`, `pyproject.toml`,
  `docs/guides/tex-plugin.md`.

### Changed

- **PyPI distribution name** is now `scriba-tex`
  (`pip install scriba-tex`); the `scriba` name is taken on PyPI
  by an unrelated project. Python import name remains `scriba`.
- `pyproject.toml` `[project.urls]`: `ojcloud/scriba` →
  `Danchuong/scriba` (canonical org per git remote). Same
  rewrite in `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`
  diff URLs, `scriba/tex/renderer.py` error message.
- `docs/operations/packaging.md`: full rewrite — `__version__`
  bumped from stale `0.1.1-alpha` to `0.9.0`, removed all
  `scriba.diagram` references (module deleted), added
  `scriba.animation` as core subpackage, synced dev deps
  (`hypothesis`, `pytest-cov`), bumped `pygments` upper bound.

### Documentation

- 5 CRITICAL audit fixes (Wave 1):
  - `SECURITY.md`: rewrite Supported Versions table
    (0.5.x → 0.9.x current + 0.8.x previous).
  - `docs/tutorial/getting-started.md`: `--debug` CLI flag
    does not exist; replaced with `SCRIBA_DEBUG=1` env-var.
  - `docs/cookbook/10-substory-shared-private`: `vars=`
    rejected by `VariableWatch.ACCEPTED_PARAMS`; replaced
    with `names=`.
- 24 HIGH audit fixes (Wave 2):
  - Stripped 5 cookbook entries (03/05/06/07/08) of legacy
    `@compute` / `apply_tag` / `scene{}` / D2-prose / `match
    event.type` syntax — replaced with current scriba grammar.
  - `docs/primitives/{matrix,metricplot,stack}.md`: stripped
    undefined params and dead selectors.
  - `docs/rfc/001-tree-graph-mutation.md`: marked "design
    only; not yet implemented".
  - `docs/spec/environments.md`: removed E1150 collision
    references (E1150 belongs to Starlark parse error).
  - `README.md`: v0.8.3 → v0.9.0 + new "What's new" block.
  - `CONTRIBUTING.md`: fixed ghost paths
    (`packages/scriba/scripts` → `scripts`), stripped
    `docs/scriba/`, replaced stale v0.2.0 roadmap.
  - `CHANGELOG.md`: Keep-a-Changelog compliance (Unreleased
    header, dash style consistency).
- Added 6 standard OSS files: `CODE_OF_CONDUCT.md` (Contributor
  Covenant v2.1 adoption stub), `.github/PULL_REQUEST_TEMPLATE.md`,
  `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`,
  `.github/CODEOWNERS`, `.editorconfig`.
- Added cross-cutting docs audit at
  `docs/archive/docs-audit-2026-04-18/` (6 per-bucket reports +
  aggregated summary + README index).
- Rewrote `examples/tutorial_en.tex` with a labyrinth/BFS
  problem (multi-source BFS + Safety Frontier Invariant).

### Deprecated

- `ArrayInstance`, `DPTableInstance`, `GridInstance`, `MatrixInstance`,
  `NumberLineInstance` aliases — use `*Primitive` names. Will be removed in v1.0.
- `HeatmapPrimitive` alias — use `MatrixPrimitive`. Will be removed in v1.0.
- `load_css` and `inline_katex_css` removed from `scriba.core.css_bundler.__all__`.
  Still importable but considered internal. A higher-level `render_to_html()`
  helper is planned for the public bundling surface.

## [0.9.0] - 2026-04-18

### Breaking

- `data-primitive=` SVG attribute and `primitive_type` for `HashMap` and
  `VariableWatch` are now lowercase (`"hashmap"`, `"variablewatch"`) to
  match the convention used by the other 13 primitives. Any CSS or JS
  selectors targeting `[data-primitive="HashMap"]` or
  `[data-primitive="VariableWatch"]` must be updated.

### Removed

- **BREAKING: `StarlarkHost.eval_raw` removed** — the deprecated `eval_raw` method has been deleted. Use `\compute{...}` blocks instead. Wire-level requests with `op="eval_raw"` now return a structured error `E1156` with a migration hint.
- **BREAKING: inline-runtime is no longer the default** — as forecast in the v0.8.3 entry, inline mode no longer defaults to `True`. Pass `--inline-runtime` (CLI) or `render_inline_runtime=True` (API) to restore the previous behavior.
  > **Correction (2026-07-03):** this flip never actually landed — `inline_runtime` defaults to `True` at every layer to this day (`render.py`, `renderer.py`, `_html_stitcher.py`). Inline, `file://`-safe output remains the default; use `--no-inline-runtime` for the CSP/external-asset mode.

### Refactored

#### Wave A — Public API hygiene
- `ContextProvider` exported from `scriba.core` public `__init__`.
- `scriba/animation/tree.py` gained an explicit `__all__`.
- Internal helpers in `tree.py` and several primitives renamed with `_` prefix to clarify private vs public surface.

#### Wave B — Cross-layer decoupling
- New `scriba/core/warnings.py` centralises warning emission; callers no longer import from animation internals.
- New `scriba/core/text_utils.py` extracts shared text-processing helpers.
- `scriba/animation/errors.py` and `scriba/tex/parser/text_commands.py` reduced to thin shims delegating to the core modules.

#### Wave C1 — `base.py` split
- `scriba/animation/primitives/base.py` reduced from 1436 to ~333 lines; logic extracted into `_types.py`, `_text_render.py`, and `_svg_helpers.py`.

#### Wave C2 — Arrow consolidation
- `PrimitiveBase.emit_annotation_arrows` unified arrow emission; plain-pointer gap closed for `Grid`, `HashMap`, `VariableWatch`, and `LinkedList`.
- `_min_arrow_above` floor now respected across 9 primitives.

#### Wave C3 — `label` normalisation
- `self.label_text` renamed to `self.label` across 6 primitives (`Array`, `Stack`, `Queue`, `Tree`, `Graph`, `Plane2D`); emitter nesting flattened accordingly. *(See Breaking entry above.)*

#### Wave D2 — Emitter and tree split
- `emitter.py` split into `_minify.py` (HTML/CSS minifier) and `_script_builder.py` (JS bundle assembly).
- `tree.py` layout logic extracted into `tree_layout.py`.

#### Wave D3 — Type aliases and constants hygiene
- `JsonValue` type alias introduced in `scriba/core/types.py`; used throughout IPC layer.
- `__all__` added to `differ.py`, `css_bundler.py`, and `constants.py`.
- Named float and font constants added to `_types.py` (replaces inline magic values).
- `pyproject.toml` migrated to Hatchling dynamic version from `scriba/__init__.py`.

#### Wave D5 — Thread-local budget state
- `_cumulative_elapsed` in `starlark_worker.py` converted from a module-level float to `threading.local()`; `reset_cumulative_budget`, `get_cumulative_elapsed`, and `consume_cumulative_budget` updated to use per-thread accessors.

## [0.8.3] - 2026-04-18

Wave 8 landing release. All changes are additive or bug fixes; fully
backward compatible with 0.8.2 consumers. `SCRIBA_VERSION` unchanged.

### CSP / Runtime (Wave 8 — M12/M13)

- **External JS runtime** — the animation runtime is now shipped as a
  standalone `scriba.<sha384[:8]>.js` asset alongside the rendered HTML.
  The new CLI flags (`--inline-runtime` / `--no-inline-runtime`,
  `--asset-base-url`, `--copy-runtime` / `--no-copy-runtime`) select
  between three deployment modes: inline (default, `file://`-safe),
  external-copy (serve alongside HTML, enables `script-src 'self'` CSP),
  and CDN (supply your own host via `--asset-base-url`).
  `scriba.animation.runtime_asset` exports `RUNTIME_JS_BYTES`,
  `RUNTIME_JS_FILENAME`, and `RUNTIME_JS_SHA384` for programmatic use.
  Inline mode is deprecated and will no longer be the default in v0.9.0.

### CLI

- **New flags** — `--lang` (BCP 47 tag for the HTML `lang=` attribute,
  default `en`), `--inline-runtime`, `--no-inline-runtime`,
  `--asset-base-url`, `--copy-runtime`, `--no-copy-runtime` (see CSP
  deployment guide at `docs/csp-deployment.md` for usage).
- **`--debug` / `SCRIBA_DEBUG=1`** — enable verbose output including full
  stack traces and intermediate render state. Intended for authoring
  diagnostics; not recommended for production CI pipelines.
- **`ScribaError` handler** — unhandled `ScribaError` exceptions in the
  CLI now print a clean one-line diagnostic without a Python traceback.
  Pass `--debug` to restore full traceback output.
- **Input extension check** — `render.py` now rejects input files that
  do not end in `.tex` with a clear error message (F-03).
- **Overwrite guard for `-o input.tex`** — the CLI refuses to overwrite
  the input file when `-o` is set to the same path as the input.
- **E1116 promoted to error** — undeclared shape references that
  previously surfaced as warnings are now raised as errors at render
  time, matching the severity documented in `docs/spec/error-codes.md`.

### Animation

- **Corrected easing curves** — `element_add` and `element_remove`
  transitions now use `ease-out` (was `linear`); `annotation_add` uses
  `ease-in-out` (was `ease-out`). Matches the authored intent from the
  Wave 6.3 transition manifest spec.
- **Named timing constants** — animation durations previously scattered
  as magic numbers are now centralised as named constants in the emitter
  (`TRANSITION_MS_FAST`, `TRANSITION_MS_NORMAL`, `TRANSITION_MS_SLOW`).

### Accessibility

- **KaTeX MathML output** — the KaTeX worker now emits both HTML and
  MathML (`output: "htmlAndMathml"`). Screen readers receive structured
  math rather than falling back to the raw LaTeX source.
- **Live-region cleanup** — the narration `aria-live="polite"` region is
  now cleared on frame reset so stale text is not re-announced when the
  animation loops or rewinds.
- **Substory narration** — substory steps now update the parent
  animation's narration region, ensuring sub-step text is announced by
  assistive technology.
- **Filmstrip `aria-label` fallback** — the static filmstrip `<figure>`
  element now falls back to `aria-label="Animation"` when no `label=`
  option is supplied, eliminating unlabelled landmark warnings.

### SVG

- **Plane2D grid `vector-effect`** — grid lines in `Plane2D` primitives
  now carry `vector-effect="non-scaling-stroke"` so grid line width
  stays visually consistent across zoom levels.
- **Print-mode dark mode fix** — the `@media print` CSS block now
  correctly resets `color-scheme` to `light` when dark mode is active,
  preventing black-on-black printed output.

### Performance

- **~7–8% render speedup** on large and medium fixtures (benchmark:
  `examples/algorithms/graph/dijkstra.tex`, `h15_kmp_matching.tex`)
  via reduced per-frame SVG allocation and deferred selector expansion.
- **−100 ms cold start** — KaTeX worker startup deferred until the first
  math expression is encountered; documents with no math pay no startup
  cost.
- **−360 KB output** on math-free renders — KaTeX font data no longer
  inlined when no `$...$` or `\[...\]` expressions are present.

### Hygiene (Wave 8 audit — P1/P3)

- **`errors.py` `__all__`** — public exception classes and `ERROR_CATALOG`
  now listed explicitly; internal helpers (`_animation_error`,
  `_suggest_closest`, `_format_compute_traceback`) prefixed with `_` and
  excluded (Round D). Audit finding P1 F-01.
- **Underscore-prefix internals** — module-private helpers across
  `emitter.py`, `scene.py`, and `starlark_worker.py` renamed with a
  leading underscore to make the public surface explicit (Round D).
- **`SubprocessWorker` deprecation message** — the `DeprecationWarning`
  now includes the replacement name (`PersistentSubprocessWorker`) and
  the version it will be removed in (`v1.0.0`). Audit finding P3 H1.
- **Extension API stability** — `DiagramRenderer` added to
  `scriba.animation.__all__` (Round A); import path documented in
  `docs/spec/environments.md`. Audit finding P1 F-05.

## [0.8.2] - 2026-04-15

### Fixed

- **Position-aware auto-ID generation** — `_scene_id()` and `scene_id_from_source()` now hash `"{position}:{raw}"` instead of just the raw content. Two animation/diagram blocks with identical content at different document positions now produce distinct HTML element IDs, fixing a bug where animation 2's JavaScript bound to animation 1's DOM element.
- **CSS selector tests updated** — Stability tests for primitive CSS centering now match the `:where()`-wrapped selectors introduced in v0.8.0.

### Added

- **Duplicate block ID warning** — The pipeline (step 4b) now scans rendered artifacts for duplicate `block_id` values and emits a `CollectedWarning(code="E1019", severity="dangerous")` on `Document.warnings`. This catches both explicit `[id="..."]` reuse and auto-generated hash collisions.

## [0.8.0] - 2026-04-14

### Fixed

- **State styling CSS specificity regression** — Cell/node/edge state colors (`current`, `error`, `good`, `highlight`, etc.) were silently overridden by primitive base selectors due to a CSS specificity conflict. Primitive base selectors now use `:where()` to zero their qualifying specificity, so `.scriba-state-*` rules always win. Browser support: Chrome 88+, Firefox 78+, Safari 14+.

### Changed

- **CSS deduplication via `css_bundler`** — The ~470-line inline CSS block in `render.py` was replaced by `scriba.core.css_bundler`, reading CSS from source `.css` files at render time.
- **Widget chrome extracted** — `scriba-standalone.css` split into `scriba-embed.css` for widget chrome CSS.

<!-- Note: versions 0.7.x and 0.8.1 were internal development snapshots
     that were never published to PyPI. They are omitted from this changelog. -->

## [0.6.3] - 2026-04-13

### Added

- **Arrow draw-on animation** — Annotation arrows now draw progressively via `stroke-dashoffset` rAF animation (120ms, ease-out) instead of fading in. Arrowhead polygon computed via `getPointAtLength()` appears at 70% progress. Falls back to 180ms opacity fade when `<path>` is absent or `getTotalLength` unavailable.
- **Value change pulse** — `value_change` transitions now fire a non-blocking WAAPI scale pulse (`1 → 1.15 → 1`, 100ms) on the `<text>` element, drawing the eye to which cell changed.
- **2-phase micro-sequencing** — Transitions are split into two phases: annotations and highlights fire at t=0, everything else fires at t=50ms. Arrows are visible before the state changes they explain. Total worst-case per step: ~230ms.

### Fixed

- **Annotation position flash on draw-on** — Annotation clones inserted during `annotation_add` animation now preserve intermediate SVG `transform` groups between the annotation and its `data-shape` ancestor. Previously, clones were appended directly to the `data-shape` container, skipping transforms like `translate(0, 44)` and causing a visible position jump until the full-frame sync corrected it.

## [0.6.2] - 2026-04-12

### Fixed

- **Animation transition integration tests** — Updated stale golden files and regex patterns in `tests/integration/test_animation_transitions.py` to match current emitter output. All 12 test cases pass.
- **Documentation accuracy** — Corrected `docs/guides/animation-plugin.md` non-goals section which falsely claimed transitions were not produced and no runtime JavaScript existed. The frame-to-frame differ (`scriba/animation/differ.py`) and JS animation runtime (`emitter.py`) are fully operational: the differ computes transition manifests (`recolor`, `value_change`, `element_add`, `element_remove`, `highlight_on`, `highlight_off`), serialized as `tr:` fields in the JS frames array, with WAAPI animations and `prefers-reduced-motion` support.

## [0.6.1] - 2026-04-11

### Fixed

- **Double-pass rendering bug eliminated** (`9f433db`) — `emit_interactive_html` now renders each frame's SVG in a single pass. Previously, the two-loop design (JS + print) caused structural mutations (`add_edge`/`remove_edge`/`add_node`/`remove_node`/`reparent`) to accumulate across passes, breaking every animation using mutation ops. The hidden-state pre-declaration pattern is no longer required (but remains a valid best practice).
- **Graph node collision resolution** (`1ecca61`) — Added `_resolve_overlaps()` post-pass to `fruchterman_reingold()` and `compute_stable_layout()`, guaranteeing minimum node separation of `2 * radius + 12px`. No two graph nodes overlap visually.
- **Narration math escape** (`4c386ee`) — `$\min_{j<1}$` inside `\narrate{...}` no longer fails. `process_hl_macros` now defers plain-text escaping when a TeX renderer is available, so `<` reaches KaTeX intact.
- **KaTeX support in 8 text sites** (`c12b2fb`, `06b0362`, `7b319f1`) — All user-authored text now supports `$...$` inline math via KaTeX. Previously literal in: annotation labels, Plane2D point labels, Plane2D line labels, MetricPlot xlabel, MetricPlot ylabel, MetricPlot ylabel_right, MetricPlot legend series names, Graph edge weights, and CodePanel caption labels.

## [0.6.0] - 2026-04-11 (Completeness audit — Phase 4 GA)

General-availability release graduating 0.6.0-alpha1. Landing **Wave 7** —
the cookbook truth pass and widget accessibility polish — plus a late-wave
Graph `hidden`-state regression fix. Six parallel worktree agents shipped
**10 new cookbook examples** (+2 rewrites) and fixed a widget a11y gap.

**`SCRIBA_VERSION` stays at 3** (bumped 2→3 in 0.6.0-alpha1). The Wave 7
deliverables are all additive or bug-fixes; no core contract change from
the alpha.

### Cookbook truth pass (Wave 7.0 — lying examples fixed)

- **`h07_splay_amortized.tex`** — rewritten to use real `\apply{T}{reparent=...}`
  structural rotations. Five reparent calls perform a textbook zig-zig that
  lifts a depth-3 node to depth 1. Verification: 6 distinct edge-coordinate
  layouts across 7 frames (vs 1 layout in the 0.5.x version — that was the
  "lie" flagged by Agent 7). Phi amortized-cost curve drops 8.5 → 5.8
  across the rotation sequence, now visibly attached to actual structural
  change rather than recolor-only.
- **`h08_persistent_segtree.tex`** — rewritten to use `\apply{T.node["X"]}{value=...}`
  for sum cascade updates. Two cascaded updates (`a[3]+=10`, `a[5]+=5`)
  model persistent versions V0/V1/V2. Root `[0,7]` deliberately on both
  update paths: its value visibly changes 36→46→51 across frames. 15
  distinct tree-node targets and 12 distinct label values rendered per
  frame, resolving Agent 7 F1 CRITICAL.

### Cookbook canonical examples (Wave 7.1-7.4 — 8 new files)

- **`h11_dijkstra_weighted.tex`** (W7.1) — 148 lines, 16 steps. Weighted
  Graph with `show_weights=true`, parallel distance Array, full relaxation
  trace showing `dist[B]` going 4→3 and `dist[D]` going 10→8. 288 weight
  label occurrences in rendered HTML.
- **`h12_kruskal_mst.tex`** (W7.1) — 108 lines, 11 steps. Same 6-node
  graph as h11 for direct algorithm comparison. 5 accepted + 4 rejected
  edges, visible accept/reject narrative via `state=good` vs `state=dim`.
- **`h13_bst_insert_delete.tex`** (W7.2) — 122 lines, 15 steps. BST insert
  (6 calls growing 1→7 nodes) + delete (leaf, then root via rename-and-
  remove using value layer override). Tree node counts visibly shrink:
  `7→7→7→6→6→6→6→5→5`.
- **`h14_bfs_tree.tex`** (W7.2) — 129 lines, 16 steps. Parallel
  graph/tree/queue/visited-array showing BFS construction on 6 nodes.
- **`h15_kmp_matching.tex`** (W7.3) — 186 lines, 22 steps. Full
  failure-function build on `ABABCABAB` (yields `F=[0,0,1,2,0,1,2,3,4]`)
  plus matching trace on `ABABDABACDABABCABAB` with 2 mismatch/fallback
  cycles before the final match at `T[10..18]`.
- **`h16_binary_search.tex`** (W7.3) — 85 lines, 11 steps. Happy path
  (target=7 found first iteration) + not-found path (target=8, 3
  narrowing iterations). Includes cookbook notes on the
  `lo + (hi-lo)/2` overflow bug and `lower_bound`/`upper_bound` variants.
- **`h17_union_find.tex`** (W7.4) — 129 lines, 18 steps. Truthful DSU
  with real path compression: 9 `add_edge` calls + 2 `remove_edge`
  rewrites (`find(D)` rewrites `D→C→A` to `D→A`; `find(F)` rewrites
  `F→E→A` to `F→A`). Parent array visibly tracks union state.
- **`h18_linkedlist_reverse.tex`** (W7.4) — 89 lines, 12 steps.
  Three-pointer (prev/curr/next) cursor trace showing in-place reversal.

Cookbook matrix coverage rose from ~42% (Agent 12 estimate) to ~70%
including all weighted-graph algorithms.

### Widget accessibility + late-wave fixes (Wave 7.5 + GA)

- **Widget `aria-label`** — `<button class="scriba-btn-prev">` now carries
  `aria-label="Previous step"` and `<button class="scriba-btn-next">` carries
  `aria-label="Next step"`. Substory widgets carry "Previous sub-step" /
  "Next sub-step" variants. Visible button text is unchanged.
- **`prefers-reduced-motion`** — `scriba-scene-primitives.css` extends its
  existing reduced-motion block with explicit widget button selectors
  (`.scriba-btn-prev`, `.scriba-btn-next`, `.scriba-dot`) set to
  `transition: none !important`. Defense-in-depth against future regressions.
- **Graph `hidden` state** (GA commit) — `Graph.emit_svg` now honors the
  `hidden` state with early-return in both the edge and node loops.
  Edges incident on a hidden node are also skipped (matches Tree.emit_svg
  behavior). Fixes a gap flagged by W7.1 Dijkstra — before the fix, `hidden`
  fell through to idle styling in Graph. 4 new regression tests in
  `tests/unit/test_graph_mutation.py::TestHiddenState`.

### Wave 7.5 correction

The FIX_PLAN Phase 4 Truth pass 3 described `scriba/animation/static/*.css`
as "orphan" files and asked W7.5 to merge them into an inline `render.py`
template. W7.5 correctly identified that those files are NOT orphans —
they ship through `AnimationRenderer.assets()` at `renderer.py:347-363`
as separate asset files per the pipeline's namespacing contract. No
inline CSS template exists. The agent left the static files intact,
extended the existing reduced-motion block, and added `aria-label` in
`emitter.py` instead. Spec bug reported; no Python source change needed.

### Tests

- **+14 new tests** across 3 new or extended files vs 0.6.0-alpha1:
  - `tests/unit/test_widget_a11y.py` (9, Wave 7.5)
  - `tests/unit/test_graph_mutation.py::TestHiddenState` (4, GA fix)
  - `tests/unit/test_graph_layout_stable.py` was extended by Wave 6.2; no
    Wave 7 additions (it just works).
- **Full suite: 2009 passed, 1 skipped, 0 failed.** (0.6.0-alpha1 was
  1996 passed.)

### Known follow-ups (not blocking v0.6.0 GA)

- **`emit_interactive_html` double-pass state bug** — W7.0, W7.2, and W7.4
  independently noted that `_emit_frame_svg` is called twice per frame
  (once for the interactive JS payload, once for the print-mode filmstrip)
  without snapshotting/restoring primitive state between passes. The
  interactive widget (the user-visible playback) is correct; the
  print-filmstrip shows the final mutated state for every frame and would
  re-raise `E1433`/`E1436`/`E1471`/`E1437` on structural mutations under
  certain rendering paths. Tracked for v0.6.1 (single-agent fix, ~50 LoC
  in `emitter.py`). Workaround: cookbook authors can use the `state=hidden`
  pre-declaration pattern (as W7.2 did for BST / BFS).
- **Animation id dup check (W6.4)** — helper exists in `animation/uniqueness.py`
  but document-level wiring needs `renderer.py` edit. Tracked for v0.6.1.
- **Starlark cumulative budget (W6.4)** — reset/consume helpers ship,
  host-side wiring at `starlark_host.py` is 2 lines away. Tracked for v0.6.1.
- **CLI `--report=path.json`** — render-report serialization for CI
  consumers. Tracked for v0.6.1.
- **KaTeX `errorCallback` migration** — replaces the HTML regex scan.
  Tracked for v0.6.1.
- **Tree `\annotate` rendering** — Agent 7 F2 HIGH. v0.7 scope.
- **Cross-frame endpoint tween** — Agent 9 FR4. v0.7 scope.
- **Graph node mutation** — v0.7 scope; v0.6.0 ships edge mutation only.

## [0.6.0a1] - 2026-04-11 (Completeness audit — Phase 3 alpha)

Pre-release alpha landing **Wave 6** — the Phase 3 implementation wave from
the 14-agent completeness audit. Five parallel worktree agents (W6.1 Tree
mutation, W6.2 Graph mutation + warm-start layout, W6.3 strict mode
infrastructure, W6.4 uniqueness + red-team hardening, W6.5 Plane2D tombstone
remove ops) shipped **~3900 LoC** across 17 files, with **+247 new tests**
(1996 passing vs 1747 baseline). Two design RFCs locked the contract first:
`docs/rfc/001-tree-graph-mutation.md` and `docs/rfc/002-strict-mode.md`.

**`SCRIBA_VERSION` bumped 2 → 3** — first break since v0.1.1. The `Document`
dataclass gained a new `warnings: tuple[CollectedWarning, ...]` field, and
the Tree / Graph / Plane2D primitives gained structural mutation APIs.
Consumer caches keyed on SCRIBA_VERSION must invalidate.

### Primitive mutation APIs (RFC-001)

- **Tree.apply_command** — `\apply{T}{add_node={id,parent}}`,
  `\apply{T}{remove_node={id,cascade}}`, `\apply{T}{reparent={node,parent}}`.
  Reingold-Tilford recomputes on every mutation; iterative DFS avoids
  recursion. Cycle detection, cascade-on-remove, duplicate-id rejection.
  Error codes E1433, E1434, E1435, E1436. (primitives/tree.py)
- **Tree value layer** — `emit_svg` now honors `get_value(suffix)` as an
  override for `self.node_labels`, unlocking segtree sum updates and
  lazy-tag propagation visualizations. Resolves Agent 7 F1 CRITICAL.
- **Graph.apply_command** — `\apply{G}{add_edge={from,to,weight}}`,
  `\apply{G}{remove_edge={from,to}}`, `\apply{G}{set_weight={from,to,value}}`.
  Undirected edge orientation normalized in lookup. Error codes E1471,
  E1472, E1473, E1474. (primitives/graph.py)
- **Graph weighted edges** — 3-tuple edge syntax `edges=[("A","B",4)]`,
  `show_weights=true` flag renders midpoint weight text. Mixed
  weighted/unweighted raises E1474.
- **Graph warm-start layout** — `compute_stable_layout` gained an
  `initial_positions` kwarg. After mutation, the new layout seeds from
  the pre-mutation positions rather than re-rolling from RNG, keeping
  existing nodes approximately in place. Unlocks Dijkstra, Prim, Kruskal,
  Bellman-Ford visualizations. (primitives/graph_layout_stable.py)
- **Plane2D.apply_command** — 4 new tombstone-based remove ops:
  `remove_point`, `remove_line`, `remove_segment`, `remove_polygon`
  (plus bonus `remove_region`). Indices stay stable across removes
  (tombstone-in-place) so existing selectors don't break. Convex hull
  pop is now authentic rather than faked with `state=dim`. Error code
  E1437. (primitives/plane2d.py)
- **`hidden` state** — new first-class state in `VALID_STATES`. Element
  is skipped in emit_svg entirely (distinct from `dim` which still
  renders). Agent 9 F2 fix. Applied to Tree nodes/edges, Graph
  nodes/edges (implicit via state checks), Plane2D points/lines/
  segments/polygons/regions/labels.

### Strict mode and render report (RFC-002)

- **`CollectedWarning` dataclass** exported from `scriba.core.artifact` —
  `(code, message, source_line, source_col, primitive, severity)` frozen
  record for structured warning surfacing.
- **`Document.warnings: tuple[CollectedWarning, ...]`** — new field on
  the return type. Always populated by the pipeline, regardless of
  `strict` setting. Empty tuple when no warnings.
- **`RenderContext.strict`, `strict_except`, `warnings_collector`** —
  new fields. `strict=False` default preserves 0.5.x behavior; opting in
  promotes DANGEROUS-class warnings to raised exceptions.
- **9 silent-fix sites promoted** (Agent 4 inventory):
  - **Promoted strict** (raise under `strict=True`): SF-1 polygon
    auto-close E1462 **(with correctness fix — pts[0] now explicitly
    appended to internal list)**, SF-3 degenerate line E1461, SF-4
    log-scale clamp E1484, SF-6 stable-layout fallback E1501/E1502/E1503.
  - **No opt-out** (always raise): SF-8 stray `\end{animation}` E1007,
    SF-9 substory prelude command drop E1057.
  - **Collector-only** (never raise): SF-2 point outside viewport E1463,
    SF-5 `layout_lambda` clamp E1504, SF-14 emitter selector mismatch
    E1115 (legacy `warnings.warn` path preserved for test compat).
- **KaTeX error capture** — new E1200 code. A post-render HTML scanner
  in `tex/renderer.py` walks the output for `<span class="katex-error">`
  elements and populates the collector with the embedded ParseError
  title. Resolves Agent 14 finding 3e/3f (macro bombs).
- **`_emit_warning(ctx, code, msg, ...)` helper** in `animation/errors.py`
  is the single surface for all primitive-level warning emission.
  Threaded through `PrimitiveBase._ctx` class attribute assigned by the
  pipeline.

### Uniqueness + red-team hardening (W6.4)

- **New module `animation/uniqueness.py`** — `validate_shape_id_charset`
  (E1017), `check_duplicate_shape_ids` (E1018), `check_duplicate_animation_ids`
  (E1019). Shape id charset and duplicate checks wired into
  `scene.py::apply_prelude` and `apply_substory`. Animation id dup helper
  ships as a standalone — document-level wiring is a follow-up.
- **Starlark budget tightening** — per-block wall-clock reduced from 3s
  to 1s. New cumulative budget `_CUMULATIVE_BUDGET_SECONDS = 5.0` across
  all compute blocks in a render, with `reset_cumulative_budget()` and
  `consume_cumulative_budget()` helpers (trip code E1152).
- **KaTeX macro-expansion hardening** — `katex_worker.js` now passes
  `macros: {}` (per-call, no cross-render `\def` persistence),
  `trust: false` (blocks `\href`/`\url`/`\htmlId` and raw HTML),
  `maxExpand: 100` (down from KaTeX default 1000). Closes Agent 14
  finding 3b and reduces macro-bomb headroom.

### Tests

- **+247 new tests** across 9 new files:
  - `tests/unit/test_tree_mutation.py` (42, Wave 6.1)
  - `tests/unit/test_graph_mutation.py` (29, Wave 6.2)
  - `tests/unit/test_graph_layout_stable.py` (+14, Wave 6.2)
  - `tests/core/test_strict_mode.py` (62, Wave 6.3)
  - `tests/unit/test_uniqueness.py` (44, Wave 6.4)
  - `tests/unit/test_starlark_budget.py` (15, Wave 6.4)
  - `tests/unit/test_plane2d_remove.py` (37, Wave 6.5 — 35 tests + 2 hidden-state tests now active via VALID_STATES)
- **Full suite: 1996 passed, 1 skipped, 0 failed.**
- `TestErrorCatalogMergeGuards` parameterized over all 16 new E-codes as
  a cross-wave integration safety net.

### Known follow-ups

- **Animation id dup check** — helper exists but document-level wiring
  needs `renderer.py` edit (outside W6.4 scope). Tracked for v0.6.0 GA.
- **Starlark host integration** — `reset_cumulative_budget` /
  `consume_cumulative_budget` helpers ship, but the host-side call site
  in `starlark_host.py` is outside W6.4 scope; 2-line wiring tracked
  for v0.6.0 GA.
- **CLI `--report=path.json` flag** — render-report serialization for
  CI consumers. Deferred to v0.6.1.
- **KaTeX `errorCallback` migration** — replaces the HTML regex scan
  in `_scan_katex_errors` with a structured worker protocol. Deferred
  to v0.6.1.
- **Tree annotate rendering** — Agent 7 F2 HIGH. Out of v0.6.0 scope.
- **Cross-frame endpoint tween** — Agent 9 FR4. Deferred to v0.7.
- **Graph node mutation** — Deferred to v0.7. v0.6 covers edges only.
- **Cookbook truth pass** (Phase 4) — rewrite `h07_splay_amortized.tex`
  and `h08_persistent_segtree.tex` to use the new mutation APIs; add 8
  canonical examples (Dijkstra, Kruskal, BST insert/delete, BFS tree,
  KMP, binary search, Union-Find, LinkedList reverse). Pre-GA.

## [0.5.2] - 2026-04-11 (Completeness audit — Phase 1 quick wins)

Patch release landing **Wave 5** — the Phase 1 quick wins from the 14-agent
completeness audit recorded in `docs/archive/completeness-audit-2026-04-11/`.
Three parallel worktree agents (W5.1 Parser, W5.2 Emitter+Primitives, W5.3
Error hints) shipped ~160 LoC of concrete, audit-backed fixes with
`file_path:line_number` precision. `SCRIBA_VERSION` unchanged (= 2); fully
backward compatible with 0.5.1 consumers.

**Total shipped**: 3 clusters, 1747 tests passing (+50 from 0.5.1), 0
regressions, 0 strict xfails. Convex hull (`h06_li_chao.tex`) and dijkstra
(`demo10.tex`) cookbook examples now compile with zero `UserWarning` stderr.

### Parser (Wave 5.1)

- **Bare-token fallthrough in `_read_param_brace`.** `\apply{stk}{pop}` now
  parses natively as `{"pop": True}` instead of raising E1005. Zero-churn for
  primitives — `Stack.apply_command` already reads `pop_val` as truthy-then-int,
  so `\apply{stk}{pop}` means "pop one" with no downstream changes. `{k=v}`
  form still works identically. (grammar.py:~1422-1482)
- **LBRACE branch in `_parse_param_value`.** Nested dict values like
  `\apply{ll}{insert={index=0, value=42}}` now parse. The recursive call to
  `_read_param_brace` makes LinkedList's `insert={dict}` reachable for the
  first time. (grammar.py:~1527-1540)
- **NFC normalization on identifiers.** Source is NFC-normalized at lex time
  (grammar.py:~105-113) and `SelectorParser.__init__` defends the direct-call
  path (selectors.py:~56). NFD-encoded shape names and selectors collapse to
  a single NFC identifier — homograph Cyrillic/Latin detection still works.

### Emitter + Primitives (Wave 5.2)

- **Skip guard for bare-shape selectors.** `_validate_expanded_selectors`
  (emitter.py:324-332) and `_emit_frame_svg`'s per-target loop
  (emitter.py:442-456) both early-out when the target key is the shape name
  itself. Kills the `Plane2D 'p': invalid selector 'p', ignoring set_state()`
  warning class that polluted `h06_li_chao.tex`'s render log. Invalid
  dot-path selectors like `arr.cell[99]` still warn — the guard only masks
  whole-shape targets.
- **Truthiness check for `dequeue=false`.** `Queue.apply_command` previously
  treated `dequeue=false` as truthy and silently popped. New
  `_is_truthy_flag` helper (queue.py:50-65) accepts only Python `True` or
  case-insensitive `"true"`. `False`, `"false"`, `0`, `None`, `""` all
  correctly NO-OP now. (queue.py:~162)
- **`PrimitiveBase.set_label` wired into the emitter.** `ShapeTargetState.label`
  was being populated by the parser but the emitter never consumed it, so
  `\relabel`-style label mutations were dead code. `_emit_frame_svg` now
  calls `prim.set_label(suffix, str(label_val))` when `label` is set.
  (emitter.py:466-473)

### Error UX (Wave 5.3)

- **`suggest_closest` hoisted to `errors.py`.** Old private `_fuzzy_suggest`
  in grammar.py was moved to a public `suggest_closest(needle, candidates,
  *, cutoff=0.6)` helper in `scriba.animation.errors`. Enables cross-module
  reuse without circular imports. The E1102 primitive-type validator was
  updated to use it directly.
- **`_raise_unknown_enum` helper collapses 6 enum raise sites.** A new
  `SceneParser._raise_unknown_enum` (grammar.py:377-406) builds uniform
  `"unknown &lt;field&gt; &lt;val&gt;; valid: a,b,c (did you mean \`X\`?)"`
  messages. Six enum raise sites collapsed to 1-line calls — error codes
  unchanged (E1004×3, E1109, E1112, E1113×2). Typing `\recolor{arr}{currnet}`
  now hints "did you mean `current`?" via difflib.
- **New `E1114` for unknown shape kwargs.** `PrimitiveBase.ACCEPTED_PARAMS:
  ClassVar[frozenset[str]] = frozenset()` added at base.py:187-213. When
  non-empty, unknown kwargs raise E1114 with a `suggest_closest` hint.
  `Plane2D` populated with 12 concrete kwargs (`xrange, yrange, grid, axes,
  aspect, width, height, points, lines, segments, polygons, regions`);
  typing `Plane2D(xranges=...)` now raises E1114 hinting "did you mean
  `xrange`?". Empty frozenset is the opt-out — legacy primitives are
  unaffected. (base.py, plane2d.py:86-103)

### Tests

- +50 new tests across 9 new test files:
  - `tests/unit/test_parser_bare_token_apply.py` (8)
  - `tests/unit/test_parser_nested_dict_value.py` (5)
  - `tests/unit/test_parser_nfc_identifier.py` (5)
  - `tests/unit/test_queue_dequeue_false.py` (15)
  - `tests/unit/test_emitter_bare_shape_selector.py` (5)
  - `tests/unit/test_emitter_set_label_wired.py` (4)
  - `tests/unit/test_suggest_closest.py` (8)
  - `tests/unit/test_error_hints_enum.py` (5)
  - `tests/unit/test_error_hints_kwarg.py` (4)
- `tests/unit/test_unicode_homograph.py::test_nfc_and_nfd_shape_names_are_distinct`
  flipped to `_collapse_to_single_nfc_identifier` per the file's own
  pre-planned docstring directive.

## [0.5.1] - 2026-04-11 (Production audit fixes)

Patch release landing **Wave 1**, **Wave 2**, **Wave 3**, **Wave 4A**,
and **Wave 4B** fixes from the 21-agent production-readiness audit
recorded in `docs/archive/production-audit-2026-04-11/`. All changes are
backward compatible with 0.5.0 consumers; the only behavioral diffs are
stricter validation, structured error codes in place of opaque failures,
and a few previously silent bugs now raising `ValidationError`.

**Total shipped**: 24 clusters across 5 waves, 1697 tests passing
(+386 from the 0.5.0 baseline of 1311), 86% coverage, 0 known CVEs,
0 strict xfails.

### Security / Sandbox

- **Starlark sandbox: 3 escape vectors closed (13-C1, 13-C2, 13-C3).**
  - `str.format()` templates that touch attributes (`"{0.__class__}".format(x)`)
    are now rejected with `E1154`. Plain positional/keyword `{0}`/`{name}`
    substitution still works.
  - F-string / recursive attribute chain bypass closed: the AST scanner
    now walks every `.attr` in an attribute chain, so
    `f"{[].append.__self__.__class__}"` is rejected at the `__class__`
    link.
  - Generator / coroutine / async-generator introspection attributes
    (`gi_frame`, `gi_code`, `gi_yieldfrom`, `gi_running`, `cr_frame`,
    `cr_code`, `cr_running`, `cr_await`, `ag_frame`, `ag_code`) added
    to `BLOCKED_ATTRIBUTES`.
- **Determinism: `hash()` removed from Starlark builtins (08-C2).** The
  prior exposure broke the byte-identical-output guarantee because the
  builtin is seeded by `PYTHONHASHSEED`.
- **Memory limit aligned to spec (08-C1).** `_MEMORY_LIMIT_BYTES` and
  `_TRACEMALLOC_PEAK_LIMIT` both pinned at 64 MB (spec §6.3). The prior
  256 MB host / 128 MB tracemalloc drift is gone.
- **Dunder blocklist expanded (08-M1).** `__class_getitem__`,
  `__format__`, `__getattr__`, `__getattribute__`, `__set_name__`,
  `__init_subclass__` added to the sandbox dunder blocklist.
- **Walrus and `match` statements forbidden (13-H1, 13-H3).**
  `ast.NamedExpr` and `ast.Match` added to `_FORBIDDEN_NODE_TYPES`.
- **Recursion limit pinned (08-M2).** `sys.setrecursionlimit(1000)` is
  now called explicitly at worker startup so the spec's 1000-frame
  limit is enforced independent of the host interpreter default.
- **Deterministic set iteration (08-M3).** Set serialization uses
  `(str(x), repr(x))` as the tie-break key for stable ordering when
  `str(x)` collides.
- **Sanitizer allowlist expanded (06-C1, 06-H1, 06-M1).** `bleach`
  was silently stripping attributes the emitter actually writes:
  - `<figure>`: `data-scriba-scene`, `data-frame-count`, `data-layout`,
    `aria-label`
  - `<div>`: `data-scriba-frames` (substory widget)
  - `<svg>`: `aria-labelledby`, `role`
  - `<g>`: `data-target` (primitive shape-group selectors)
  Each addition is documented as inert (no URL-accepting attribute, no
  script execution, no `is_safe_url` wiring needed).

### Parser

- **Unclosed brace at EOF now raises `E1001` (07-C1).** Previously
  `\shape{a}{Array}{size=5` silently parsed to an empty shape, losing
  user data.
- **Unknown commands rejected with `E1006` (01-H2).** `\foo` (where
  `\foo` is not in `_KNOWN_COMMANDS`) now raises a structured error
  listing the valid commands instead of silently becoming a CHAR token.
  Bare backslashes (`\{`, `\\`) still parse as CHAR.
- **`\step[label=...]` now supported at parse level (01-H1).** The
  spec on line 546 documented step label options that the parser
  previously rejected with `E1052`. `FrameIR.label` carries the value
  through the AST; top-level and `\substory`-nested steps both accept
  it. Emitter wiring will land in a follow-up.
- **`\foreach` depth limit enforced at parse time (07-H3).** Mirrors
  the runtime limit in `scene.py` so deep nesting now errors out at
  parse time with a structured code rather than producing a
  pathological IR tree.
- **`FrameIR.label` field added** to the animation AST.

### Error codes & UX

- **`E1103` mega-bucket split (05-C2, 09-C2, 09-H1).** The previous
  catch-all has been replaced with primitive-specific codes carrying
  valid-range hints in the message:
  - `E1400` — empty params at primitive construction
  - `E1401` — Array size overflow (valid: 1..10000)
  - `E1411` — Grid rows/cols overflow
  - `E1412` — Grid data shape mismatch
  - `E1420` — Matrix missing rows/cols
  - `E1425` — Matrix / DPTable cell count exceeds 250 000
  - `E1430` — Tree missing root
  - `E1453` — NumberLine invalid domain
  - `E1454` — NumberLine domain overflow
  - (plus DPTable / Graph / HashMap / Queue / Stack specific codes;
    see the error catalog for the full list)
  - `E1103` itself is kept as a **deprecated alias** for user code that
    catches it generically.
- **`E1425` wired at Matrix/DPTable cell cap (06-H2, 10-C1).** The
  spec-code drift (spec said 10 000, code allowed 250 000, the cap
  raised generic `E1103`) is now resolved: both primitives raise
  `E1425` with the actual `rows*cols` value and the 250 000 limit.
- **`E1173` for foreach iterable overflow (05-C2).** `_safe_range()`
  now raises a structured `animation_error("E1173", ...)` instead of a
  bare `ValueError` (which previously collapsed to `E1151`).
- **`animation_error()` factory extended (05-C3, 09-H2).** New keyword
  arguments `line`, `col`, `hint`, `source_line` (all optional, fully
  backward compatible) are forwarded to `ValidationError.__init__`.
- **`ValidationError` source-snippet rendering (09-M3).** `__str__()`
  now appends a pointer line when `source_line` is provided:
  `at line 42, col 15:\n  <source>\n  ^` — omitted when unset, so
  existing callers see identical output.
- **`ValidationError.from_selector_error()` classmethod added (09-H4)**
  for selector-position → line/col translation.
- **`errors.format_compute_traceback()` helper added (09-H3)** — filters
  Python internals out of Starlark tracebacks so editorial authors see
  only their own `\compute` block stack.

### Pipeline & workers

- **Placeholder substitution re-entry hole closed (20-C2).** Each
  `render()` call now allocates a fresh 128-bit hex nonce baked into
  the placeholder prefix (`secrets.token_hex(16)`), and substitution
  walks markers in a single `re.sub` pass keyed by block index.
  Adversarial or buggy renderer output that happens to contain the
  legacy `\x00SCRIBA_BLOCK_N\x00` pattern can no longer trigger
  re-entrant substitution.
- **Context-provider validation (20-C1).** `Pipeline._prepare_ctx()`
  wraps each provider in `try/except` and asserts
  `isinstance(ctx, RenderContext)` after every provider returns.
  Provider exceptions are re-raised as `ValidationError` with provider
  identity; missing instance check raises with the offending type.
- **`renderer.version` coercion guarded (20-H2).** `int(renderer.version)`
  is now wrapped: non-int-coercible values yield `ValidationError`
  naming the renderer and the offending type instead of a bare
  `TypeError` mid-render.
- **Block-render error enrichment (20-M1).** Mid-loop failures are
  re-raised with `renderer.name`, block `kind`, and byte range via
  `__cause__` chaining so partial-failure diagnostics are actionable.
- **`Pipeline.close()` cleanup exceptions surfaced (20-H1).** Each
  failing `renderer.close()` now emits a `RuntimeWarning` and is
  logged with a traceback; cleanup remains best-effort.
- **Asset-path collision warning (20-M2).** When two renderers map the
  same namespaced `namespace/basename` key to different paths, a
  `UserWarning` is emitted and the first-seen path wins. Previously
  the second path silently clobbered the first.
- **`context_providers=[]` loud-opt-out (20-C3).** Passing an explicit
  empty list now emits a `UserWarning` so consumers notice they have
  opted out of every default provider (including TeX inline-rendering
  auto-wiring). Passing `None` or omitting the argument still activates
  the built-in defaults.
- **Worker JSON protocol is ASCII-safe (20-H3).** Both
  `PersistentSubprocessWorker.send` and `OneShotSubprocessWorker.send`
  now pass `ensure_ascii=True` so zero-width joiners, BOM, and LS/PS
  separators cannot break newline framing via adversarial Unicode in
  request payloads.
- **`SubprocessWorker` alias deprecated (14-H2).** The long-standing
  alias now emits a single `DeprecationWarning` at module import time.
  Identity is preserved (`isinstance` and `is` still match
  `PersistentSubprocessWorker`); the warning is the only behavioral
  change. Migrate to `PersistentSubprocessWorker`.
- **Dead `getattr` fallback removed (20-L1).** `__init__` already
  validates `renderer.name`, so the `getattr(renderer, "name",
  "unknown")` fallback in asset namespacing was unreachable and masked
  programmer errors.

### Primitives & limits

- **Matrix / DPTable raise `E1425` at cell cap (06-H2).** The error
  message now includes `rows`, `cols`, and the `rows*cols` overflow
  value so authors can see how much they were over.
- **Graph with empty nodes now raises (10-L / prior H4).**
  `Graph.__init__` raises `animation_error("E1103")` on missing or
  empty `nodes=[]` (previously warn-only). Two pre-existing tests
  updated to expect the raise.
- **Annotation list per-frame cap (10-M1).**
  `SceneState._MAX_ANNOTATIONS_PER_FRAME = 500`; overflow raises
  `ValidationError(E1103)` at `_apply_annotate()`.
- **CodePanel 1-based indexing made load-bearing (04-H4).**
  `validate_selector()` has an explicit `idx < 1` short-circuit and a
  docstring spelling out the one-off convention; boundary tests pin
  that `line[1]` is the first valid line and `line[0]` is rejected.
- **LinkedList `link[i]` semantics documented (04-M1).**
  Class-level comment pins `link[i]` as the outgoing arrow from
  `node[i]` to `node[i+1]`; valid indices are `0..N-2`.

### Spec & docs

- **Duplicate `§5.3` in `ruleset.md` renumbered (02-C1).** Former
  `§5.3` (second copy) → `§5.4`; `5.4–5.8` cascaded to `5.5–5.9`.
- **Stack spec drift fixed (04-C1).** `§5.2` / `§5.8` no longer
  reference the non-existent `cell_width` / `cell_height` / `gap`
  parameters; all remaining params clarified as optional.
- **`\begin{diagram}` marked reserved for extension E5 (01-C1).**
  v0.5.x treats diagram mode as unimplemented; parser still returns
  `AnimationIR` and the spec flags the gap explicitly.
- **`\step` forbidden inside `\foreach` documented (19-H4).** A
  prominent note in `ruleset.md §2.1` explains that `\step`,
  `\shape`, `\substory`, and `\endsubstory` are not allowed inside a
  `\foreach` body (→ `E1172`) and points at the manual-unroll pattern
  for algorithms that need per-iteration frames (monotonic stack,
  amortized walk).
- **CodePanel 1-based note added to `ruleset.md §3` (04-H4).**
- **`environments.md §3` header clarified (02-C3).** 12 block
  constructs (counted as one each).
- **`primitives.md` stale refs fixed (02-C2).**
  `04-environments-spec.md` → `environments.md`.
- **Sandbox spec §6.1 / §6.3 / §7.1 / §7.3 expanded (08-M2, 13-H1).**
  Windows SIGALRM fallback, three-layer memory enforcement,
  intentionally-allowed AST nodes (`ListComp`, `DictComp`, `SetComp`,
  `GeneratorExp`, `JoinedStr`, `isinstance`, generator `.send/.throw`)
  all documented.
- **Cookbook 06-frog1-dp `\compute{}` indentation bug fixed (18-C4).**
  The Starlark block previously had inconsistent indent levels that
  would fail Starlark parse. Rewritten with uniform 4-space indents;
  algorithmic meaning unchanged.
- **Blog post `launch-0.5.0.md` primitive count corrected (18-C3).**
  The table now enumerates all 16 primitives including the 5
  data-structure primitives (Queue, LinkedList, HashMap, CodePanel,
  VariableWatch); the stale "11 primitives" / "Plus 4 extensions"
  wording is gone.
- **CHANGELOG 0.5.0 back-fill (18-H3).** 5 data-structure primitives
  (Queue, LinkedList, HashMap, CodePanel, VariableWatch) are now
  retroactively acknowledged in the 0.5.0 "Added" section.
- **Cookbook example 11 added (19-H4):** `11-loop-to-step-manual-unroll.md`
  walks through the monotonic-stack next-greater pattern showing how
  to manually unroll `\step` blocks and use `\foreach` inside each
  step for per-iteration fanout.
- **README "Coming in v0.2.0" note removed.** The animation environment
  has been shipping since 0.2.0 and is part of 0.5.x; the forward-looking
  note was factually stale.

### Tests

- **1439 tests passing** (1311 baseline + Wave 3 additions). Wave 1+2
  contributed 50 Starlark red-team cases, 57 sanitizer-contract assertions
  (including 41 parametrized per-tag membership snapshots pinning
  `ALLOWED_TAGS` / `ALLOWED_ATTRS` against silent regression), 21 parser/
  lexer cases, 20 primitive cases, and 17 pipeline/worker cases. 16 tests
  across 8 files updated to expect the new specific error codes instead
  of `E1103`.

### Wave 3 — Test infrastructure & coverage (Cluster 7)

- **pytest-cov wired (17-H2).** `pyproject.toml` gains `pytest-cov>=5.0`
  and a `[tool.coverage.run]` / `[tool.coverage.report]` section with
  `fail_under = 75`, branch coverage, and sensible omits. Coverage runs
  opt-in via `uv run pytest --cov=scriba --cov-report=term-missing`;
  baseline is **84.33%** (above the 75% gate).
- **Hypothesis property tests (17-C1).** 10 property-based parser
  tests in `tests/unit/test_parser_hypothesis.py` covering identifiers,
  selectors, shape declarations, foreach iterables, unclosed-brace E1001,
  empty bodies, `.all` / `.node[]` / `.cell[]` accessors, and
  interpolation refs. Hypothesis is a new dev dependency.
- **`\cursor` command tests (16-C2).** 15 integration tests in
  `tests/unit/test_cursor_command.py` pinning E1180/E1181/E1182 and
  frame-to-frame state transitions. Previously the command had zero
  test coverage.
- **`\reannotate` / `\apply` / `\compute` coverage (16-H5).** 11 tests
  in `tests/unit/test_reannotate_apply_compute.py` covering recolor
  semantics, multi-primitive `\apply`, and the compute → `\foreach`
  binding bridge.
- **Error code coverage (16-C1).** 15 regression tests in
  `tests/unit/test_error_code_coverage.py` pinning previously-unverified
  codes (E1003, E1013, E1051-E1056, E1102, E1400, E1150, E1154, E1172,
  E1460, E1465, E1480). Net-new safety net against silent regression
  of error dispatch paths.
- **KaTeX worker stress tests (17-H3).** 2 tests in
  `tests/unit/test_workers_stress.py` — 100 concurrent inline-math
  requests and bad-math recovery — verifying no deadlocks and that
  one bad request does not kill the worker.

### Wave 3 — Public API surface & stability policy (Cluster 9)

- **`STABILITY.md` added at repo root (15-C1/C2/C3).** Documents the
  11 locked contracts Scriba promises to consumers: public API surface,
  `Document` shape, asset namespace format (`<renderer>/<basename>`),
  error code numbering (append-only in E1001–E1599), exception
  hierarchy, `ALLOWED_TAGS`/`ALLOWED_ATTRS`, CSS class names, SVG scene
  ID format (`scriba-<sha256[:10]>`), `SCRIBA_VERSION`, `Renderer`
  protocol, and `SubprocessWorker` deprecation. See `STABILITY.md` for
  SemVer/deprecation policy and the contract-change procedure.
- **`ScribaRuntimeError` exported from `scriba` and `scriba.core`
  (14-C1).** Previously the class existed in `scriba/core/errors.py`
  but was missing from `__all__`, so `from scriba import
  ScribaRuntimeError` failed. Now symmetric across both namespaces.
- **Lazy `SubprocessWorker` deprecation via PEP 562 `__getattr__`
  (14-H2 follow-up).** Wave 1 Cluster 3 emitted the deprecation warning
  at module import time, which fired on every plain `import scriba`.
  Wave 3 moved it to a lazy `__getattr__` hook so only external code
  that actually touches `SubprocessWorker` sees the warning; scriba's
  own internal imports and end-user `import scriba` stay silent.
- **`docs/spec/architecture.md` refreshed (14-C2).** Now documents
  `Document.block_data` and `Document.required_assets` (added in 0.1.1
  but never in the locked spec), the asset namespace format, and the
  full exception hierarchy including `ScribaRuntimeError`.
- **68 new contract-stability tests.**
  `tests/unit/test_public_api.py` (46) pins `__all__` membership for
  both `scriba` and `scriba.core`, verifies every symbol imports, and
  asserts the deprecation warning contract (silent on `import scriba`,
  fires on attribute access). `tests/unit/test_stability.py` (22)
  pins the `Document` field set, asset-key namespace separator,
  `Renderer` protocol attributes, `scriba-<sha256[:10]>` SVG ID format,
  and `ALLOWED_TAGS`/`ALLOWED_ATTRS` shape.

### Wave 3 — Ops, CI, release (Cluster 10)

- **GitHub Actions CI (`.github/workflows/test.yml`) (21-C2).**
  Python 3.10/3.11/3.12 × {ubuntu, macos} matrix with Node 20. A
  separate coverage job runs on ubuntu + Python 3.12 with
  `--cov-fail-under=75` and uploads `coverage.xml` as an artifact.
  Windows intentionally excluded; see `SECURITY.md` §Known limitations
  for the SIGALRM reason.
- **Release workflow template (`.github/workflows/release.yml`) (21-C3).**
  Triggers on `workflow_dispatch` and `push: tags v*`. Builds wheel +
  sdist via `uv build` and uploads as artifact. `uv publish` step is
  commented out pending PyPI trusted-publisher configuration; GitHub
  release job creates drafts for manual review. Clear TEMPLATE header.
- **Dependabot (`.github/dependabot.yml`).** Pip weekly, GitHub Actions
  monthly. KaTeX is vendored out-of-band, so no npm ecosystem entry.
- **`SECURITY.md` refreshed (21-C1).** Supported versions updated from
  stale `0.1.x` to `0.5.x Beta`. New "Known limitations" section
  documenting the Windows SIGALRM gap and the vendored KaTeX 0.16.11
  (latest upstream 0.16.22 is not yet integrated, pending a visual
  regression suite). New "Vendored dependencies" section pointing at
  `scripts/vendor_katex.sh` for the upgrade procedure.
- **`CONTRIBUTING.md` prerequisites section (21-H2).** Explicitly
  lists Python 3.10+, Node.js 18+, and `uv` as setup prereqs, with a
  fresh-clone quickstart (`uv sync --dev && uv run pytest -q`). Notes
  Windows is unsupported for development.
- **Homebrew formula marked as template (21-C3/21-H1).** Prominent
  header states SHA256 values will be populated at first PyPI release.
  Ruby syntax verified with `ruby -c`. Added `depends_on "node"` with
  an explanatory comment about vendored KaTeX.
- **KaTeX upgrade procedure documented (21-H4).** Seven-step checklist
  in `scripts/vendor_katex.sh` header covering release-note review,
  SHA-256 verification, full test run, snapshot-diff inspection, and
  SECURITY.md sync. The actual 0.16.11 → 0.16.22 upgrade is deferred
  to Wave 4+ pending a visual regression suite.
- **`scripts/check_deps.sh` helper.** Wraps `uv pip audit` / `pip-audit`
  for CVE scanning of the dependency tree.

### Wave 3 — Docs ecosystem cleanup (Cluster 8)

- **`CHANGELOG.md`** (this file) received the fat 0.5.1 entry you are
  reading, ingested from the Cluster 2 / Cluster 3 handoff file
  `docs/archive/production-audit-2026-04-11/changelog-pending.md`
  (now deleted).
- **5 data-structure primitives retroactively acknowledged in 0.5.0
  (18-H3).** The CHANGELOG had silently omitted Queue, LinkedList,
  HashMap, CodePanel, and VariableWatch since their introduction; they
  are now listed in the 0.5.0 "Added" section.
- **Blog post primitive count (18-C3).** `docs/blog/launch-0.5.0.md`
  now says "16 primitives" (was "11") and lists them in three labeled
  groups: Base (6), Extended (5), Data-structure (5). Removed the
  misleading "Plus 4 extensions" sentence.
- **Cookbook 06-frog1-dp Starlark indent fix (18-C4).**
  `docs/cookbook/06-frog1-dp/input.md` had inconsistent indentation in
  the `\compute` block that would have failed Starlark parse. Fixed
  to uniform 4-space style; semantics unchanged.
- **New cookbook recipe 11 (19-H4 deferred from Cluster 4).**
  `docs/cookbook/11-loop-to-step-manual-unroll.md` walks through the
  monotonic-stack next-greater pattern, showing how to manually unroll
  `\step` blocks when an algorithm needs per-iteration frames, with
  `\foreach` used inside each step for per-iteration fanout.
- **Primitive docs refreshed.** `docs/primitives/matrix.md` updated
  to 250k cell cap / E1425 (was stale 10k / E1421); `docs/primitives/
  stack.md` removed stale `cell_width`/`cell_height`/`gap` params.
- **`README.md` "Coming in v0.2.0" note removed.** Animation has been
  shipping since 0.2.0; the forward-looking paragraph was factually
  stale. Replaced with a factual 16-primitive summary.

### Wave 4A — Parser, emitter, sandbox, and test-surface polish

- **Parser improvements (01-H2, 07-M3/M4/M6, 07-H3, 09-H4).**
  Unknown primitive types rejected at parse with `E1102` + fuzzy
  "did you mean?" suggestion. Negative selector indices rejected
  with `E1010`. `\foreach` nesting depth enforced at parse time
  via `_MAX_FOREACH_DEPTH = 3` (E1170), matching the runtime
  check in `scene.py`. `${var}` interpolation references emit a
  best-effort UserWarning when no known compute-scope binding is
  declared. Selector-parse errors now carry accurate `line`/`col`.
- **Emitter `\step[label=...]` wiring (01-H1 follow-through).**
  Wave 3 Cluster 4 added AST+parser support for `FrameIR.label`;
  Wave 4A Cluster 2 wired it through 5 emitter sites. Frame IDs
  now use `{scene_id}-{label}` when the label is id-safe, else
  `{scene_id}-frame-{N}`. Duplicate labels within a scene raise
  `E1005`. Interactive widget and print-frame markup gain a
  `data-label` attribute when labels are present.
- **Starlark sandbox hardening (08-H2, 09-H3, 13-H1/H3).**
  `_FORBIDDEN_NODE_TYPES` expanded with `ast.NamedExpr` (walrus)
  and `ast.Match`. `format_compute_traceback()` helper now wired
  into `_evaluate` so `E1151` runtime errors show clean
  `<compute>` frames without Scriba-internal Python frames. Windows
  users get a one-shot `RuntimeWarning` at `StarlarkHost` init
  explaining that `SIGALRM` wall-clock backstop is Unix-only.
- **Primitive limit hardening (06-H3/H4, E1505).** Plane2D
  `_check_cap()` converted from soft-drop + log to hard-raise
  `E1466`. MetricPlot `_MAX_POINTS = 1000` cap converted to hard
  `E1483` with series name in message. Graph `layout_seed` now
  validated (rejects non-int, float, bool, negative) via `E1505`;
  `seed` accepted as alias when `layout_seed` is absent.
- **cses cookbook content fixes.** 4 files (`cses02_missing_number`,
  `cses03_repetitions`, `cses04_increasing_array`,
  `cses05_permutations`) had 2-space-indented `\compute` bodies
  that Starlark rejected as "unexpected indent". Dedented to
  column 0; 58/58 batch compile now clean.
- **Ruleset prose quality (03 series).** `docs/spec/ruleset.md`
  §1 RFC 2119 compliance note. §3.1 new "Interpolation and
  Subscript Semantics" subsection (E1155/E1156/E1157). §3.2 new
  "Command Evaluation Order". §4 `dim` state concrete CSS formula
  `color-mix(in oklch, var(--scriba-fg) 10%, transparent)`
  (previously the inaccurate "50% opacity"). §6.6 "Runtime Error
  Context". §8 frame lifecycle fully rewritten with 8 subsections
  + 13-row state-transition table. 15 prose "must/may" occurrences
  promoted to RFC 2119 MUST/MUST NOT/SHOULD.
- **Docs ecosystem sweep.** `docs/guides/editorial-principles.md`
  primitive count 6 → 16, state vocabulary corrected to canonical
  `idle/current/done/dim/good/error/path`. `docs/guides/
  diagram-plugin.md` + `animation-plugin.md` constructor signatures
  corrected (`starlark_host=` kwarg, dropped fictional
  `worker_pool`/`strict=` args). 4 primitive docs (plane2d,
  metricplot, graph-stable-layout, variablewatch) moved from
  "draft — Pivot #2" to "shipped in v0.5.x"; fictional error
  codes (E1105, E1106, E1464, E1482) removed. ~22 stale
  `04-environments-spec.md` cross-references replaced.
- **Test coverage improvement (17-H2 target).** 117 new tests
  bringing `scriba/tex/__init__.py` from 40% → 100%,
  `scriba/tex/renderer.py` 79% → 95%, `scriba/tex/highlight.py`
  49% → 87%, `scriba/core/workers.py` 71% → 82%. Overall coverage
  86% (above the 75% fail_under gate).
- **New test categories (17-M3).** `test_svg_injection.py` (19
  tests), `test_unicode_homograph.py` (17), `test_recursive_dos.py`
  (14), `test_substory_soft_limit.py` (8), and 8 new
  `TestSecurityWave4NewVectors` cases. Flagged 4 real bugs for
  Wave 4B (see below).
- **Ops follow-through.** `.github/SECURITY_CONTACTS.md` stub.
  First recorded pip-audit baseline in `docs/ops/dep-cve-baseline.md`
  (OPS-001: pygments 2.19.2 CVE-2026-4539). Visual regression
  scaffold `scripts/visual_regression/compare.py` (structural HTML
  diff; Layer 1 of a 3-layer plan documented in
  `docs/ops/visual-regression.md`).

### Wave 4B — Bug fixes from Wave 4A audit findings

- **Tree recursion DoS fix.** `reingold_tilford` converted from
  recursive DFS to iterative (explicit-stack, 3-pass). 1000/1500-
  level linear trees now build in <1s without hitting Python's
  1000-level recursion limit. Existing 80 tree tests pass
  unchanged — algorithm equivalence verified.
- **Graph O(N²) layout DoS fix.** `Graph.__init__` now raises
  `E1501` when `len(nodes) > _MAX_NODES` (100). A 1000-node cycle
  (which took ~12s force-layout under Wave 4A) is now rejected
  immediately with a message surfacing the offending count +
  `layout=stable` hint.
- **Sandbox `ast.FunctionDef` bypass fix.** `_FORBIDDEN_NODE_TYPES`
  expanded with `ast.Yield`, `ast.YieldFrom`, `ast.AsyncFunctionDef`,
  `ast.AsyncFor`, `ast.AsyncWith`, `ast.Await`. `def f(): yield 1`
  is now rejected by the pre-exec AST scan (via `ast.walk` recursion
  into function bodies), while plain `def f(): return x` helper
  functions remain legal — preserving cookbook 05/07/08 +
  `TestFunctionDef`/`TestRecursion`. `async def` is forbidden
  outright (no legitimate compute use).
- **NumberLine `ticks < 1` validation fix.** `NumberLinePrimitive`
  now raises `E1103` with explicit valid range `1..1000` when
  `ticks <= 0`. Previously the upper-bound check `ticks > 1000`
  silently accepted `ticks=0`, producing a degenerate primitive.
- **pygments CVE-2026-4539 closed.** `pyproject.toml` pin lifted
  `<2.20` → `<2.21`, pygments 2.20.0 locked. `uv run --with
  pip-audit pip-audit` now reports 0 known vulnerabilities.
  `docs/ops/dep-cve-baseline.md` updated with OPS-001 RESOLVED.
- **E1483 catalog polish.** Message updated from
  `"Series exceeded maximum point count (truncated)."` →
  `"MetricPlot series exceeded maximum point count (hard limit)."`
  matching Wave 4A Cluster 4's soft-drop → hard-raise conversion.
- **Parser source_line propagation (09-M3 follow-through).**
  `ValidationError` has a `source_line: str | None` field since
  0.1.1 but parser raise sites didn't populate it. Wave 4B Cluster
  3 wired 19 grammar.py raise sites + 6 selector-parse call sites
  via a new `SceneParser._source_line_at(line_number)` helper.
  Error messages now render a source snippet + caret pointer at
  the offending column:
  ```
  [E1053] at line 1, col 1: \highlight is not allowed in the prelude
        \highlight{a.cell[0]}
        ^
    -> https://scriba.ojcloud.dev/errors/E1053
  ```
- **Selector parser latent bug fixed.**
  `SelectorParser._error` was silently dropping `line`/`col`
  parameters entirely; now forwarded alongside `source_line`.

### Tests (final counts)

- Baseline 0.5.0: 1311 passing
- +227 from Wave 4A (parser, emitter, sandbox, coverage,
  new categories, etc.)
- +31 from Wave 4B (DoS fixes, sandbox FunctionDef gap,
  ticks<1, E1483 catalog, source_line wiring)
- Total: **1697 passing**, 1 skipped, 0 xfails, 0 failures

## [0.5.0] - 2026-04-10 (Phase D)

### Added
- Structured error codes (`E1xxx`) with line and column information for all
  parse and render errors, replacing opaque tracebacks with actionable messages.
- HARD-TO-DISPLAY verification suite achieving 9/10 coverage across edge-case
  LaTeX constructs.
- Launch blog post and documentation site.
- Homebrew tap for CLI installation (`brew install ojcloud/tap/scriba`).
- **Five data-structure primitives retroactively documented**
  (`Queue`, `LinkedList`, `HashMap`, `CodePanel`, `VariableWatch`). These
  landed across the 0.3.x–0.5.0 window without explicit CHANGELOG entries;
  they are pinned here for provenance. `CodePanel` is the one primitive
  in the catalog that uses **1-based** line indexing (every other
  primitive is 0-based) so that line numbers match the displayed
  gutter. See `docs/primitives/codepanel.md`, `queue.md`,
  `linkedlist.md`, `hashmap.md`, `variablewatch.md`.

### Changed
- Error UX overhaul: every user-facing error now carries a unique `E1xxx` code,
  source location (line/col), and a human-readable suggestion.
- Development status upgraded from Alpha to Beta in PyPI classifiers.

### Fixed
- Remaining edge cases in error reporting for deeply nested LaTeX environments.

## [0.4.0] - 2026-04-09 (Phase C)

### Added
- `Plane2D` animation primitive for 2D coordinate plane visualizations.
- `MetricPlot` animation primitive for plotting algorithmic metrics over time.
- `Graph` layout mode `layout=stable` for deterministic node positioning across
  animation frames.
- `\substory` macro for composing nested editorial sub-narratives within a
  single animation timeline.

### Changed
- Graph renderer now supports stable layout by default when `layout=stable` is
  specified, preventing node jitter between frames.

### Fixed
- Graph layout instability when nodes are added or removed between frames.

## [0.3.0] - 2026-04-09 (Phase B)

### Added
- `scriba.diagram` plugin for rendering diagram blocks alongside TeX.
- `Grid` animation primitive for 2D grid-based visualizations (BFS/DFS grids,
  game boards).
- `Tree` animation primitive for tree structure visualizations with
  auto-layout.
- `NumberLine` animation primitive for 1D range and interval visualizations.
- `figure-embed` directive for embedding static or animated figures inline
  within editorial text.
- `Matrix` and `Heatmap` animation primitives for 2D numeric data
  visualization.
- `Stack` animation primitive for LIFO data structure visualization.

### Changed
- Animation scaffold extended to support diagram-originated primitives
  alongside TeX-originated ones.

### Fixed
- Figure embedding edge cases when mixing inline TeX math with diagram
  figures.

## [0.2.0] - 2026-04-08 (Phase A)

### Added
- Animation scaffold: `@keyframes`-based CSS animation engine for editorial
  step-by-step playback.
- `Array` animation primitive for visualizing array operations (swaps,
  highlights, pointer movement).
- `DPTable` animation primitive for dynamic programming table fill
  animations.
- `Graph` animation primitive for graph algorithm visualizations (BFS, DFS,
  shortest path).
- `\hl` (highlight) LaTeX macro for marking editorial text regions that
  synchronize with animation steps.
- `@keyframes` generation from editorial step descriptors, producing
  self-contained CSS animations without JavaScript dependencies.

### Changed
- `SCRIBA_VERSION` bumped to `3` for animation-aware `Document` shape.
- `Document` dataclass extended with animation timeline metadata.

### Fixed
- Snapshot test alignment after `Document` shape changes.

## [0.1.1-alpha] - 2026-04-08

Phase 3 architect-review fixes. Bumps `SCRIBA_VERSION` to `2` because
`Document` gains `block_data` and `required_assets` fields and the
asset key shape changes (now namespaced as `<renderer>/<basename>`).

### Added
- `scriba.core.Worker` -- runtime-checkable Protocol any worker satisfies
- `scriba.core.PersistentSubprocessWorker` -- renamed from
  `SubprocessWorker` (kept as deprecated alias for one release)
- `scriba.core.OneShotSubprocessWorker` -- spawns a fresh subprocess per
  call for engines that should not be kept alive
- `SubprocessWorkerPool.register(..., mode="persistent"|"oneshot")`
- `RenderArtifact.block_id` and `RenderArtifact.data` -- public per-block
  payload exposed on `Document.block_data`
- `Document.block_data` -- `{block_id: data}` aggregated from artifacts
- `Document.required_assets` -- `{namespaced-key: Path}` map for renderer
  assets, parallel to `required_css`/`required_js`
- `Renderer.priority: int` -- overlap tie-breaker (lower wins, default 100)
- `Pipeline(..., context_providers=[...])` -- pluggable hooks; default
  set keeps the previous TeX inline-renderer auto-wiring
- `scriba.tex.tex_inline_provider` -- explicit context provider that
  callers can pass to opt out of duck-typing detection
- `scriba.tex.parser._urls.is_safe_url` -- shared URL safety check used by
  href/url and the includegraphics resolver
- `scriba.tex.parser.math.MAX_MATH_ITEMS = 500`
- `scriba.tex.renderer.MAX_SOURCE_SIZE = 1_048_576`
- New tests: oneshot worker, Worker protocol, namespaced assets,
  block_data round-trip, priority tie-breaker, math item cap, source
  size cap, four new XSS tests for href URL smuggling, image resolver
  output validation

### Changed
- **BREAKING (cache key)** `Document.required_css` / `required_js` now
  contain namespaced strings of the form `"<renderer>/<basename>"` so
  two renderers can ship files with the same basename without collision.
- `Pipeline.render` overlap resolution now sorts by
  `(block.start, renderer.priority, list-index)` instead of just
  `(start, list-index)`.
- `_is_safe_url` rewritten to use `urllib.parse.urlparse` after
  stripping all C0 control characters and unicode line/paragraph
  separators.
- `extract_math` raises `ValidationError` if more than `MAX_MATH_ITEMS`
  expressions are found.
- `TexRenderer.detect` raises `ValidationError` for sources larger than
  `MAX_SOURCE_SIZE` bytes.

### Fixed
- `TexRenderer._render_inline` and the math batch fallback now log a
  `warning` before swallowing `WorkerError`.
- `Pipeline` no longer late-imports `scriba.tex` for inline-tex wiring.
- `apply_includegraphics` validates the resolver result through
  `is_safe_url`; unsafe URLs are treated as missing images.

## [0.1.0-alpha] - 2026-04-08

First alpha release. TeX plugin generalized from an earlier in-house KaTeX
worker; diagram plugin (0.2+) reserved.

### Added
- `scriba.core.Pipeline` -- plugin orchestration with
  detect-then-render-with-placeholders
- `scriba.core.SubprocessWorkerPool` / `SubprocessWorker` -- generalized
  persistent/per-call subprocess management
- `scriba.core.{Block, RenderArtifact, Document, RenderContext,
  RendererAssets}` -- frozen dataclasses for the plugin contract
- `scriba.core.{ScribaError, RendererError, WorkerError, ValidationError}`
  -- exception hierarchy
- `scriba.tex.TexRenderer` -- LaTeX to HTML renderer with KaTeX math,
  Pygments highlighting
- Shipped static assets: `scriba-tex-content.css`,
  `scriba-tex-pygments-{light,dark}.css`, `scriba-tex-copy.js`
- `scriba.sanitize.{ALLOWED_TAGS, ALLOWED_ATTRS}` -- bleach whitelist
  matching the output contract
- 71 tests: 30 snapshot + 5 XSS + 6 validator + 9 API + 7 pipeline +
  9 workers + 7 sanitize

[0.5.1]: https://github.com/Danchuong/scriba/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/Danchuong/scriba/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/Danchuong/scriba/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/Danchuong/scriba/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/Danchuong/scriba/compare/v0.1.1-alpha...v0.2.0
[0.1.1-alpha]: https://github.com/Danchuong/scriba/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/Danchuong/scriba/releases/tag/v0.1.0-alpha
