# Decisions

**Status:** Implemented

This is the architecture decision record. Each entry states the context that forced a choice, the decision, and its consequences. Entries are never edited after acceptance; a reversal is a new entry that references the old one. Other documents cite entries as `D-N`.

## 1. D-1 — `IR.Transform.scale/2` is two constructors, not one

**Context.** `Visualize.IR.Transform.scale/2` had two clauses: `scale(sx, sy)` building a new transform, and `scale(%Transform{}, s)` appending a uniform scale. A guard ordering made the second clause unreachable, so `Transform.new() |> scale(2)` raised `FunctionClauseError` and the documented pipeline form did not work.

**Decision.** `scale/2` with two numbers constructs a new transform with `{:scale, sx, sy}`; with a transform and one number it appends `{:scale, s, s}`. `scale/1` constructs a uniform scale; `scale/3` appends a non-uniform one. Both `scale/2` clauses are guarded so each is reachable.

**Consequences.** The pipeline form works and is tested in `test/visualize/defects_test.exs`. The overloading remains: a reader must know that the first argument's type selects the meaning. This mirrors `translate/2` vs `translate/3` and `rotate/1` vs `rotate/2`, so it is consistent rather than surprising.

## 2. D-2 — No per-process backend override

**Context.** `Visualize.Render.with_backend/2` set a backend in the process dictionary for the duration of a function, restoring it afterwards. Any render inside the function silently used a backend chosen elsewhere, and restoring the previous value required `try/after`, which this project forbids because it hides the failure it wraps.

**Decision.** `with_backend/2` is removed. A backend is selected only by the `:backend` option at the call site or by `config :visualize, default_backend:`. `Visualize.Backend.default/0` reads the configuration; nothing reads the process dictionary.

**Consequences.** Every render call names its backend or inherits the application-wide default, so the choice is visible where it matters. Code that rendered inside `with_backend/2` threads `backend:` through instead. No `try/after` remains in `lib/`.

## 3. D-3 — Clip angle clips by angular distance from the centre

**Context.** `Visualize.Geo.Projection.clip_angle/2` stored a value that nothing read: a projection with a clip angle still projected the far hemisphere, so orthographic globes drew the back of the world over the front.

**Decision.** `project/3` returns `nil` when the angular distance from the point to the projection centre (after rotation) exceeds `clip_angle`, with a `1.0e-9` tolerance on the boundary. `new/1` sets a default clip angle per azimuthal type: orthographic 90°, stereographic 142°, gnomonic 60°, azimuthal equal-area 180°, azimuthal equidistant 180°. `clip_angle(proj, nil)` disables clipping.

**Consequences.** Azimuthal projections behave as in `d3-geo`. Callers that projected points beyond the horizon now receive `nil` and MUST handle it, which `Visualize.Geo.Path` does by dropping the point. The projection golden was regenerated for the affected cells.

## 4. D-4 — The many-body force is exact and has no `:theta`

**Context.** `Visualize.Layout.Force.Forces.apply_many_body/3` accepted a `:theta` option in the manner of `d3-force`'s Barnes–Hut approximation, but there is no quadtree in the library and the option was ignored. An accepted, ignored option is a lie in the API.

**Decision.** The many-body force evaluates every pair directly, O(n²) per tick, and the `:theta` option is removed rather than left inert. Passing it is a programmer error.

**Consequences.** The force is exact and simple, and documented as such. Graphs beyond a few thousand nodes are slow per tick; a Barnes–Hut implementation, if ever wanted, arrives as a new decision with a quadtree, at which point `:theta` returns with meaning.

## 5. D-5 — Transverse Mercator inverse: contract is `nil`, code raises

**Context.** `Visualize.Geo.Projection.invert/3` for `:transverse_mercator` raises `ArithmeticError` for points away from the central meridian, where the inverse series leaves its domain. This predates the projection split and is tracked as issue #9.

**Decision.** The contract is that `invert/3` returns `nil` outside the valid domain, as every other projection does ([11-public-api](11-public-api.md) §4). Implemented under D-51, which found that no point of the plane is outside the inverse's domain. Until it is, the projection golden records the transverse Mercator inverse as `excluded` so the golden neither crashes nor freezes the wrong behaviour.

**Consequences.** Callers inverting transverse Mercator points MUST expect the exception until #9 closes. When it does, the golden is regenerated to record the inverse and the `@no_inverse` exclusion is removed; this entry then stands as the record of the gap.

## 6. D-6 — The projection golden folds negative zero

**Context.** Moving projection maths verbatim into family modules (D-9) changed the sign of zero on 259 golden cells with no numeric change: whether `0.0` or `-0.0` emerges from an expression depends on whether the compiler could see the operand's type, which changed when the functions moved across a module boundary.

**Decision.** The golden's formatter adds `0.0` to every value before printing, which folds `-0.0` into `0.0`. The sign of zero is not a property of the maths and is not part of the contract.

**Consequences.** Refactors that move maths without changing it leave the golden untouched. Residuals that round to zero at nine decimals but are genuinely negative still print with a sign; that is real and stable. Nothing in the library treats `-0.0` as distinct from `0.0`.

## 7. D-7 — Components compile only when `Phoenix.Component` is loaded

**Context.** Chart components and the `Phoenix.HTML.Safe` implementations need Phoenix, but the library is useful without it and must not force the dependency on a script, a report generator or a test.

**Decision.** `Visualize.Components` and `Visualize.Components.Tree` are wrapped in `if Code.ensure_loaded?(Phoenix.Component)`; the `Phoenix.HTML.Safe` implementations for `Visualize.IR.Element` and `Visualize.SVG.Element` in `if Code.ensure_loaded?(Phoenix.HTML.Safe)`. Phoenix is not listed in `deps`.

**Consequences.** A consumer with Phoenix gets the components with no configuration; one without gets everything else. The guard is a compile-time check of the consumer's build, so a consumer that adds Phoenix later MUST recompile `visualize` (`mix deps.compile visualize --force`). CI proves the no-Phoenix build with a consumer compile ([12-testing-and-conformance](12-testing-and-conformance.md) §4).

## 8. D-8 — Step curves emit D3-identical paths

**Context.** `:step_before` and `:step_after` emitted a zero-length segment at the first point and consecutive horizontal commands, so the path was longer than D3's and differed textually while drawing the same picture — which made comparison against D3 output impossible.

**Decision.** For points `p0 … pn`, `:step` emits `M x0,y0` then for each following point `H mid V y H x`; `:step_before` emits `V y H x` per point; `:step_after` emits `H x V y` per point. No zero-length command and no two consecutive `H` commands are emitted. The reference strings are in `test/visualize/defects_test.exs`.

**Consequences.** Paths match `d3-shape`'s `curveStep*` byte for byte for the same input, so D3 fixtures can serve as goldens. Existing rendered output changes textually but not visually.

## 9. D-9 — Family modules are `@doc false` internals

**Context.** `Visualize.Geo.Projection` and `Visualize.Backend.CanvasBinary` each exceeded the project's 1,000-line file limit. Splitting them was necessary, but exposing the pieces would have widened the public API with modules that only make sense through their parent.

**Decision.** `Visualize.Geo.Projection.{Cylindrical, Azimuthal, Conic, Pseudocylindrical, Compromise}` and `Visualize.Backend.CanvasBinary.{Opcodes, PathEncoder, SVG}` hold the maths and encoders, reached only via the parent's public functions. Their functions are `@doc false`. The maths was moved verbatim, which is what made the projection golden (§1 of [12-testing-and-conformance](12-testing-and-conformance.md)) a valid proof of output identity.

**Consequences.** The public surface did not grow. The family modules MAY be reorganised freely. `Opcodes` is public because the JavaScript decoder is written against the same byte values, and a reader of the hook needs the table.

## 10. D-10 — The specification is generated from the code as found

**Context.** The specification was written after most of the code. Writing it as the code *should* be would have produced a document that disagrees with `lib/` on day one; writing it as the code *is* would have enshrined defects.

**Decision.** Each document describes the behaviour the code actually has, except where code and intent differ: there the document states the intent and a decision in this log records the gap (D-5, D-11, D-12). `API_SURFACE.md` measures the surface; the decision log measures the behaviour gaps.

**Consequences.** Every `Implemented` document is falsifiable against `lib/`, and every known divergence is enumerated. Closing a gap is a code change plus a note on the decision; opening one is a new decision, never a silent edit.

## 11. D-11 — `SVG.Element.from_ir/1` renders `view_box` as `viewBox`

**Context.** `Visualize.IR.Element.root/3` stores the view box under the attr key `view_box`. `Visualize.Backend.SVG` maps it to `viewBox` when rendering a `:root`, but `Visualize.SVG.Element.from_ir/1` copies attrs verbatim and `Visualize.SVG.Renderer` converts every underscore to a hyphen, emitting `view-box="…"`, which is not an SVG attribute. The same IR root therefore renders correctly through one path and incorrectly through the other.

**Decision.** `from_ir/1` MUST map a `:root` element's `view_box` attr to the `viewBox` key. Attribute names that are camel-cased in SVG (`viewBox`, `preserveAspectRatio`, `gradientUnits`, `patternUnits`, `clipPathUnits`, `markerWidth`, `markerHeight`, `refX`, `refY`) MUST NOT be hyphenated by the renderer; the underscore-to-hyphen rule applies to presentation attributes only.

**Consequences.** Both rendering paths agree. This is not yet implemented; a `:root` converted with `from_ir/1` currently emits `view-box`, and browsers fall back to the `width`/`height` attrs so the defect is visible only when the view box differs from the size.

## 12. D-12 — One `d` serialisation

**Context.** `Visualize.IR.Path.to_string/1` formats floats with `:erlang.float_to_binary/2` at four decimals; `Visualize.Backend.SVG.path_data/1` uses `Float.round/2` then `Float.to_string/1`, which emits exponent notation for magnitudes below `1.0e-4` or at or above `1.0e16`, after which trailing-zero trimming can corrupt the mantissa (`1.0e20` → `"1.0e2"`). `Visualize.SVG.Path.to_string/1` and the `String.Chars` implementation go through the backend, so the same path can serialise two ways.

**Decision.** The `d` string for a path is defined by `Visualize.IR.Path.to_string/1`: integers verbatim, floats with at most four decimals, trailing zeros and point trimmed, never exponent notation. `Visualize.Backend.SVG.path_data/1` MUST produce the identical string, by delegating to it or by using the same formatter.

**Consequences.** One formatter to test; goldens compare across backends. Not yet implemented: today the two agree for coordinates in the ordinary pixel range and differ only at extreme magnitudes.

## 13. D-13 — Defects found while writing the specification are tracked as proposals, not normalised into it

**Context.** Generating this specification from the code surfaced roughly sixty behaviours where the code does not do what its own documentation, D3's reference behaviour, or basic correctness requires: a scale protocol five modules do not implement (`Scale.apply/2` raises on power, symlog, quantile and quantize scales), a component layer that has never rendered, a Delaunay triangulation that is not Delaunay, a tidy tree that overlaps cousins, and a binary path encoder that silently drops commands. Each document marks such a spot with `> Decision: see spec/13` and states the intended behaviour.

**Decision.** The specification states intent. Every marker is owned by one of these proposals, which carry the reproduction, the root cause and the recurrence guard: #13 (scales and `Data.ticks/3`), #14 (shapes, axes, formatting), #15 (`Components` and the brush hook), #16 (layouts, geo, random), #17 (SVG bridge, binary and canvas backends, benchmark), plus #9 (transverse Mercator inverse) and #5 (force-simulation timers, verified by the ExTLA model). A document's status stays `Implemented` when the divergence is a defect against a settled contract; it would become `Agreed` only if the contract itself were still open.

**Consequences.** Closing a proposal above MUST replace the corresponding markers with the implemented contract and, where the resolution changed the intended behaviour, add a D-entry here. Until then `API_SURFACE.md` reports the functions as `specified`: the surface gate checks that the declared functions exist, not that they are correct — correctness is what the tests those proposals add are for.

## 14. D-14 — Force-simulation timer discipline: tagged ticks, restart keeps the state, alpha is clamped

**Context.** Review found two timer defects in `Visualize.Layout.Force.Simulation` (an unguarded deferred start; a `:restart` that started a stopped simulation) and the ExTLA model (spec/12 §5) found a third: a tick from a timer cancelled after its message had reached the mailbox was indistinguishable from a live tick, so `stop/1` then `start/1` could leave two tick chains running for ever. `set_alpha/2` and `set_alpha_target/2` accepted any number.

**Decision.** Tick messages carry the reference generated when the timer was armed, the server keeps the current one, and a tick with any other tag is ignored. `:start_simulation` and `:start` share one idempotent `arm`. `restart/1` resets `alpha` to 1.0 and does nothing else: a running simulation keeps its chain, a stopped one stays stopped (a client that wants both calls `restart/1` then `start/1`). `set_alpha/2` and `set_alpha_target/2` clamp into `[0, 1]` rather than raise, because a value slightly outside the range from an interpolation is a rounding artefact, not a caller error.

**Consequences.** The three model invariants hold on the code, and the conformance test drives the callbacks through the scripts the model's counterexamples produced. `restart/1` no longer starts anything, which is a behaviour change for any caller that relied on the defect; none exists in this repository or in `examples/`.

## 15. D-15 — The scale protocol is a behaviour, and every scale stores lists

**Context.** `Visualize.Scale` dispatches nine operations to `scale.__struct__`, but nothing declared what a scale module had to define. Five modules (`Power`, `Symlog`, `Quantile`, `Quantize`, `Threshold`) were written against a different interface — `scale/2` for the mapping, no `nice/1`, `padding/2`, `bandwidth/1`, and for the three discretising scales no `invert/2`, `ticks/2`, `clamp/2` — so `Scale.apply/2` raised `UndefinedFunctionError` on any of them and `Axis` could not draw a power, sqrt or symlog axis. Three of them stored domain and range as tuples, which `Axis` read as "no range" and replaced with `[0, 1]`. `Power` applied `|x|^e` to the bounds and only negated the value, collapsing any domain with a negative bound. No test exercised the facade polymorphically, which is why none of this was seen (#13).

**Decision.** The protocol is the behaviour `Visualize.Scale.Behaviour` with the nine callbacks of spec/03 §1.3; every scale module declares it and implements every callback with explicit clauses (no `__using__` defaults, so each module's surface stays readable and scannable). Operations without meaning for a kind are identity, `nil`, or `0`; discretising scales tick at their thresholds. `scale/2` remains on the five modules as a documented alias of `apply/2` for one release. Domains and ranges are two-element lists in every struct; tuples are still accepted on input. `Power` uses D3's signed transform on bounds and value alike. The facade gains `threshold/0`. `test/visualize/scale/protocol_test.exs` constructs every kind through the facade and calls all nine operations, and renders an axis on every continuous scale.

**Consequences.** A scale module that omits an operation no longer compiles under `--warnings-as-errors`, so the facade is total by construction. Callers that pattern-matched the tuple form of `Power`/`Symlog`/`Quantize` domains break; none exists in this repository or in `examples/`. `Quantile.apply/2` also now reaches its last bucket (the bucket search's base case returned index `0` instead of the index it had reached), which the same test covers. The remaining scale defects of #13 landed as D-16 (time ticks) and D-17 (band, tick and colour defects).

## 16. D-16 — Time ticks follow d3-time: boundary snapping and ratio-based interval choice

**Context.** `Visualize.Scale.Time.ticks/2` chose the first interval whose length was at least `span / count`, snapped only sub-day intervals, treated the day "ceiling" as the value's own midnight (so the first tick could precede the domain), never snapped weeks, months or years, rewrote month fields without clamping the day (31 January + 1 month was an invalid 31 February), and for any span wider than `count` years fell back to daily ticks — 8,767 of them over 24 years. Every metresis time-series panel draws a time axis, so this was the milestone's first defect (#13, #23).

**Decision.** Ticks follow `d3.timeTicks`: an interval `{unit, n}` from the table in spec/03 §7.3.1, chosen as the neighbour closer in ratio to `span / count` (not the first one at least as long — at the week→month and quarter→year seams the old rule overshot by up to 4×), and beyond one year a multi-year step from the 1-2-5 nice-step sequence. Every tick is a boundary of the interval inside `[d0, d1]`: multiples of the step below a day, midnight, Monday, the 1st of the month, 1 January of a year divisible by the step. Weeks start on Monday (ISO), where d3 defaults to Sunday. `nice/1` uses the same floor and ceiling. `Visualize.Scale.Time.interval/2` exposes the choice so callers can format ticks by the interval that produced them.

**Consequences.** Tick counts stay within about `2.2×` of the hint in either direction and a long span costs tens of ticks, not thousands. The month-stepping helper clamps the day, though ticks only ever land on the 1st. The property tests in `test/visualize/scale/time_test.exs` assert ascending, in-domain, boundary-aligned ticks over random domains from a minute to two centuries. Callers that relied on the first tick preceding the domain, or on `count` giving strictly at-least-that-long intervals, see different ticks; none exists in this repository or in `examples/`.

## 17. D-17 — Band, tick and colour defects resolve to d3-scale's behaviour

**Context.** The last group of #13: `Band` gave negative `bandwidth` and `step` on a descending range and left `step/1` unrounded; `Quantize.thresholds/1` returned two values for a one-element range (`1..0` descends); `Color` silently substituted `:blues` for an unknown scheme and, unclamped, extrapolated channels past `0…255` into malformed hex; `Ordinal.apply/2` raised on an empty range; `Linear.ticks/2` and `nice/1` raised on a degenerate domain; `Log.ticks/2` ignored its count; `Symlog.ticks/2` ignored its constant; and `Data.ticks/3` raised on every call because `max/2` resolved to `Visualize.Data.max/2` under the module's `import Kernel, except:`. Writing the property tests for the fixes surfaced three more of the same `0..-1` class: `Linear.ticks/2` and `Data.ticks/3` emitted two ticks *outside* a domain that contained no multiple of the step, and `Symlog.ticks/2` emitted decades below a positive lower bound.

**Decision.** Each resolves to the d3-scale reference. `Band` is d3's `rescale`: a descending range is laid out in reverse with positive step and bandwidth; `round/2` floors the step and rounds the start and bandwidth from it. `Log.ticks/2` is d3's log ticks — every `k · base^e` inside the domain when the decades fit the count, thinned powers when they do not, linear ticks when the multiples are too few — with exact logarithms for bases 10 and 2 so `1e12` is decade 12, not 11.999…. `Symlog` decades are `±c · 10^k`, since the log region begins at the constant. An unknown colour scheme raises `ArgumentError` naming `schemes/0`; RGB channels clamp to a byte. `Ordinal` on an empty range returns `unknown`; `Linear` on a degenerate domain returns `[d0]` and leaves `nice/1` alone. Every tick loop steps `0..(n − 1)//1`, so an empty tick set is `[]`. `Data.ticks/3` calls `Kernel.max/2` explicitly and handles zero and descending spans.

**Consequences.** `test/visualize/scale/{band,color,ticks}_test.exs` and `test/visualize/data_test.exs` carry one regression per defect and properties (StreamData) for band arithmetic under either range direction and for tick lists being ascending, distinct and inside the domain — which is what found the three extra defects. `Log.ticks/2` returns different (and more) values than before for every domain, and `Symlog.ticks/2` differs whenever the constant is not 1; nothing in this repository or `examples/` depended on the old lists. The `import Kernel, except: [min: 2, max: 2]` in `Visualize.Data` remains a hazard for any future arithmetic in that module; every `min`/`max` there now names `Kernel` explicitly, and the regression test fails the moment one does not.

## 18. D-18 — `defined/2` breaks the path into subpaths

**Context.** `Line.defined/2` and `Area.defined/2` filtered undefined data out and joined what remained into one continuous path, so a gap in a time series drew as a line across it — the opposite of what the moduledoc promised and of what a dashboard panel needs to show missing data (#14). Nothing tested the path.

**Decision.** d3-shape's semantics: the data are split into runs of consecutive defined data, each run is generated through the configured curve as its own subpath (each with its own `M`; for `Area`, its own closed ring), and the subpaths are concatenated in data order. A run of fewer than two points contributes nothing — it has no segment — so a lone defined point between gaps is invisible and a data list with no run of two points yields an empty path. This keeps the existing "fewer than two points is empty" contract of spec/04 §1.3 rather than adopting d3's `M x,y Z` for a lone point, which fills nothing and strokes nothing but is not empty. The run splitting is one `@doc false` helper on `Shape.Line` that `Area` shares.

**Consequences.** A gap is visible. Rendered output changes for any caller that used `defined` to filter and relied on the points either side being joined; none exists in this repository or in `examples/`. The reference strings are in `test/visualize/shape/line_area_test.exs`.

## 19. D-19 — `Curve.natural/1` is the natural cubic spline

**Context.** The natural spline placed both control points of every segment one third of the way along the chord, which is a straight line written as a cubic Bézier: `:natural` rendered identically to `:linear` (#14). The tridiagonal solve the moduledoc and the spec announced had never been written.

**Decision.** A port of d3-shape's `controlPoints`: per axis, the first control coordinates solve the open natural-spline tridiagonal system (spec/04 §5.6) by the Thomas algorithm and the second control coordinates follow from them; x and y are solved independently, as in d3. The two-point fallback to `:linear` stays.

**Consequences.** `:natural` paths change for every input of three or more points. The three-point example in `test/visualize/shape/line_area_test.exs` is hand-derived from the system and matches d3; `test/support/curve_golden.txt` records every curve type over one fixed point set (the projection-golden pattern), so any later change to curve maths is a visible diff rather than a silent drift.

## 20. D-20 — `Line` accepts constant accessors; `LineNx` honours `:curve`

**Context.** `Line.x/2` and `Line.y/2` matched only a function or an atom while `Area` and `Arc` also took a number, so the same constant baseline needed a lambda on one generator and not another. `LineNx.generate_path/3` documented a `:curve` option it never read (#14).

**Decision.** `Line` normalises a number to `fn _ -> n end`, exactly as `Area` does. `LineNx.generate_path/3` reads `:curve` (default `:linear`) and `:curve_opts` (default `[]`); `:linear` keeps the direct `M`/`L` build and every other curve passes the scaled points through `Visualize.Shape.Curve.generate/3`. Implementing the option was chosen over deleting the documentation because the point of `LineNx` is the vectorised scaling, which a curve does not disturb.

**Consequences.** Default output of both is unchanged. A `LineNx` call with a curve pays the curve's per-point cost after the batch scale, which is the same cost `Line` pays.

## 21. D-21 — `Arc` is a port of d3-shape's arc: padding and corner radius are applied

**Context.** `Arc` stored `corner_radius` and `pad_angle` and rendered as if both were zero; `pad_radius` had no setter; the facade lacked `corner_radius/2`, `sort/2` and `sort_values/2` (#14). A pie with padded, rounded slices — the standard donut on a dashboard — could not be drawn.

**Decision.** `generate_path/2` is d3-shape's `arc` sector branch, ported line for line: the per-ring padding shift `asin(rp / r · sin(ap))` with collapse to the mid-angle, the corner radius limited to half the ring thickness and (for sectors under a half-turn) by the sector's own edge geometry, `cornerTangents` for the corner circles, and a small port of d3-path's `arc` so the emitted `A` commands — including the implicit `L` to an arc's start — are byte-faithful to d3. Two departures: an arc with no outer radius or no angular extent is an empty path rather than d3's degenerate `M … Z` (there is nothing to draw, and spec/04 §6.2 already said so), and d3's NaN-driven collapse of a ring whose pad shift has no arcsine is written as an explicit branch. With the port come two behaviours the old generator lacked: the radii swap when `r1 < r0`, and the full ring closes with `Z`. `pad_radius/2` sets the pad radius (`nil` restores the default). The facade gains `corner_radius/2`, `pad_radius/2`, `sort/2` and `sort_values/2`.

**Consequences.** Every arc with a non-zero `corner_radius` or `pad_angle` renders differently — the shape callers asked for. Plain sectors are byte-identical to before. The reference strings and the tangent-geometry checks are in `test/visualize/shape/arc_stack_test.exs`; the corner path was checked against the geometry by hand (the corner centre lies `r1 − rc` from the origin, its tangent points on the edge and the ring), not against a d3 run, since no JavaScript runtime is available here.

## 22. D-22 — `Stack` series are in key order; `:diverging` and `:wiggle` are d3's

**Superseded in part by D-125 (#496):** `:insideout` is d3's `stackOrderInsideOut`, no longer a sum-descending interleave. The entry stays as the record of the rest.

**Context.** `Stack.generate/2` returned series in stacking order, so a caller colouring by key had to search for each key after any `order` but `:none`; `:diverging` neither accumulated negatives nor separated them from positives; `:wiggle` was `:silhouette` under another name (#14).

**Decision.** As d3-stack: series are returned in key order and each carries `index`, its position in the stacking order. The offsets take that order as a permutation. `:diverging` and `:wiggle` are direct ports of `stackOffsetDiverging` and `stackOffsetWiggle`; `:none`, `:expand` and `:silhouette` keep their values, expressed in the same shape (a baseline on the bottom series, then stacking). `:expand` keeps the existing "all zero when the column total is zero" rule where d3 would leave a mixed-sign column that sums to zero unnormalised. `:insideout` keeps its sum-descending interleave; d3 orders by peak position (`orderAppearance`), which is out of #14's scope and unchanged.

**Consequences.** The series list is a different permutation than before for any `order` other than `:none`; a caller that indexed the list by stacking position must use `index` instead — none exists in this repository or in `examples/`. Every point map gains nothing; every series map gains `index`. The d3 algorithms were hand-run for the reference layouts in `test/visualize/shape/arc_stack_test.exs`.

## 23. D-23 — Axis labels sit away from the plot; band ticks are centred

**Context.** `Axis` computed the `:top` label's `y` as `−k·i − p`, which with `k = −1` is `i − p = +3`: the label sat below the axis line, inside the plot, overlapping the marks. On a `Band` scale every tick sat at the band's start edge, so a bar chart's labels hung off the left of each bar (#14). Every metresis panel with a top axis or a categorical axis showed both.

**Decision.** d3-axis's behaviour: the label offset is `k·(tick_size_inner + tick_padding)` for `:top` (the other three orientations were already right), and the tick position is `apply + offset + max(0, bandwidth − 2·offset) / 2`, rounded when the band scale rounds — d3's `center`, which keeps `offset` meaning "pixel offset" while still landing the tick in the middle of the band. The bandwidth comes from the scale protocol, so no orientation or scale type is special-cased.

**Consequences.** Every `:top` axis moves its labels by `2·(i + p)`; every band axis moves its ticks by half a band. Callers that had added `bandwidth / 2` through `offset/2` to centre ticks by hand now get `offset + max(0, bandwidth − 2·offset) / 2` — for the usual `offset = bandwidth / 2` that is `bandwidth / 2` again, so they are not double-shifted, but they should drop the workaround. `test/support/axis_golden.txt` records the rendered SVG of every orientation over a linear, a band and a rounded band scale.

## 24. D-24 — `Format.number/2` groups the magnitude; `formatter/1` is d3-format's parser

**Context.** `number/2` grouped the signed digit string, so `−123456` came out as `"-,123,456"` and `−0.5` lost its sign entirely. `formatter/1` classified a specifier by substring in the order `s, %, e, f, d`, so `"e"` anywhere selected exponential and no fill, alignment, sign, width, zero padding, `~` or the `g`/`r`/`b`/`o`/`x`/`X`/`c` types existed (#14).

**Decision.** `number/2` formats `abs(value)` and prepends `-` afterwards, hiding it when the magnitude rounds to all zeros. `formatter/1` is a port of d3-format: the specifier regular expression, the `n` and no-type aliases, precision clamping, the type formatters (`toFixed`, `toExponential` and `toPrecision` reproduced over Erlang's `float_to_binary/2`, d3's `formatRounded` and `formatPrefixAuto` for `r`/`p`/`s`), `formatTrim`, the sign conventions, the `$`/`#` affixes, grouping before or after zero padding with d3's width limit, and the four alignments. One locale departure: the minus is ASCII `-` rather than d3's U+2212, because every other formatter in the module and every axis label already uses `-`. `exponential/2` keeps Erlang's exponent form (`1.23e3`) since spec/05 §2.4 documents it; the parser's `e` type produces JavaScript's (`1.23e+3`).

**Consequences.** Outputs of `formatter/1` change wherever the old classifier was wrong (`"e"` in a specifier that meant fixed, `".2e"` now `1.23e+3`, `"d"` on a float now rounds like d3). The table in `test/visualize/axis_format_test.exs` carries d3-format's documented examples and edge cases (`~`, `(`, `^`, `0` with `,`, `#x`, sub-yocto `s`); an invalid specifier raises `ArgumentError` instead of silently formatting with `to_string/1`.

## 25. D-25 — `Format.time/2` is a one-pass strftime tokenizer

**Context.** `time/2` applied eight `String.replace/3` calls in sequence, so a directive's output was re-scanned by the next replacement, there was no `%%`, and `%y`, `%j`, `%a`, `%I`, `%p`, `%Z` and the rest of strftime were unsupported (#14).

**Decision.** The format is tokenised once — `%` plus one character is a directive, everything else literal — and each directive is looked up against a fixed table: the original eight plus `%y %e %j %I %L %p %a %A %U %w %Z %z` and `%%`. A trailing lone `%` and an unknown directive pass through verbatim, as d3-time-format leaves an unknown directive in place. `%Z` and `%z` read a `DateTime`'s zone fields and are empty for `Date` and `NaiveDateTime`; no zone conversion is performed. Weeks under `%U` start on Sunday, as in C and d3, which differs from the Monday-based time ticks of D-16 because `%U` is a display convention, not a tick interval.

**Consequences.** Existing formats produce the same output unless they contained `%%` or a directive whose output contained `%`-sequences. Consumers can format any label d3-time-format's common subset can; `%f`, `%q`, `%V`, `%G` and the locale-dependent `%c`/`%x`/`%X` are not provided and pass through, which is visible rather than silent.

## 26. D-26 — `Band` is an element mark: one `:rect` per datum, index-aligned

**Context.** A state timeline — *which state was this in, and for how long* — colours every block by its datum. The path generators return one `d` string, which carries no per-datum style, and `Visualize.Shape.Area` with a step curve cannot change fill mid-path; metresis (#1) had no primitive to build from.

**Decision.** `Visualize.Shape.Band` returns a list of `Visualize.IR.Element` rectangles from `generate/2`, one per datum in data order, with the fill accessor's value in each element's style. It reuses the `Area` accessor vocabulary (`x0`, `x1`, `y`) and adds `height` and `fill`, all in pixels: the caller applies the x scale in the accessors exactly as for `Line` and `Area`, so a band and the line above it share one scale pipeline. `generate_path/2` is kept for the uniformly filled case and folds every rectangle into one path.

**Consequences.** `Visualize.Shape.generate/2` is no longer path-or-data: for `Band` it returns elements, which render through `Visualize.Render` like any scene-graph node. A zero-width datum is still emitted, so the element list can be zipped with the data. The rectangles are separate DOM nodes; a timeline of thousands of states meets the SVG ceiling (spec/00) as any other mark does.

## 27. D-27 — Annotations take domain values and a scale

**Context.** Vertical rules and shaded x-bands were hand-rolled per chart as raw SVG, outside the scale and margin system, and misaligned the moment a margin changed (#1).

**Decision.** `Visualize.Shape.Rule` and `Visualize.Shape.XBand` are generators over a list of annotations whose x accessors yield *domain* values, mapped through the scale given to `scale/2`; with no scale the values are pixels, so they compose with a caller's own mapping. `Rule.new/0` reads each datum as its own value, so a list of instants is valid data. Each annotation renders as a `:group` element (a line or rect, plus a `:text` label when set) styled with `:current_color` and a `class`, so CSS can restyle it. `XBand`'s default fill is the brush selection's, so a live selection and a recorded window read alike. Neither has `generate_path/2`: a label is not a path.

**Consequences.** A rule follows the axis it annotates whatever the margins, because both read the same scale. The label position is fixed (right of the upper end, 10 px in); a caller wanting another placement moves or replaces the text child.

## 28. D-28 — `PercentileBand` is a composition with a fixed output shape

**Context.** A median line with p25–p75 and p5–p95 bands around it is composable from `Visualize.Shape.Area` and `Visualize.Shape.Line` today, and was verified to be; but the composition is fiddly enough that every consumer would do it slightly differently — different band order, different handling of gaps, curves applied to the line and not the bands (#1).

**Decision.** `Visualize.Shape.PercentileBand` holds one `x`, one `median`, and two `{lo, hi}` pairs, and `generate_path/2` returns exactly `%{outer:, inner:, median:}` built by delegating to `Area` and `Line` with the same `defined`, `curve`, and `curve_opts`. It adds no geometry of its own: each path is byte-identical to the standalone generator's, which is the whole guarantee. An unset band or median is `nil` in the map rather than an empty path, so a caller can tell "not asked for" from "no defined run".

**Consequences.** `Visualize.Shape.generate/2` returns a map for this generator. Consumers get one place to change the band convention. Styling stays with the caller, as for every path generator.

## 29. D-29 — Brush helpers work on the axes they are given

**Context.** `Visualize.Hooks.Brush.selection_to_domain/2` and `filter_selection/3` fetched both scales with `Keyword.fetch!/2`, so an `"x"` time brush — the common dashboard gesture — could not be converted without inventing a y scale (`KeyError :y_scale`), and the filter tested a `y0 = y1` band the hook had not meant (#1, acceptance note).

**Decision.** Each helper reads the scales it is given. With one scale, `selection_to_domain/2` returns a `{start, stop}` pair in domain units, in pixel order as d3's `selection.map(scale.invert)` would give; with both, the four-key map as before. `filter_selection/3` tests only the axes whose scale is present. Neither scale is an `ArgumentError`: a brush with no axis is a programming error, not an expected condition (spec/11 §5).

**Consequences.** Existing two-scale callers are unchanged. One-axis callers get a tuple, not a map, so the return type follows the arguments; the spec states both shapes. The y-only pair is in pixel order, which under a flipped range is descending in the domain — documented rather than sorted, since sorting domain values of arbitrary type would need a comparator the helper does not have.

## 30. D-30 — `Scale.Radial` ticks divide the turn; `nice/1` is identity

**Context.** `Visualize.Scale` was entirely cartesian; a cyclic quantity — bearing, hour of day, day of year — needs a scale whose range is an angle (#1). Reusing `Linear` with a range of `[0, 2π]` gets the mapping but the wrong ticks: the nice-step sequence gives `0, 100, 200, 300` for a bearing domain, and `nice/1` would extend `[0, 360]` to `[0, 400]`, breaking the cycle.

**Decision.** `Visualize.Scale.Radial` interpolates exactly as `Linear` (the same struct shape, range in radians, default one full turn) but `ticks/2` returns `count` equal divisions of the domain — the count exact, the last division dropped when the range is a full turn since it coincides with the first — and `nice/1` is identity. It implements `Visualize.Scale.Behaviour` in full, so the facade and the protocol test cover it; angles follow `Arc`'s clockwise-from-12 convention so a bearing domain needs no offset.

**Consequences.** `ticks/2` is the one protocol operation whose count is a guarantee rather than a hint; the spec says so. A caller who wants nice-step ticks on an angular axis uses `Linear` with the same range. Values outside the domain extrapolate past a full turn unless clamped; wrapping a cyclic value modulo its period is the caller's job, as with any scale.

## 31. D-31 — `Rose` is `Arc` over a radial scale

**Context.** With `Scale.Radial` in place the missing piece for a wind rose was small: a mark that centres one `Arc` sector on each datum's angle and lengthens it by a value (#1). `Shape.Arc` already does the angular geometry, padding included.

**Decision.** `Visualize.Shape.Rose` holds an `angle` and a `width` accessor in the scale's domain units, maps `angle ± width / 2` through `scale/2`, and delegates each datum to `Arc.generate_path/2` with the radius and pad-angle accessors. It adds no geometry of its own — every sector is byte-identical to the `Arc` call with the same numbers — and returns a list of paths, one per datum, so a stacked rose is several roses with rising inner radii, drawn by the caller. No fill accessor: as with `Arc`, styling is the caller's.

**Consequences.** A sector straddling the domain's origin (bearing 0, width 45) spans negative radians, which `Arc` draws correctly since it never reduces its angles modulo a turn. A datum with a zero outer radius is an empty path, kept in the list so it stays index-aligned with the data.

## 32. D-32 — The `d` serialisers fold negative zero

**Context.** `Visualize.Backend.SVG.path_data/1`, `Visualize.Backend.Canvas` and `Visualize.IR.Path.to_string/1` printed a float that is `-0.0`, or rounds to it at four decimals, as `-0`: an `Arc` sector whose inner edge is the origin ended `L0,-0Z`. Valid SVG, but a byte difference in every path golden for a value whose sign is not a property of the maths; D-6 had already settled the same question for the projection golden, and the rose goldens were folding `-0` in the test instead.

**Decision.** Every `d` serialiser adds `0.0` after rounding, so `-0.0` and anything that rounds to it print as `0`. The contract in [02-architecture](02-architecture.md) §3 gains the clause; the goldens assert raw strings.

**Consequences.** No `-0` coordinate is emitted by any backend. A caller that read the sign of a zero coordinate out of a path string never had a contract to rely on.

## 33. D-33 — `Curve.basis/1` is d3's `curveBasis`

**Context.** The original `basis/1` emitted one `C` per sliding window of four points and a closing `C` from the penultimate data point, so an `n`-point spline had `n − 3 + 1` segments instead of `n − 1`, skipped its last two knots, and kinked into the endpoint. The per-curve golden written for #25 made it visible (#28).

**Decision.** `basis/1` is the d3-shape `curveBasis` algorithm exactly, as written in [04-shapes-and-curves](04-shapes-and-curves.md) §5.3: leading `L` to `(5p0 + p1)/6`, a `C` per point from the third on, a closing `C` and a trailing `L` to the last point. Fewer than three points remain `:linear`.

**Consequences.** Every basis path changes shape (the old one was wrong, not merely different); only the `basis` line of `test/support/curve_golden.txt` moved. A property test holds the segment count and continuity for any point set.

## 34. D-34 — One SVG serialiser behind both bridges

**Context.** The same IR root rendered correctly through `Visualize.Backend.SVG` and incorrectly through `Visualize.SVG.Element.from_ir/1`: the backend wrote `viewBox` while the bridge copied `view_box` and `Visualize.SVG.Renderer` hyphenated it to `view-box`, and the bridge's root carried no `xmlns` (D-11). Path data and transforms each had two serialisers (`Backend.SVG.path_data/1` with `Float.to_string/1`'s exponent forms, D-12; a private transform printer that never used the single-argument `scale`), and a `points` list reached the bridge unformatted (#17).

**Decision.** `Visualize.SVG.Renderer.attribute_name/1` is the one attribute-name map: hyphenate, except the camel-cased SVG names (`viewBox`, `preserveAspectRatio`, `gradientUnits`, `clipPathUnits`, …), which is what keeps `clip_path` a presentation attribute and `clip_path_units` a camel-cased one. `Visualize.Backend.SVG` renders tags, attributes (nil-dropped, sorted by SVG name) and text through the renderer's functions; a root passes its attrs through, so `view_box` reaches the map; `from_ir/1` adds `xmlns` to a root, renames `view_box` to `viewBox` and formats a points list. `Backend.SVG.path_data/1`, `Visualize.SVG.Path.to_string/1` and `String.Chars` delegate to `Visualize.IR.Path.to_string/1`; `Backend.SVG` serialises transforms with `Visualize.IR.Transform.to_string/1`; `Visualize.IR.Path.format_number/1` is the shared number formatter, which also gives transforms the negative-zero fold of D-32. `stop` self-closes in both.

**Consequences.** `test/visualize/svg/bridge_test.exs` renders one scene of every element type through both paths and asserts byte equality, so a divergence is a red test rather than a browser-only symptom. A `:rotate` or `:scale` transform on an IR element now prints as `Visualize.IR.Transform.to_string/1` prints it (`scale(2)` for equal factors), and an arc's rotation prints formatted (`0` rather than `0.0`); nothing in this repository or in `examples/` compared those strings.

## 35. D-35 — `IR.Path.transform/2` takes an affine matrix

**Context.** `transform/2` applied an arbitrary function to every coordinate pair, including relative deltas and the `{x, 0}` / `{0, y}` stand-ins for `H`/`V`, so any function with a translation moved relative segments and single-axis lines by the wrong amount, and the contract's "MUST be linear" clause could not be checked (#17).

**Decision.** The argument is an affine matrix `{a, b, c, d, e, f}` or a `Visualize.IR.Transform`, folded by the new `Visualize.IR.Transform.to_matrix/1` in SVG composition order. Absolute points get the full map, deltas the linear part; the current point is tracked so `H`/`V` are exact — they stay single-axis when the transform preserves that axis and become `L` otherwise — and an arc's ellipse is mapped exactly through the closed-form SVD of the 2×2 product, in canonical form (`rx ≥ ry`, rotation in `[0, 180)`), the sweep flipping under a reflection. A function argument no longer matches: it was never sound for paths with relative commands, and no caller in this repository or in `examples/` passed one.

**Consequences.** The same matrix serves the binary encoder's transform fold (spec/09 §4.3.6). Integer inputs stay integers under an integer matrix; a `Transform` yields floats. `test/visualize/ir/path_transform_test.exs` checks translation invariance of deltas, `H`/`V` under rotation and scale, the arc image, and holds the identity and translate-and-back properties over random paths.

## 36. D-36 — The binary path stream is lossless and round-trip tested

**Context.** `Visualize.Backend.CanvasBinary` chose the compact `path` record whenever a path had no `C`, `Q` or `A`, collecting only `M`/`L` end points — so `Z`, `H`/`V`, every relative command and every second subpath vanished from the stream, which is exactly what a step curve or a multi-segment `defined` line produces. In the full stream, `S`/`s`/`T`/`t` encoded to zero bytes while still being counted, desynchronising the decoder. The transform fold pre-multiplied some operations and post-multiplied others and ignored four of the seven; `rgba(0,0,0,1)` raised in `String.to_float/1`; named colours were black; and the moduledoc's opcode table was stale (#17). No decoder existed, so none of this could be seen from Elixir.

**Decision.** The compact record is used only for one `M` followed by `L` commands, the one shape it holds without loss; everything else is a `path_cubic` stream. `Visualize.IR.Path.expand_smooth/1` rewrites the smooth curves to `C`/`Q` by SVG's reflection rule before encoding, and `absolute/1` (the same walker, for targets without relative commands) joins it on `IR.Path`; the encoder's silent fallback clause is gone, so an unknown command fails to match instead of encoding to nothing. Transforms fold through `Visualize.IR.Transform.to_matrix/1` (D-35). Both the IR and the `SVG.Element` encoder parse colours with `Visualize.Color.parse/1`. `test/support/canvas_binary_decoder.ex` decodes every record of spec/09 §4–5 from the specification's layouts, and `test/visualize/backend/canvas_binary_test.exs` holds `decode(encode_path!(path)) == expand_smooth(path)` over random paths, the compact-form choice, the matrix fold for every operation and for order, the colour cases and the moduledoc table.

**Consequences.** Streams for closed, stepped or multi-subpath paths grow (17 bytes per command against 16 per point) and draw correctly; a polyline is byte-identical to before. A `0xFF` transform is now right for every operation list, which the hooks must apply with `ctx.transform`, not `setTransform` (spec/09 §4.3.6). `Visualize.Color.parse/1` downcases its input, so `"SteelBlue"` resolves too.

## 37. D-37 — Every Canvas output is executable

**Context.** `Visualize.Backend.Canvas` emitted `moveTo_rel`, `bezierCurveTo_smooth`, `lineTo_h`, `arcSvg` and the like as command tuples; the `:js` serialiser printed them as comments and `drawImage` as a commented call, and the JSON hook skipped them as unknown methods — so a step curve, a relative path or an arc drew nothing on Canvas. A group's style never reached its children, which lost `Visualize.Backend.Hybrid` its `:dynamic_style` for lists, and `render_scene/2` lacked the two binary formats `render_element/2` had (#17).

**Decision.** `path_data/1` normalises the path with `Visualize.IR.Path.absolute/1` and maps the result to Canvas methods only; an arc becomes an `ellipse` call by the SVG endpoint-to-centre conversion in `Visualize.Backend.Canvas.Arc`, done on the server rather than in each hook. So the `:commands`, `:json` and `:js` outputs are all directly executable, not just `:js`. A group emits its style setup after its transform inside `save`/`restore`. `drawImage` loads the image and draws in `onload` under the transform that was current. `render_scene/2` concatenates per-element binaries for `:binary` and `:binary_base64`. Booleans serialise as literals. The comment fallback is gone: an unknown tuple raises.

**Consequences.** The 3.3.2 table is shorter and the `*_rel`/`*_smooth`/`lineTo_h*`/`arcSvg*` tuple names no longer exist; a consumer that matched on them (none in this repository or in `examples/`) sees `lineTo`/`ellipse` instead. The `CanvasChart` hook needs no arc helper. `test/visualize/backend/canvas_test.exs` renders a path of every command kind and asserts no `//` in the JavaScript, checks the arc conversion against hand-computed centres, and holds the group style and scene binary contracts.

## 38. D-38 — Incremental scrolling: a zero delta is `:none`, regions are bounded, the callback sees the margin

**Context.** `scroll_mode({0, 0}, _)` fell through to `:full_redraw`, so a no-op scroll event re-sent the whole viewport; `region_count` was documented 1–3, written unchecked from a list length, and `exposed_regions/2` produced at most two; `Visualize.Incremental` promised a `"canvas_full"` event it never emitted and stored a `:margin` nothing read (#17).

**Decision.** `scroll_mode/2` returns `:none` for a zero offset, and `Visualize.Incremental.scroll/2` answers it with a `"none"` payload — same event name, empty binary, nothing encoded — so the caller's pipeline is unchanged and the hook ignores it. `encode_incremental/3` raises `ArgumentError` above three regions or for a zero offset; the docs say 0–2 from `exposed_regions/2` and 0–3 in the record. `build_element` receives `margin` in its options map for every build; `render_viewport/1` keeps passing the whole dataset, and the docs now say so and why: whether a full frame needs points outside the viewport depends on the mark, which only the callback knows. The `"canvas_full"` promise is deleted.

**Consequences.** A zero-delta scroll costs nothing. Callers that relied on `:full_redraw` for `{0, 0}` (none in this repository; `examples/` never sends one) would now see `"none"`. `test/visualize/incremental_test.exs` covers the classification, the bounds, the `"none"` payload and the options map.

## 39. D-39 — The stream benchmark measures time with its clock only

**Context.** `Visualize.Benchmark.stream_benchmark/1` computed elapsed time as `now − (end_time − frames)`, subtracting a frame count from a timestamp, so `fps` was `frames / (frames − overrun)` — about one thousand for any backend, whatever the machine did (#17).

**Decision.** The two loops are one `stream_loop/5` over a frame-rendering function; it reads a clock at the start and once per iteration and reports `last − first` as the elapsed time. The clock is the `:clock` option (default `System.monotonic_time(:millisecond)`), so `test/visualize/benchmark_test.exs` runs the whole benchmark on a clock that advances ten milliseconds per reading and asserts the frame count, the elapsed time and the frame rate exactly.

**Consequences.** Reported frame rates are true rates; the historical `format_stream_report/1` numbers were meaningless and are not comparable. The option is public API, which is the honest price of a testable benchmark.

## 40. D-40 — The canvas hooks ship in `Visualize.Hooks`

**Context.** `Visualize.Backend.Hybrid`, `Visualize.Backend.CanvasBinary` and `Visualize.Incremental` all named a client hook — `CanvasChart`, `CanvasBinaryChart`, `CanvasIncrementalChart` — that only `examples/` carried, inline in a layout, with two diverging copies of the binary decoder (one applied a full matrix with `setTransform`, one double-drew circles, neither handled the relative arc sub-opcode). `Hybrid`'s doc also promised a `:js` default the code did not have (#17). metresis's dense panels will run on these hooks.

**Decision.** Three modules — `Visualize.Hooks.Canvas`, `Visualize.Hooks.CanvasBinary`, `Visualize.Hooks.CanvasIncremental` — carry the examples' JavaScript with the debug logging removed and one decoder, `executeVisualizeBinary`, written against `Visualize.Backend.CanvasBinary.Opcodes`: every opcode, sub-opcode and transform kind has a case, `0xFF` composes with `ctx.transform`, circles and rectangles fill one path for the draw records that follow, and an unknown byte throws. `Visualize.Hooks.js_code/0` bundles all five hooks in one ES module with the decoder exported. `CanvasChart` replays `data-commands` (JSON) or `data-script` (JavaScript), the two attributes `Hybrid` emits; `CanvasIncrementalChart` ignores mode `"none"` (D-38). The `Hybrid` doc states the `:json` default.

**Consequences.** A host installs one file and has every hook the library's own output needs; `examples/` can drop its inline copies. The test holds the bundle's exports and the decoder's byte coverage, so an opcode added to `Opcodes` without a `case` fails in Elixir before it desynchronises a browser.

## 41. D-41 — Hierarchy, tidy tree, partition and treemap follow d3-hierarchy

**Context.** Four layout defects verified under #16: `Visualize.Layout.Hierarchy.stratify/2` attached a snapshot of each child at the moment it was appended, so a grandchild listed after its parent was lost (the moduledoc's own example produced three nodes); `Visualize.Layout.Hierarchy.path/2` prefixed the answer with every ancestor of the lowest common ancestor; `Visualize.Layout.Tree.generate/2` was not Reingold–Tilford — its second walk handed every child the same modifier, so cousin subtrees overlapped (`A1 = B1`, `A2 = B2` for the two-by-two tree); and `Visualize.Layout.Partition` and `Visualize.Layout.Treemap` filtered zero- and `nil`-valued children out of the returned hierarchy.

**Decision.** Port d3-hierarchy. `stratify/2` resolves every parent link over the whole input and builds the tree from the root down, so the result depends only on the links; the root rule, list-order children and the silent drop of unknown parents are unchanged. `path/2` is d3's `node.path`: source-side chain up to the lowest common ancestor, then target-side chain down, nodes matched by the `data` of their ancestor chain (a node equals its own snapshot in a descendant's `parent`). `Tree.generate/2` is d3's `tree` — Buchheim's linear-time algorithm with threads, apportioned shifts and accumulated modifiers — run on an id-keyed store of plain maps in place of d3's mutable wrappers; raw coordinates put the root at `x = 0`, and the `size` normalisation (leftmost 0, rightmost `width`) and `node_size` multiplication of spec/06 §2.1 are kept as they were, so `size` mode does not carry d3's half-separation margins. `Partition` and `Treemap` weigh a `nil` or non-positive value as 0, tile every child, give a weightless child a zero-extent rectangle at the slot it would occupy, and lay its subtree out below it; an inner rectangle or tile that a padding inverts collapses to its midpoint as d3's `positionNode` does, in place of the negative extent or the unpositioned subtree the old code left. The `:binary` tiler's half-split is guarded so the prefix is never empty — with zero-weight children the old split could recurse on the same list forever.

**Consequences.** Every descendant of a partitioned or treemapped root carries a rectangle, so a consumer no longer has to guard against vanished nodes; one that relied on zero-valued children being absent must now skip zero-area rectangles itself. Raw tidy-tree coordinates are centred on the root rather than starting at 0; only the raw mode moves, `size` and `node_size` output for a single-parent tree is unchanged. `test/support/tree_golden.txt` (two trees, three scaling modes) and `test/support/treemap_golden.txt` (five tilers and the partition over a hierarchy with a zero-valued child) are the recurrence guards, with `test/visualize/layout/hierarchy_test.exs` holding the stratify order-independence and path cases.

## 42. D-42 — Sankey and circle packing are ports of d3-sankey and d3-hierarchy pack

**Context.** `Visualize.Layout.Sankey` placed every node in one column under `:center`, made `:justify` identical to `:left`, mirrored the diagram under `:right` so links flowed backwards, stored `link_sort` without consulting it, sized node heights on a per-layer scale and link widths on a global one (so the ribbons of a node did not add up to its height), and assigned layers in an order that depended on `MapSet` iteration. `Visualize.Layout.Pack` separated siblings by twice the padding, enclosed them in a bounding-box circle rather than the minimal one, and fell back to `:rand` when its tangent search found no candidate, so a layout could differ between runs (#16).

**Decision.** Both modules are ports of their d3 references. Sankey: `depth` and `height` are longest paths (a cycle stops after `n` rounds instead of raising as d3 does); the four alignment functions are `sankeyLeft`, `sankeyRight`, `sankeyCenter` and `sankeyJustify`; one scale `ky`, the least over the columns, sizes nodes and links, so no minimum link width remains; the vertical pass is d3's — centred columns, `alpha`/`beta` relaxation towards the ideal link positions in both directions, two-sided collision resolution, links re-ordered by their neighbour's position unless `link_sort` is given, in which case it is applied once. This library's own conventions stay: link `y0`/`y1` are top offsets (d3 stores centres), the ribbon path of §7.4 is unchanged, and `node_align/2` now rejects an unknown atom. Pack: `packSiblings` (the front chain) and `packEnclose` (Welzl over a shuffle drawn from d3's constant-seeded LCG) replace the tangent search, the bounding-box circle and the random fallback; children are packed in input order, not by radius; padding follows d3's two-pass scheme, so the sibling gap is `padding * r0 / r1` output units, where `r0` and `r1` are the unpadded and padded root radii.

**Consequences.** Sankey output changes for every input: columns spread across the width under every alignment, sinks share the last column under `:right` and `:justify`, and links tile their nodes exactly. A caller that relied on the 1-pixel minimum link width must draw thin ribbons itself. Pack layouts are reproducible and tighter; a consumer that depended on the descending-radius order of the old packing sees the input order instead. `test/support/sankey_golden.txt` (five nodes, four alignments) and `test/support/pack_golden.txt` (five leaves, with and without padding) are the recurrence guards, and the pack property test checks non-overlap, containment and determinism over random sibling sets.

## 43. D-43 — `Geo.Delaunay` is a Delaunay triangulation with half-edges

**Context.** `Visualize.Geo.Delaunay.new/1` inserted points into a super-triangle with an in-circle test whose sign depends on vertex order, while it built new triangles from boundary edges whose orientation it had discarded; the result was neither Delaunay nor a triangulation (40 random points gave 147 triangles, 138 with a point inside their circumcircle). `new([])` returned `hull: [0, -1]`, `halfedges` was always `[]`, and the hull came from a separate gift wrap with an "infinite loop" guard (#16).

**Decision.** Incremental insertion with ghost triangles (Shewchuk's formulation) rather than a finite super-triangle, which can lose thin hull triangles however large it is made: every hull edge carries a ghost for the region beyond it, the cavity rule is one predicate over real and ghost triangles, and the hull is read off the surviving ghosts, so triangles and hull cannot disagree. Every triangle is kept counter-clockwise and the in-circle determinant is normalised by orientation, so `det > 0` is "inside" for any vertex order. `halfedges` is filled with d3-delaunay's semantics — the field was already public, and filling it is what lets `Visualize.Geo.Voronoi.edges/1` stop searching the triangle list, a change left for later; the quadratic `edges/1` keeps its contract. Coincident points after the first are dropped; all-collinear input gives no triangles and a two-point hull; fewer than three points list every index. Predicates are plain floating point, adequate for general position and for exactly representable degenerate coordinates; adaptive-precision arithmetic (d3's `robust-predicates`) is out of scope. `Visualize.Geo.Voronoi` is untouched and still does not extend hull cells to infinity (spec/07 §4.1).

**Consequences.** Triangulations are correct, so Voronoi cells and `neighbors/2` are now meaningful; the triangle list changes for every input, and triangle vertices are now counter-clockwise where before their order was arbitrary. Construction remains quadratic — `d3-delaunay`'s sweep would be `O(n log n)` — which is acceptable at the sizes this library draws. `test/visualize/geo/delaunay_test.exs` holds the StreamData property (empty circumcircles, `2n - 2 - h`, counter-clockwise triangles, convex hull, paired half-edges, every point present) and the degenerate fixed cases.

## 44. D-44 — Rotation inverts in reverse order, collision pushes both nodes, every computed range has a step

**Context.** Three defects verified under #16. `Visualize.Geo.Projection.invert/3` undid the spherical rotation by `phi` then `gamma` — the forward order — so it was exact only when at most one of the two was non-zero. `Visualize.Layout.Force.Forces.apply_collision/3` pushed only node `i` of each overlapping pair. `Visualize.Random.binomial/2` and `samples/2` iterated `1..n`, which for `n = 0` is the descending range `[1, 0]`, so `binomial(0, p)` ran two trials and `samples(fun, 0)` returned two samples — the third instance of the class after `Data.range/3` and `Quantize.thresholds/1` (#13).

**Decision.** The inverse rotation is composed in reverse: gamma about z, then phi about x, then lambda added back; the rotation axes and the `lon - lambda` sign convention of spec/07 §1.4 are kept as documented, so the projection golden (rotation `{0, 0, 0}`) is unchanged and no consumer's map moves. Flipping to d3's sign would have been a silent contract change for every caller of `rotate/3,4` with nothing gained but conformity; it can be its own proposal if ever wanted. The collision force accumulates, over every pair `i < j`, an equal and opposite half of the overlap correction on both nodes before touching any velocity, so it is symmetric and order-independent; the equal split of spec/06 §8.2 is kept rather than d3's radius-squared weighting, since the specification states the equal split and nothing in the library depends on the difference. Every range in `lib/` whose upper bound is computed — 44 sites across 20 modules, from `Random` and `Forces` to `Voronoi.cells/1`, `Chord`, `Stack` and the benchmark loops — now carries `//1`, so an upper bound below the lower one yields an empty range instead of a descending one; the sweep was mechanical and intentionally total, so that the class cannot recur by omission.

**Consequences.** `invert/3` is an inverse for any rotation; the property in `test/visualize/geo/projection_rotation_test.exs` runs every type but transverse Mercator (#9) at per-type tolerances. Collision layouts change wherever nodes overlapped: clusters now spread symmetrically and momentum is conserved (`test/visualize/layout/force/forces_test.exs`). `Random.binomial(0, p)` is 0 and `samples(fun, 0)` is `[]` (`test/visualize/random_test.exs`); `Voronoi.cells/1` on an empty diagram is `[]` rather than a crash; a force run or collision with `iterations: 0` does nothing instead of two passes. Ranges that were always non-empty behave as before.

## 45. D-45 — Phoenix is an optional dependency; the components render through the real scale API and are held by render goldens

**Context.** Every function component in `Visualize.Components` raised on first render: they called `Visualize.Scale.scale/2` (the facade is `apply/2`), `Visualize.Scale.color/0` and `Visualize.Scale.scheme/2` (never existed), passed `Visualize.Data.extent/1`'s tuple where `Visualize.Scale.domain/2` takes a list, and raised in `Enum.max/1` or `hd/1` on empty data (#15). Nothing had ever executed them, and the reason was deeper than the missing Phoenix in this library's own build: `examples/`, which depends on `phoenix_live_view` 1.2, compiles visualize without the component modules at all. Mix prunes the code path to a dependency's *declared* deps while compiling it, so `if Code.ensure_loaded?(Phoenix.Component)` over an undeclared Phoenix is false in every host — and the source, which called the `raw/1` that LiveView 1.x no longer imports, would not have compiled had the guard ever been true. A test-only dependency would have made the goldens green while leaving every consumer exactly where it was.

**Decision.** `phoenix_live_view` is declared `optional: true`, as `nx` already is: a host that has LiveView gets the ordering and code-path guarantee and compiles the components; a host without it never fetches Phoenix. The library's own builds therefore carry Phoenix, so the components compile in every environment, are documented, and are rendered under test. The proof that a consumer stays Phoenix-free moves out of this checkout's `MIX_ENV=prod` build into `scripts/consumer_check.exs`, the throwaway consumer [12-testing-and-conformance](12-testing-and-conformance.md) §4 always described. The seven chart components are rewritten against spec/03–05: every coordinate is `Visualize.Scale.apply/2`; a `colors` atom is `Visualize.Scale.Color.scheme/1` as the range of a `Visualize.Scale.Ordinal` over the mark indexes, a list is used the same way, and an unknown scheme or empty list is an `ArgumentError`; extents become `[min, max]`, ordered by the value's module for time values; a collapsed domain spans one unit so a single datum renders; empty data renders the frame over `[0, 1]` domains with no mark. Coordinates print through `Visualize.IR.Path.format_number/1`, and a `nil` `class` or a false `animate` emits no attribute. A private support module (`lib/visualize/components/support.ex`, hidden from the documented surface) holds what the two component modules share. `test/visualize/components_test.exs` renders each component through `Phoenix.LiveViewTest.render_component/2` on a fixed dataset and on `[]` against `test/support/components/*.svg`.

**Consequences.** `mix deps.get` in this repository fetches Phoenix, Plug and their dependencies for every environment; a consumer's dependency tree is unchanged unless it already had LiveView, in which case it now also compiles `Visualize.Components` and `Visualize.Components.Tree`. A single-datum chart puts its datum at the start of the range rather than raising; the linear and time scales themselves still divide by zero on a collapsed domain, which is a scale-level question for a proposal of its own. The `consumer-compile` job takes the time of a cold compile of `lib/`. The tree components compile again (their calls into the removed scale functions are mechanically replaced) but keep their remaining defects until the next work item of #15.

## 46. D-46 — The hierarchy components lay out with `Layout.Partition`, colour by branch and draw their labels

**Context.** The three components of `Visualize.Components.Tree` shared the scale-API defects of D-45 and had three of their own (#15): `treemap_chart/1` and `sunburst_chart/1` replaced a `colors` list with `:category10`; `sunburst_chart/1` accepted `label`, computed it and never drew it; and the sunburst carried a private angular layout — a level-by-level walk that matched nodes back to their originals by `data` and `depth`, gave equal shares to children summing to 0, and duplicated what `Visualize.Layout.Partition` already computes. The treemap coloured leaves by immediate parent while its own function-table row said top-level parent.

**Decision.** The sunburst is `Visualize.Layout.Partition` sized `{2π, radius}`, which gives the same ring radii the private layout did, and each node is drawn by `Visualize.Shape.Arc` over its `x0`/`x1` angles and `y0`/`y1` radii — the arc's clockwise-from-12 convention is the one the private path builder implemented by hand. A node whose value is not positive is a zero-width slot that draws nothing, as D-41 already settled for the layout, in place of the equal-share rule. Colour is assigned once, walking down from the root: the i-th child of the root takes the i-th colour and every descendant inherits it, in both the treemap and the sunburst, so the two agree and a colour list is honoured through the palette of D-45. Sunburst labels are drawn at `Visualize.Shape.Arc.centroid/2` under the treemap's fit test transposed to polar terms (ring thicker than 15, mid-radius arc longer than 30, truncated to the arc). A root-only hierarchy is the empty case and renders without raising. `test/visualize/components/tree_test.exs` holds render goldens for the three components on a fixed hierarchy and on a root-only one, next to the chart goldens of D-45.

**Consequences.** A treemap deeper than two levels changes colour: leaves under the same top-level branch now share its colour instead of splitting by immediate parent. A sunburst whose siblings all sum to 0 draws no arc for them where it drew equal sectors before; a sunburst now carries labels where it drew none. The tree components no longer hold any layout code of their own.

## 47. D-47 — `BrushHook` stores its bound handlers and removes those

**Context.** `BrushHook.destroyed()` called `document.removeEventListener` with `this.handleMouseMove.bind(this)` and three siblings — a fresh function each time, which `removeEventListener` matches against nothing — so every unmount of a brushed SVG leaked four `document` listeners that kept the dead hook, its element and its LiveView closure reachable (#15). The `Visualize.Hooks.Brush` module doc also stated a default brush colour of `rgba(0,0,0,0.1)` where the script applies `rgba(119, 119, 119, 0.2)`.

**Decision.** `mounted()` binds each handler once, stores it on the hook (`onMouseMove` and the rest) and adds those references; `destroyed()` removes the four `document` listeners by the same references. The capture rect's listeners are left to go with the element. The module doc states the colour the script applies. The test holds the source to the shape rather than exercising a DOM: it asserts that every `.bind(this)` in `mounted()` is a stored one, that `destroyed()` binds nothing and removes the four stored document references, and that the documented default is the applied one.

**Consequences.** Unmounting a brushed chart releases it. `ZoomHook` binds inline too but only on `this.el`, whose listeners die with the element, so it needs no clean-up and its `destroyed()` stays empty.

## 48. D-48 — `Hooks.js_code/0` exports each hook once

**Context.** The generated module declared every hook with `export const` and then appended `export { ZoomHook, … }`, a second export of each name. esbuild refuses a module that exports a name twice, so a consumer that bundles the file (metresis's `mix assets.deploy`) could not use it as written and stripped the closing list itself.

**Decision.** `Visualize.Hooks.js_code/0` emits per-hook `export const` declarations and one `export default { … }` object, nothing else. A test asserts each export name appears exactly once and that the default object lists every hook.

**Consequences.** Named imports and the default import both work; the file bundles unchanged.

## 49. D-49 — A collapsed domain maps to the range midpoint

**Context.** `Visualize.Scale.Linear.apply/2` and `Visualize.Scale.Time.apply/2` computed `(v − d0) / (d1 − d0)` and raised `ArithmeticError` when the domain's ends coincided — the extent of a single datum, or `[0, 0]` for an all-zero bar chart. `Visualize.Scale.Power` and `Visualize.Scale.Symlog` guarded the same case with `t = 0`, so the five continuous scales disagreed with each other and with d3, whose `normalize` returns a constant `0.5` for a degenerate interval. `Visualize.Components` had started widening collapsed domains itself (spec/10 §1.3), a chart-level workaround for a scale-level gap (#47).

**Decision.** `Visualize.Scale.Behaviour.normalize/3` is the one place the fraction is computed: `0.5` when the interval is collapsed, `(v − a) / (b − a)` otherwise. Every continuous scale's `apply/2` and `invert/2` use it, so a collapsed domain maps every value to the range midpoint and a collapsed range inverts to the domain midpoint. Nothing raises.

**Consequences.** A single-datum chart draws its point in the middle of the axis, as it does in d3. `Power` and `Symlog` moved from `0` to `0.5` on the collapsed case. The components' own widening stays as a chart-level policy for readable axes; it is no longer load-bearing.

## 50. D-50 — Optional-dependency modules compile warning-free without the dependency

**Context.** `Visualize.Shape.LineNx` and `Visualize.Backend.CanvasBinary` call `Nx` directly. In this repository Nx is always fetched, so the build never showed it; in a consumer that does not depend on Nx every call printed `Nx.… is undefined` while visualize compiled — nine warnings, not the one #46 first reported, once the consumer check kept the whole output.

**Decision.** Both modules declare `@compile {:no_warn_undefined, Nx}`, Elixir's mechanism for exactly this case. The calls stay direct, so a missing Nx still raises `UndefinedFunctionError` at the call as spec/04 §10.1 promises. `scripts/consumer_check.exs` compiles visualize inside the throwaway consumer with its output captured and fails on any `warning:` line, which is the recurrence guard; the consumer's own `--warnings-as-errors` never covered a dependency's compile.

**Consequences.** An Nx-less consumer builds silently. Any future optional dependency gets the same declaration or the gate goes red.

## 51. D-51 — Transverse Mercator inverts on the whole sphere

**Context.** D-5 recorded the crash (`asin` of a value outside `[−1, 1]` away from the central meridian) and proposed `nil` outside the projection's domain plus a forward clip at 90° from the meridian. Working it through showed the domain question was moot: the code computed `asin(sin y / sqrt(sinh² x + cos² y))`, whose denominator can be smaller than `|sin y|`; Snyder's spherical inverse divides by `cosh x ≥ 1`, so the argument is always in range, and `atan2(sinh x, cos y)` with `cos y < 0` returns the back hemisphere correctly. The forward projection covers the sphere except the two points where `cos(lat) · sin(lon) = ±1`, which it clamps.

**Decision.** `invert/3` is Snyder's inverse; the forward projection is unchanged and still never returns `nil`. Transverse Mercator rejoins the projection golden's inverse column and the rotation round-trip property (D-43) with no exclusion.

**Consequences.** Every projection type now inverts on the golden grid; D-5's `nil` contract remains true in the sense that there is no point to return it for. The 36 grid points that raised are a unit test.

## 52. D-52 — `Data.Table.rows/1` is the one door for tabular data; `table` is optional

**Context.** Every generator takes a list of rows and accessors. Data reaches a chart as an Explorer DataFrame, a column-oriented map, a keyword list of columns or an Nx tensor as often as a row list, and every caller converted by hand. Tucan and Ggity take these through the `Table.Reader` protocol; the protocol package is small, but a required dependency would reach every consumer of visualize for a shape most never pass.

**Decision.** `Visualize.Data.Table.rows/1` ([08-utilities](08-utilities.md) §6) is the single conversion, and the generators call it (a following work item wires them). Lists, column maps and keyword lists are read natively, so a row list is zero-copy and the common shapes need no package; a tensor of rank 2 becomes tuple rows and rank 1 its values, natively through Nx; any other struct goes through `Table.to_rows/1` rather than a direct `Table.Reader.init/1` or `impl_for/1`, because the consolidated protocol's known impls are only `List` and `Map` wherever Explorer is absent and Elixir's type checker then flags a protocol call on an arbitrary struct as incompatible, or its non-`nil` branch as dead; a plain function's domain is inferred from its heads and the call type-checks everywhere. `table` is `optional: true`, declared for the same reason as Nx and Phoenix (D-45): a host that has it gets it on the code path, and without it the struct branch raises `UndefinedFunctionError` at the call while everything else works. The module declares `@compile {:no_warn_undefined, [Table, Nx]}` (D-50), and `scripts/consumer_check.exs` asserts that neither `Table.Reader` nor `Explorer.DataFrame` loads in a consumer. Column names are resolved at read time, not by rewriting rows: `Visualize.Data.Table.get/2` reads either spelling, so an atom accessor reaches Explorer's string columns, no row is copied to rename keys and no atom is ever created from data. Time-column detection reads the whole column, because a first-row check (Tucan's) accepts a column that stops being temporal; the cost is documented.

**Consequences.** Explorer is a test-only dependency: the round-trip tests prove the reader path and the consumer check proves it stays out of a consumer's build. A rank-1 tensor bound to a dense series remains a first-class source for `Visualize.Shape.LineNx` and the binary path; `rows/1` does not replace that path and the declarative layer must keep both doors open.

## 53. D-53 — Generators and components read their data through `Data.Table.rows/1`

**Context.** D-52 gave the library one conversion from tabular sources to rows and left the generators reading row lists. Calling `rows/1` at every site is the conversion every caller wrote by hand; the question was where the call lives and what the atom accessors do with rows whose keys are strings.

**Decision.** Every generator that takes a list — `Line`, `Area`, `Pie`, `Stack`, `Band`, `Rule`, `XBand`, `PercentileBand`, `Rose` — and `Contour.Density.compute/2` call `Visualize.Data.Table.rows/1` on entry, in the module rather than in the `Visualize.Shape` facade, so a direct module call accepts the same sources. The components with a `data` assign do the same once at the top and their `data` attr widens from `:list` to `:any` ([10-liveview-integration](10-liveview-integration.md) §1.2). A row list is returned by `rows/1` as the same term, so the existing path costs one clause and no copy. Field accessors — an atom or, now, a string — normalise to `Visualize.Data.Table.accessor/1` in every module that had its own `normalize_accessor` (`Line`, `Area`, `Arc`, `Pie`, `Annotation`) so `:v` reads an Explorer frame's `"v"`. `Pie`'s field accessor and `Stack.new/0`'s default keep their `0` default and extend it to a `nil` value: `get/2` cannot tell an absent key from `nil`, and a `nil` slice or stack value is a zero-height mark, not an arithmetic error. `Arc` and `Symbol` take one datum and are unchanged. `Shape.LineNx` is not routed through `rows/1`: its inputs are coordinate lists and tensors, and a tensor bound to a dense series stays a first-class source for the batch and binary paths, which the declarative layer (#53) must keep open beside the row path.

**Consequences.** A `line` from an Explorer frame renders the same path as from the equivalent row list, held by a test. Row lists are unchanged in cost and identity. `usage-rules.md` names the accepted sources in the pipeline line.

## 54. D-54 — Responsive sizing is two halves: `ResizeHook` for the round trip, `viewBox` scaling for the rest

**Context.** A chart rendered at `width × height` is that many pixels in the page: a narrower panel overflows it and a wider one leaves a gap. Two remedies exist and they suit different charts. Scaling the `<svg>` by CSS keeps the server out of it but scales text and stroke widths with the drawing; re-rendering at the container's size keeps every label at its size and lets the server choose a resolution from the pixel width (the metresis case), at the cost of a round trip per resize.

**Decision.** Both are provided and neither is the default. `Visualize.Hooks.Resize` ([10-liveview-integration](10-liveview-integration.md) §10) is the round-trip half: a `ResizeObserver` on the container pushes `{width, height}` once per settle — a debounce timer restarted by every observation, so a drag produces one event — and skips a size equal to the last pushed, so a LiveView that assigns the size to `width` and `height` re-renders once per resize. `Visualize.IR.Element.root/3` under `responsive: true` and the `responsive` component assign are the CSS half: `preserveAspectRatio="xMidYMid meet"` and `style="width:100%;height:auto"` on the root, which fills the container's width at the drawing's aspect ratio while `width` and `height` stay the intrinsic size for a consumer that reads them. `preserveAspectRatio` is not emitted unless asked for: it is the SVG default, and emitting it always would change every existing root for nothing.

**Consequences.** A chart under `ResizeHook` is one `handle_event` clause away from following its panel; one under `responsive` follows it with no server involvement but with scaled text. The hook is the sixth export of `Visualize.Hooks.js_code/0` and the export-uniqueness tests list it. Every root and component golden is unchanged unless it opts in.


## 55. D-55 — A theme slot renders as a CSS custom-property reference carrying its literal as the fallback

**Context.** Every axis, mark and component carried its colours as literals — `"steelblue"`, `"white"`, `"#ccc"`, `currentColor` on the axes — and its fonts and sizes likewise, so a dark page, a house palette or a larger tick font meant editing Elixir, and the declarative layer (#53) had no vocabulary for "the axis colour". Proposal #57 asked for a `Visualize.Theme` whose values reach the page as CSS custom properties emitted on the root, so that a consumer overrides them in CSS.

**Decision.** `Visualize.Theme` ([08-utilities](08-utilities.md) §7) holds named slots, and `resolve/3` is the one door, taking the mode the render target needs: `:literal` for a canvas, `:css` for SVG. The `:css` form is `var(--vis-<slot>, <literal>)` and the root declares nothing. A property declared on the `<svg>`, inline or in a `<style>`, beats every ancestor rule in the cascade and would force `!important` on the consumer, the opposite of what a theme is for; the fallback form keeps a standalone SVG themed and lets a rule on any ancestor switch the theme, which `stylesheet/1` writes for a whole theme as one class. The axes give up `currentColor` for the `:axis` and `:text` slots: `currentColor` let a page colour an axis through `color`, but to one colour only, and it kept the tick lines and the labels from differing. The built-in palettes are this library's own, because every d3 categorical scheme has a colour under 2.1:1 on white and a theme that promises contrast cannot ship one unchanged. `Visualize.Theme.mode/1` fixes the backend rule — `:css` for `Visualize.Backend.SVG`, `:literal` for every other — and `Visualize.Axis.render/2` applies it, as the style grammar of #53 will.

**Consequences.** Every axis and component golden changes once: colours become `var(--vis-…, literal)` references, fonts and sizes read from the theme, the `<svg>` of every component carries the theme's `font-family`, and the hierarchy components' separators and over-mark labels take `:background`. A consumer that matched `stroke="steelblue"` now matches `var(--vis-series-1, #3b6fa8)`, and every mark attribute is a few bytes longer. The gallery under `examples/` exposes each of its palettes as a `Visualize.Theme`, gains a `:dark` palette from `Visualize.Theme.dark/0`, and draws its text and axes from the palette rather than from literals.


## 56. D-56 — A chart names itself: `<title>` and `<desc>` on the root, `role="img"`, hidden axes

**Context.** A rendered chart carried no `role`, `<title>` or `<desc>`, so a screen reader announced an image and nothing else, then read the tick labels of the axes as stray numbers. Nothing in the library could name a chart; a consumer had to edit the markup.

**Decision.** `Visualize.IR.Element.root/3` takes `:title`, `:description` and `:id` ([02-architecture](02-architecture.md) §2.1) and every component takes `title` and `description` ([10-liveview-integration](10-liveview-integration.md) §1.2). A root is `role="img"` always — a chart is an image to assistive technology, named or not — and, when named, prepends `<title id>` and `<desc id>` and points `aria-labelledby` at them, the SVG accessibility mapping's own pattern; the ids default to a hash of the two texts under a `vis-` prefix, so a page of distinct charts gets distinct ids without the caller inventing them, and `:id` overrides the prefix when a page needs to. The axis group is `aria-hidden="true"` ([05-axes-and-formatting](05-axes-and-formatting.md) §1.4): tick labels are decoration once the chart has a name, and a grid, when the frames of #53 add one, follows the same rule. Marks are not given `role="graphics-symbol"`: without per-mark labels the role is noise, and the declarative layer, which knows the datum, is where a per-mark label belongs. The two new IR types `:title` and `:desc` render only in SVG; every canvas backend emits nothing for them, as the binary stream does for `:text`.

**Consequences.** Every root gains `role="img"`, the axis golden gains `aria-hidden`, and the component goldens change for the same two attributes. A titled chart is announced by its title and description. `test/visualize/theme_contrast_test.exs` is the contrast guard proposal #57 asked for, over both themes and every scheme ([08-utilities](08-utilities.md) §7.4).

## 57. D-57 — The design map is primary, every name in it is an atom, and JSON carries the rest as tagged terms

**Context.** Proposal #53 settled that a chart is a nested map and the pipeline API is sugar (spec/14). Two questions were open when the schema was written: whether a field, scale, style or source name may be a string as well as an atom — `Visualize.Data.Table.get/2` reads either spelling (D-52), and Explorer columns are strings — and how a JSON document round-trips terms JSON has no form for (`%Visualize.Chart.Var{}`, `{:field, f}`, `{:series, i}`, `{:axis, s}`, `DateTime`, atoms in open positions).

**Decision.** In the map form every name is an atom ([14-declarative-chart](14-declarative-chart.md) §1.4): a string where a name belongs is a type error, and `:v` reaches a column named `"v"` through `get/2`. With that rule the JSON encoding is schema-driven — a string decodes to an atom exactly where the schema says the value is a name, an enumerated atom, a field or a slot — and the terms JSON cannot spell are objects with one `$`-prefixed key (§11.2), which nothing else in a design uses. Decoding therefore creates the atoms a design names; a design is authored, not data, so this is the author's vocabulary and not a data column, and a consumer loading designs from an untrusted source bounds them itself. `Jason` is an optional dependency like `Nx` (D-50): the JSON functions return `{:error, {:missing_dependency, :jason}}` without it, and the consumer check asserts it stays out of a bare consumer.

**Consequences.** `from_json(to_json(chart)) == chart` holds without a second spelling of any name, and a design is diffable as text. A design that needs a string column name writes the atom of the same spelling. The one-key `$` object is reserved: a future term gets a new tag, never a second meaning for an existing one.

## 58. D-58 — Every schema key carries a facet and a merge rule; the schema is data

**Context.** Of about a hundred builder-struct fields, some ten are style; the rest of the style vocabulary sat in ad-hoc element attribute maps at more than sixty sites, which is why style touched everything and why nothing could say which options of a mark are presentation. The builder (#54), the theme (#57) and the compiler each need that answer, and a hand-kept list would drift three ways.

**Decision.** `Visualize.Chart.Schema` is the schema as data: every key of every node kind is a spec with a type, a facet (`:binding | :channel | :geometry | :style | :content`), a default, whether it is required and its merge rule (`:concat | :union | :override`) ([14-declarative-chart](14-declarative-chart.md) §1.3–1.5, §14.2). Style is a facet, not a node: one eighteen-key grammar declared once under `styles` and referenced by name from marks, axes, labels and the legend, whose values are literals, theme slots, variables or `{:field, f}` bindings, resolved through `Visualize.Theme.resolve/3`. `keys(:style)` is the only answer to "what is style"; the validator, the JSON codec and the introspection the builder needs are all derived from the same specs, so a key added to the schema reaches every consumer at once. The validator returns every fault as `{path, reason}` with the path in the map's own terms (`[:marks, 1, :channels, :y]`), which is what a form and a compiler error both need.

**Consequences.** No node other than a style carries a colour, a font or a curve; tick sizes and padding stay geometry on the axis while the axis's colours are its `style` reference. Which channels and options a mark type reads is data too (`channels/1`, `options/1`), so a form for a mark is generated, not written. The merge rules are recorded now and applied by composition when work item 5 lands; recording them with the keys means composition cannot invent a rule later.

## 59. D-59 — A stored design keeps what its author wrote; defaults live in the schema, a variable's default in its declaration

**Context.** `from_map/1` could fill every default into the struct, so a reader never consults the schema, or keep the map sparse, so `to_map/1` returns what was written. Filling makes `to_map/1` emit forty keys the author never typed, turns a diff of two designs into noise and makes the round-trip property true only up to defaults. Separately, the research notes had `var(:unit)` carrying a default and `vars` declaring one, two places that could disagree under composition.

**Decision.** `Visualize.Chart.from_map/1` stores the nested nodes exactly as given after migration and validation; only the nine design-level keys take their defaults, because a struct has fixed fields ([14-declarative-chart](14-declarative-chart.md) §14.1). A reader takes a nested default from `Visualize.Chart.Schema.default/2`, and `from_map(to_map(chart)) == chart` holds exactly. A `%Visualize.Chart.Var{}` carries only its name; the default is declared once under `vars` (§2.4, §7.1), and the validator requires every variable used to be declared, so an undeclared one is reported by path before application. `version` is required and migrates forward through `Visualize.Chart.Migration` before validation, one registered step per version; the registry is empty at version 1 so that the first schema change ships with its step.

**Consequences.** A stored design is the author's document and a diff of two designs shows only what differs. Every consumer of a nested key reads its default through the schema, which is one place to change it. A variable used in a fragment must be declared in some fragment of the composition, which is what `:union` on `vars` is for.

## 60. D-60 — The frame realises its scales once, infers `:auto` from the whole bound column and never widens, and renders without data

**Context.** Work item 2 of #53 had to settle what a frame *is* at runtime. The design map declares scales by name with `domain: :auto` and `range: :auto`; the axes, the grid, the legend and the labels are the static furniture the compiler (§12) must render without touching the marks; and the research notes asked for `Frame.scales/1` as an escape hatch and a per-axis override of inferred scales. Three questions were open: when inference runs and where its result lives; what a collapsed or empty domain becomes, given that `Visualize.Components` widens one datum to a unit span and D-49 makes every continuous scale total over `[v, v]`; and what a colour scale's `:auto` range is when the theme, not the design, owns the palette.

**Decision.** `Visualize.Chart.Frame.new/2` realises every scale struct once — kind parameters through the existing scale modules, `:auto` domains from the whole column of every bound mark channel that names the scale, ranges from the plot area by the scale's name — and caches them on the struct; `scales/1` reads them and `put_scale/3` replaces one after construction, which is the code-level override of [14-declarative-chart](14-declarative-chart.md) §1.1 and the "per-axis override" of the notes, since an axis is drawn for a scale name. A collapsed extent stays `[v, v]` and an unbound scale takes the unit domain (`[0, 1]`, one day for time, `[]` for a discrete scale): the frame does not widen, because D-49 already makes the scale total and a readable axis is the author's call (`[0, :auto]`), and the unit domain is what lets a frame render with no source at all, which is the static split's whole point. A discrete colour scale's `:auto` range is the theme's series *as slot atoms*, resolved through `Visualize.Theme.resolve/3` by whatever draws them, so the scale's output follows a theme swap and a CSS override the way every other slot does; a sequential one runs from the theme's background to its first series colour. Frame labels gain two anchors, `{:frame, corner}` and `{:data, [x, y]}`, the latter mapped through the frame's position scales by kind, so an annotation at a data point is a label and not a mark.

**Consequences.** A frame's axis is byte for byte the `Visualize.Axis` a caller builds by hand from the same scale, and `test/visualize/chart/frame_test.exs` asserts it. The compiler can treat a frame with fixed domains as static without inspecting data. A single-datum design draws its point at the midpoint with one tick, as d3 does, rather than a manufactured span. Marks (work item 3) read the scales from the frame and resolve slot atoms in a colour scale's range exactly as the legend does now. `Visualize.Axis` gains two hidden helpers — the tick position with band centring, and the default tick label — so the grid and the legend share the axis's arithmetic and formatting rather than repeating them.

## 61. D-61 — A mark is the generator it names, drawn through the frame's scales, with its paint from the theme's series

**Context.** Work item 3 of #53 had to settle what a mark *is* at runtime. Spec/04 already holds every data-shaped generator, `Visualize.Shape.Rose` and `Visualize.Shape.PercentileBand` add no geometry of their own (D-28, D-31), and the frame realises the scales once (D-60). Three questions were open: whether a mark reimplements or delegates; how a channel reaches a scale that is not a point mapping — a `:band` scale gives a start, a bar needs two edges, a point needs the centre; and what a mark is painted with when its style says nothing, given that the theme, not the design, owns the palette.

**Decision.** `Visualize.Chart.Mark.generate/3` delegates: every element is the generator's own — `Line`, `Area`, `Band`, `Rule`, `XBand`, `PercentileBand`, `Rose`, `Symbol`, `Pie` over `Arc` — fed pixel accessors the mark computed from the frame's scales, so a mark is byte for byte the direct call with the same numbers and `test/visualize/chart/mark_test.exs` holds every type to that ([14-declarative-chart](14-declarative-chart.md) §5.6). The four generic marks (`path`, `rect`, `circle`, `arc`) are what a layout's rows need and nothing more. A channel through a `:band` scale reads the band's start for `x0`, its end for `x1` and its centre for `x` (§5.2): the edge rule lives in the mark, once, rather than in a `bandwidth` channel every bar would repeat. A `series` channel splits a path mark into one path per series and names the colour scale, so a stacked area is one mark. A mark's *paint* — `stroke` for a stroked type, `fill` for a filled one — is the style's, else the theme's `{:series, i}` for the mark's position, a slot resolved in the render's mode like every other, so a theme swap or a CSS rule recolours the marks with the legend; a `percentile_band`'s two bands are its paint at fixed opacities and its median the paint stroked, the one place the mark adds a presentation the generator does not carry, because the generator returns three paths and cannot. A `:rule` and an `:x_band` keep the generator's own label placement rather than the inline label's anchor table: the annotation a metresis panel draws today must not move.

**Consequences.** A mark's geometry can never drift from a generator's, and a generator fix reaches every design. `inner`/`outer` on a `percentile_band` became four channels (`inner_lo`, `inner_hi`, `outer_lo`, `outer_hi`), since a channel is one field and a band is two. The `value` channel means a size on a `:symbol` and a `:circle` and a share on an `:arc`, and the frame's inference skips it there. Marks with no bound source render empty, not raise, so a frame with marks still renders without data.

## 62. D-62 — A transform is a pure step over rows whose only context is the plot and the projection, and the frame runs the pipeline before its scales

**Context.** The layouts of spec/06 and the geo algorithms of spec/07 position rows and draw nothing; #53 made them transforms in a mark's data pipeline rather than marks. Two things were open: what a transform may see — the frame's scales would let a `:delaunay` read domain values, but the frame infers its scales *from* the marks' rows — and how a layout's several outputs (a tree's nodes and its links, a chord's groups and its ribbons, a Voronoi's cells and its edges) reach the marks that draw them when a transform yields one row set.

**Decision.** `Visualize.Chart.Transform.apply/3` runs a mark's steps in order, each a pure function of the rows before it and its node, and sees only the plot area (`size:`, the default extent of every layout) and, in a `:geo` frame, the projection — never a scale ([14-declarative-chart](14-declarative-chart.md) §5.4). That break in the cycle is what lets `Visualize.Chart.Frame.new/2` run the pipeline at construction and infer `:auto` domains from the transformed columns, replacing the limit D-60 recorded (a `:bin`'s `count` now infers the histogram's axis). Geo steps therefore read pixels: a `:projection` step puts `x` and `y` on the rows, and a `:delaunay`, `:voronoi` or `:density` after it, or after a `:force`, reads them. Each layout op takes an `output` key naming which of its row sets it yields (`:nodes` or `:links`, `:groups` or `:chords`, `:cells` or `:edges`, `:triangles` or `:hull`), so two marks with the same steps draw a tree's links under its nodes; a row that has a shape carries it as a `Visualize.IR.Path` in a `path` column, built from the layout's own numbers (`Visualize.Shape.Arc` for a chord's groups, the ribbon of `Visualize.Layout.Chord.ribbon_path/2`, the link of `Visualize.Layout.Sankey.generate_link_path/1`) and translated to the plot's pixels, which the `:path` mark draws as it is on SVG and canvas alike. The graphs read their rows as flows and derive the nodes from the distinct ids, so a sankey is one source. `:force` is the synchronous `Visualize.Layout.Force.run/1` with the centre force at the plot centre: a design renders the same graph twice.

**Consequences.** A transform node gained `id`, `tile`, `bandwidth` and `output`; `Visualize.Chart.Schema.transform_keys/1` lists what each op requires and the validator reports a missing one by path. A pipeline step raises `ArgumentError` for a key it lacks rather than yielding nothing. The `:contour` grid is `size: [columns, rows]` over row-major rows and its rings are stretched over the plot area, the one transform whose `size` is not pixels. A `:density` on the plot area of a small chart is fast; on a large one it is #67's problem, not the design's.

## 63. D-63 — A style node resolves to two outputs by mode and binds its field references per element

**Context.** The research notes on #53 required style to resolve to classes and CSS custom properties for SVG and to literal attributes for canvas, from one node, and `{:field, f}` to reach a per-datum value through the mark's colour scale. `Visualize.Theme.resolve/3` already takes a mode (D-55) and the frame's furniture resolved its four built-in styles through a hidden resolver in #59.

**Decision.** `Visualize.Chart.Style.resolve/3` is that resolver made public: the element keys of a node in the mode of the render target, and no second representation — the `:css` output *is* the classes-and-properties form, the `:literal` output *is* the canvas form, and `Visualize.Backend.Canvas` reads the latter unchanged ([14-declarative-chart](14-declarative-chart.md) §3.4). A `{:field, f}` survives `resolve/3` and `Visualize.Chart.Style.bind/4` reads it from an element's datum — a `:colour` key's reading through the frame's `color` scale when it declares one, since a scale's output may be a slot atom and is resolved as one — so a style declared once is bound per element by the mark that draws it. The four keys that are not element keys (`curve`, `curve_opts`, `symbol`, `symbol_size`) reach the generator and never an element.

**Consequences.** The theme's mode is the only switch between the SVG and the canvas outputs; a compiled chart (§12) passes one node through one call per target. A style's `class` is appended to the mark's `mark mark-<type>` classes rather than replacing them, so a stylesheet can address both.

## 64. D-64 — A component is a preset design plus an assign mapping, drawn by the layer into the markup it always had

**Context.** Work item 4 of #53 turns the ten LiveView components into stored designs. Their render goldens (D-45, D-46) are the acceptance test and MUST NOT change, yet the layer's own rendering (`Visualize.Chart.Frame.render/2`) carries groups the components never had — `class="frame"`, `class="mark mark-<type>"`, `class="axis axis-<side>"`, marks under the axes — and a HEEx template's whitespace, blank lines for an absent `<title>`, and `<path></path>` in place of `<path/>`, none of which a serialiser reproduces. Two things were open: what a component keeps when the design and the layer own the chart, and what to do where the components' hand-written arithmetic disagreed with the layer's rules (a collapsed domain widened by one unit, a categorical line at the band start) or where the layer's vocabulary cannot say what a component draws (a tree node's circle-and-text group, a treemap's leaves alone, a sunburst arc's angles from its own row).

**Decision.** `Visualize.Chart.Presets` holds one design per component as a function of the component's assigns, and `Visualize.Chart.Presets.sources/2` is the assign mapping — the accessor assigns applied once, into the rows the design's one source binds to, so no function reaches the design ([14-declarative-chart](14-declarative-chart.md) §15). The component is what remains: it builds the frame over the mapped source, draws it with `Visualize.Chart.Frame.generate/2`, and prints the elements the layer returns in the markup [10-liveview-integration](10-liveview-integration.md) declares — the `<svg>` shell, `<title>` and `<desc>`, the `x-axis`/`y-axis` wrappers, each mark's attribute order, `class` and the `animate` transition — so every number and every paint is the layer's and the goldens hold byte for byte. Where the layer's rule and the old arithmetic differed, the layer's rule holds and spec/10 §1.3 now says so: a collapsed domain stays `[v, v]` at the range midpoint (D-49, D-60), a line or an area over a band sits at the band centres (§5.2), a repeated category is one band, a `[0, :auto]` domain is `[0, max]`. The three hierarchy presets carry their layout as the design's transform and marks and are drawn by the component from `Visualize.Chart.Mark.rows/3`, because a tree node's group, a leaves-only treemap and a sunburst's per-row angles are not marks of §5; rendered through the layer those designs draw what their marks say, and the vocabulary gaps — a row filter, per-datum angles on an `:arc`, an oriented tree link — are a proposal of their own (#71) rather than a schema change inside this item. The component's assign defaults live once, in `Visualize.Chart.Presets.defaults/1`, which the `attr/3` declarations read.

**Consequences.** The component modules shrink to their templates and the mapping; the hidden support module keeps the shell attributes, the transition and the label truncation and nothing about scales. A design a component draws is storable, diffable and bindable to another source with the same columns, and `test/visualize/chart/presets_test.exs` round-trips every preset through `Visualize.Chart.from_map/1` and `to_map/1`. A single-datum chart moves its datum from the start of the axis to its middle; a categorical line moves half a band to the right; a repeated category no longer draws two bands. The three hierarchy presets are the one place a design drawn by its component and by `Visualize.Chart.Frame.render/2` differ, until the proposal that closes the gaps lands — which it did as #71: D-68, D-69 and D-70 close the three gaps, and the layer now draws those presets too.

## 65. D-65 — A design is applied in three steps into an applied chart that holds its data and has no map form; fragments compose key by key under the schema's merge rules

**Context.** Work item 5 of #53 had to settle what "the concrete chart" that `Chart.apply/2` returns *is*, and how far composition merges. Three facts constrained it: `Visualize.Chart.Frame.new/2` raises on any `%Visualize.Chart.Var{}` it reaches (D-60), so variables must be gone before a frame is built; the `%Visualize.Chart{}` struct is the design's exact map form and `from_map(to_map(chart)) == chart` (D-59), so it cannot carry rows without breaking the round trip; and the research note on #53 lists `size` — a node — among the scalars the later fragment overrides, while the schema gives the frame's own keys `:union` and `:concat` rules that a whole-node replacement would never exercise.

**Decision.** `Visualize.Chart.apply/2` runs three steps, each reporting every fault it finds by path and stopping before the next ([14-declarative-chart](14-declarative-chart.md) §7.3): variables resolve from `vars:` or their declared default — a whole node as readily as a value, a number or an atom in a text written with `to_string/1` — and an unresolved one is `{:unresolved, :var, name}` where it stood; slots bind from a *pool* of named sources under the slot's own name, else its `default`, and every declared field must be a key of some row of the bound source, in either spelling, else `{:missing_field, f}` at `[:sources, name]`; then the validator types what the variables became and the frame is realised over the bound sources, which is where `:auto` domains are inferred. The result is a `%Visualize.Chart.Applied{}` — the concrete chart with `vars` empty, the bound sources by slot name as the pool gave them, and the realised frame — a struct with no map form, because it holds data; `Visualize.Chart.render/2` draws it and the frame's `scales/1` stays the escape hatch. `Visualize.Chart.compose/2` merges two fragments by the merge rule of every key (§1.5, §8) and recurses into a node given as a map by both, so `frame.scales` union and `frame.axes` concatenate while `size` is the later's; a `:union` name declared differently in both is `:conflict` at its path, and the result is a fragment, validated only when it becomes a chart.

**Consequences.** One design binds to any pool with the fields it declares — the metresis designs of `test/support/designs/` now name a `primary` slot with a default and title and unit variables, and render byte for byte what they did. A binding fault reads as the contract failing (`sources.primary: has no field :v`) rather than as a mark drawing nothing. The applied chart is the value the compiler of §12 will take a design *to*, per binding, so item 6 owes no new struct for "a design with its data". Composition recursing into nodes makes a house frame fragment (size, margins, a standard axis) meaningful, at the cost that `:override` on a node key means "the later's, key by key" rather than "the later's, whole" — the schema's own per-key rules inside the frame are what a reader consults. A variable's *type* is still unknown until application (D-59): a wrong value is a validator error at the variable's path, not a resolution error.

## 66. D-66 — A compiled chart is the frame's regions, each static and drawn once or dynamic with a closure, a target per mark fixed at compilation, and a plot-area canvas for the streaming window

**Context.** Work item 6 of #53 had to turn the agreed prose of [14-declarative-chart](14-declarative-chart.md) §12 into a contract. Four things constrained it: `Visualize.Chart.Frame.generate/2` draws the furniture and the marks as one group in one order (§4.7), and the byte-for-byte holds of #59–#61 are only worth keeping if the split reassembles to that group; the hybrid chart map of [09-rendering-backends](09-rendering-backends.md) §6 has one static SVG layer and one canvas layer, and a mark that stays on SVG but changes every tick fits neither; `Visualize.Incremental` exposes the strip at the canvas's own edge (spec/09 §5.3), so a canvas that carries the margins never exposes the newest points; and the target of a mark decides which DOM the mark lives in, which cannot flip mid-stream.

**Decision.** The compiled chart is the frame's drawing order as a list of *regions* — the grid, each mark, each axis, the legend, the labels, or a facet's panels — each static, drawn once at compilation, or dynamic with one closure over the frame of the tick and its sources, and a reason: a mark is always the data, the panels are the facet values, and anything else is dynamic exactly when it reads a scale whose domain moves — inferred, or windowed by the viewport (§12.2). Per tick the frame is realised again over the sources (that is where inference runs) and only the dynamic closures run; validation is paid once. The hybrid chart map gains one key, `dynamic_svg`, for the SVG layer's moving part, and the consumer stacks three layers, the canvas under the static SVG under the dynamic SVG; the one-string form `svg/2` reassembles the frame's own group and equals `Visualize.Chart.Frame.render/2` when every mark is SVG, which is the hold on the split (§12.4). A mark's target is decided once, at compilation, from the rows it draws over the binding — `:auto` is SVG at or below the ceiling and the binary path (canvas without Nx) above it — and reported with its count; a facet's marks stay in the SVG layer; a canvas mark's inline label is drawn in the SVG layer, since the canvas has no text (§12.3). A viewport windows its scale to `[hi − span, hi]` with `hi` the newest bound value or a `now:` clock, the compiled chart holds `Visualize.Incremental` state between ticks, and the incremental canvas is the plot area, sized `plot` and placed at the margin's offset, so the exposed strip is where the newest points land; the copy-shift holds only while every other scale the canvas marks read is fixed, else every tick is a full redraw (§12.5). The claim is measured on the performance page, old path against compiled path, and the numbers live on the issue (§12.6).

**Consequences.** A static region's bytes are identical across data changes by construction, and `dynamic_regions/1` tells an author which domain to fix to shrink the diff. The split does not re-order the frame — `svg/2` is the frame — but the layered form draws the dynamic SVG over the static SVG, so with an inferred axis the marks sit above the furniture rather than between the grid and the axes; that is the price of two strings. Recompilation is the only way a target changes, which keeps a mark's DOM stable through a stream and makes "rebind a slot, recompile" the documented move. The compiled struct holds closures, so it is a runtime value with no map form; the compile-time macro entry point of the research notes waits on a data form of the regions. `Visualize.Backend.Hybrid.render_dynamic/1` is documented as passing the binary formats through, which it always did.

## 67. D-67 — A mark names the scale a channel family reads through, and the worked example of §13 is a design that renders

**Context.** The worked example of [14-declarative-chart](14-declarative-chart.md) §13 is held to show every construct of the layer once, and its third mark — a `:rect` over a `:bin` of `v` — is where the `:data` node, the transform and the generic mark appear. Its bins are numbers drawn as `x0`/`x1`, and §4.3 binds `x0` and `x1` to `x` by the frame's kind, so in a frame whose `x` is `:time` `Visualize.Chart.Frame.new/2` raised `scale :x (time) cannot take 97.5` and the example applied only without that mark (#62). Proposal #72 named two shapes that keep every construct — a marginal histogram of `v` along `y`, or a histogram on a second linear scale with its own axis on `:top` — and both need what the layer did not have: a way for one mark to read a channel family through a scale the frame declares under another name. §4.3 already gives such a scale a range (it follows the first axis that names it) and D-60 the unit domain when no channel names it, but no channel could name it.

**Decision.** A mark takes a `scales` node, `:mark_scales`, whose keys are the scale names of the kinds' tables (`x`, `y`, `angle`, `r`, `color`) and whose values are declared scale names: `scales: %{x: :count}` sends the mark's `x`, `x0` and `x1` through `count` instead of `x`, for inference, for placement, and for the compiler's reading of which scales a mark moves with (§4.3, §5.1, §12.2). A channel not renamed reads the kind's table as before; a style's `{:field, f}` colour reads `color` whatever the mark says. The example's third mark bins `v` and draws the bins along the frame's own `y` — the scale `v` is drawn on, so they line up with the line — with the counts from `0` through `count`, a linear scale over `[0, :auto]` with `range: [0, 120]` and an axis on `:top`: a marginal histogram in a 120-pixel strip at the plot's left edge, which is the second of #72's shapes with the first's alignment. §13 says the design renders and `test/visualize/chart/example_test.exs` holds it, from the spec's own fenced block, through `Visualize.Chart.apply/2`, `Visualize.Chart.render/2` on SVG and canvas, and `Visualize.Chart.compile/2`.

**Consequences.** A second scale of any name is now a mark's to draw on — a dual-axis chart is two marks with `scales: %{y: :right}` on one and an axis on `:right` — without a new mark type, a new channel or a frame kind, and the rename is a name reference like every other, so fragments compose and the map stays diffable (§1.2). The worked example stays the one place every construct appears, and a reader can now run it; the example test extracts the block from the spec, so the text and the design cannot drift. The price is one more node kind (twenty-five) and a key on the mark that most designs never write; the validator holds its values to declared scales, and a key the kind does not use is inert rather than an error, which keeps a fragment written for a cartesian frame valid when composed into a polar one.

## 68. D-68 — A row filter is a transform step, tested under `test` against `value`

**Context.** Work item 1 of #71. A mark draws every row its pipeline yields (spec/14 §5.4, D-62), and nothing in the pipeline could drop a row by a predicate — so a treemap design could not say "the leaves alone" nor a sunburst "the nodes below the root with a positive extent", and the two hierarchy presets that need those were drawn by their components from `Visualize.Chart.Mark.rows/3` rather than by the layer (D-64, spec/14 §15.4). The proposal sketched the predicate's comparison under `op`, but a transform node already owns `op` for the step's name: `%{op: :filter, field: :height, op: :eq, value: 0}` is not a map.

**Decision.** `:filter` is a data step of [14-declarative-chart](14-declarative-chart.md) §5.4.1 with `field` required, `test` (`{:enum, [:eq, :ne, :lt, :lte, :gt, :gte, :present, :absent]}`, `:present` by default) and `value` (`:term`, `nil` by default): the rows, in order, whose reading of `field` passes the test against `value`. `:present` and `:absent` are the two tests of a `nil` reading; `:eq` and `:ne` are `==` and `!=`; the four orderings keep a reading that is not `nil` and stands so to `value` under Elixir's term ordering, the ordering `:sort` already uses, so a `nil` reading never passes one. A column the rows lack reads `nil`, as every step reads it (§5.4). The step is pure over its rows and reads no scale, as D-62 requires, so it runs before the frame's scales and its result is what inference sees.

**Consequences.** A design can now say which rows a mark draws — the leaves of a hierarchy (`height` equal to `0`), the nodes below its root (`depth` greater than `0`), the arcs with a positive value — with a step rather than by leaving rows to a component, which is what items 2 and 3 of #71 build on for the hierarchy presets. The comparison lives under `test`, so a reader of a node sees the step's name once and its predicate beside it; `value` is a `:term`, which is what the JSON form and the validator already carry for a variable's default (§1.4). A test on a field the rows never carry filters everything out under `:present` and nothing under `:absent`, silently: the step reads what the rows have, and the validator holds a field to the source's declared `fields` only where the mark reads the source directly (§5.4).

## 69. D-69 — An `:arc` takes its angles from `start` and `end` channels when a datum carries them, and from the pie otherwise

**Context.** Work item 2 of #71. The `:arc` mark of [14-declarative-chart](14-declarative-chart.md) §5.6 is `Visualize.Shape.Pie` over `value` and then `Visualize.Shape.Arc` per slice, with `inner` and `outer` per datum but the angles only ever the pie's. A sunburst's angles are its own rows' `x0` and `x1` from `Visualize.Layout.Partition`, so the design of the `sunburst_chart` preset could not say what the component draws (D-64, §15.4). Two ways were open: a new mark type for "an arc over its own angles", or two more channels on `:arc`. A new type would duplicate the radii channels, the options and the label rule for the sake of one difference — where the angles come from.

**Decision.** `start` and `end` are optional channels of `:arc`, radians, read as pixels in every frame kind like `value` (no scale of §4.3 names them). Given together they replace the pie: every datum placed on both is one `Visualize.Shape.Arc.generate_path/2` over its own angles and radii — `inner`/`outer`, else the radius options — with `pad_angle` as the arc's own `Visualize.Shape.Arc.pad_angle/2` and `corner_radius` as before, byte for byte the direct call (D-61); `start_angle` and `end_angle`, the pie's, are not read then, and the inline label's anchor is `Visualize.Shape.Arc.centroid/2` over the same angles (§5.5). `value` is required unless both are given, and either of the pair requires the other; the validator reports the missing key as `:required`, while `Visualize.Chart.Schema.channels(:arc)` keeps `value` required and lists `start` and `end` optional, since the schema's tables are per type and the pair rule is the one rule the tables cannot carry (§5.2).

**Consequences.** A partition's rows draw as arcs without leaving the layer: `channels: %{start: :x0, end: :x1, inner: :y0, outer: :y1}` is the sunburst of spec/10 §3.4, which is what item 3 of #71 declares in the preset. With `value` alone nothing changes, so every pie design renders as before. The one asymmetry is that `pad_angle` means the pie's padding in one form and the arc's own in the other — the same number, applied by the generator that owns the angles in each case — and `start_angle`/`end_angle` are inert with the pair, as `radius` is inert on a `:circle` with `value`.

## 70. D-70 — A tree's link is drawn in the orientation the transform names, and the hierarchy presets are drawn by the layer

**Context.** Work item 3 of #71, the last of the three gaps D-64 left. The `:links` rows of a `:tree` or a `:cluster` carried one `path`, the vertical link of [14-declarative-chart](14-declarative-chart.md) §5.4.2, while spec/10 §3.2's horizontal tree lays out over `[height, width]` and draws the same cubic with the axes swapped; a `:path` mark over such a layout drew the wrong curve, so the `tree_diagram` component built its links by hand. With D-68's filter and D-69's angles in place, what remained was to decide where the orientation lives — on the transform, whose rows carry the path, or on the mark, which draws paths as they are — and then whether the components should keep drawing their hierarchy from rows now that their designs can say what they draw.

**Decision.** `orientation` (`{:enum, [:vertical, :horizontal]}`, `:vertical`, `:geometry`) is a key of the `:transform` node read by `:tree` and `:cluster`: the `:links` rows' `path` is the link of that orientation — horizontal is `M y0,x0 C ym,x0 ym,x1 y1,x1` with `ym` the mid-depth — while every position the rows carry (`x`, `y`, `x0`, `y0`, `x1`, `y1`) stays the layout's own, so a hierarchy step lays out exactly as a direct call would in either orientation (§5.4.2) and a `:circle` over a horizontal tree swaps its channels, as the preset does. The three hierarchy presets now declare what the component draws (§15.4): the tree's transform carries the orientation and its `:circle` a `label`; the treemap filters to the leaves; the sunburst filters to the nodes below the root with a positive value and its `:arc` reads `start: :x0, end: :x1` with an `arc_label` at the centroid, as the pie preset does. The components draw from the layer where that now holds — the tree's link `d`, the treemap's rectangles, the sunburst's arcs and label positions are the elements of `Visualize.Chart.Frame.generate/2` — and keep only what is not a mark: the tree's circle-and-text group and the two label rules of spec/10 §3.3–3.4. §15.4's caveat is removed, D-64's consequence carries the closing reference, and `test/visualize/chart/presets_test.exs` holds `Visualize.Chart.Frame.render/2` of each design to the elements the component prints.

**Consequences.** Every one of the ten component designs is now drawn by the layer, so a stored hierarchy design binds to any node-list source and renders the same leaves, arcs and links the component would; the ten render goldens are unchanged, which is the proof the elements were already the layer's numbers. The orientation is a property of the rows rather than of the mark, which keeps the `:path` mark a mark that draws what it is given (D-62) and lets a second mark — a label at the link's midpoint, say — read the same rows. The price is that a horizontal tree is still declared as `size: [height, width]` plus the swapped `:circle` channels, because the rows' positions do not swap: a transform that put its rows in page coordinates would have made `size` mean something other than the layout's own, which §5.4.2 promises it does not. The tree's node group remains the one thing a component draws that its design does not say in full — the text's anchor and offset follow the node — and the `:circle` mark with a `label` is the nearest the layer has, recorded as such rather than closed with a mark type for one component.

## 71. D-71 — A component's `d` is the one serialiser's, and the tree's link goldens carry the comma form

**Context.** Every `d` the library prints is `Visualize.IR.Path.to_string/1`, the one `d` serialisation of [02-architecture](02-architecture.md) §2.2 (D-12, D-34): coordinates comma-separated, commands concatenated, `M0,280C140,280,140,70,280,70`. The one exception was the tree of spec/10 §3.2, whose links printed the cubic's three points space-separated, `M0,280C140,280 140,70 280,70`, a hand format from before the component was a preset (D-64). D-70 made the link the `:tree` transform's own `path`, yet the component re-formatted it by hand, solely to keep `test/support/components/tree_diagram.svg` and `tree_diagram_vertical.svg` byte for byte — the ten component goldens were #71's acceptance and could not change there — and `test/visualize/chart/presets_test.exs` normalised the separator to compare the layer's `d` with the component's. Both forms are valid SVG; the markup was inconsistent and the hand formatter was a second serialiser in miniature. Root cause: the component predates the serialiser rule and kept its own string, undetected because the goldens record whatever a component prints and nothing held a component's `d` to `Visualize.IR.Path.to_string/1`.

**Decision.** The tree's link `d` is `Visualize.IR.Path.to_string/1` of the row's `path`, printed through the same helper every other component path goes through; the hand formatter is deleted and spec/10 §3.2 says so. The two tree goldens are regenerated and differ from before only in the `C` separators; the other eight hierarchy goldens and the seventeen chart goldens are unchanged. The presets test compares the layer's `d` with the component's byte for byte. Recurrence guard: `test/visualize/components/tree_test.exs` holds every `d` attribute of every golden under `test/support/components/` space-free — the form only the one serialiser prints — so a hand format cannot return in any component ([12-testing-and-conformance](12-testing-and-conformance.md) §6).

**Consequences.** D-34's rule now holds without exception across every component, and a consumer reading a component's `d` — a diff tool, a stylesheet, a test of their own — sees one form. A golden that records the comma form of a link is the first component golden to change since D-46 for a reason other than a deliberate change of what is drawn: the curve is the same, only its spelling. A future component that prints a path by hand fails the guard before its golden is recorded, which is the check that was missing when the tree's form was recorded.

## 72. D-72 — The contour grid is a tuple padded once and the tracing takes each segment once, because a list read by index cost the default density grid minutes

**Context.** `Visualize.Contour.Density.compute/2` with the defaults of [07-geo](07-geo.md) §7.1 — 960 × 500 pixels, `cell_size` 4, so a 241 × 126 grid, 20 thresholds — took 159 s for two points, while the same two points on a 51 × 51 grid took 1.3 s: twelve times the cells, a hundred and twenty times the time (#67, surfaced by #66's tabular test, which hit ExUnit's timeout and kept its density test on a 40 × 40 grid). Profiled before anything was changed: on the 51 × 51 grid the list walk inside `Enum.at/3` was 371 million calls and all but a few tens of milliseconds of the run. The callers were `Visualize.Contour`'s corner reads and its padding step, both `Enum.at/3` on the flat value list: four reads per cell per threshold and one per padded cell per threshold, each a walk of half the list on average, so a threshold cost O(cells²) and the whole run 30k × 4 × 20 × 15k ≈ 36 billion list steps — the 159 s. Measured, 51² → 101² (four times the cells) took fourteen times as long. The kernel accumulation was linear and 26 ms on the default grid, though `List.update_at/3` on the flat list made each touched cell O(cells), which two points never show and a thousand would. The ring tracing appended to the ring with `++` and rescanned the remaining segments per step, quadratic in the segment count but 14 ms here; it also looked a point up in a start map it never consumed from, so a corner exactly on a threshold — two segments out of one point — cycled without closing, and `Visualize.Contour.compute/2` on the moduledoc's own grid with a threshold count of 3 never returned. Why undetected: nothing in the suite computed a density contour, and the two tests that touch `Visualize.Contour.compute/2` use a 4 × 4 grid, where a quadratic walk is a few thousand steps.

**Decision.** `Visualize.Contour.compute/2` turns the grid into a tuple and pads it once per call, before the thresholds, and reads corners with `elem/2`; segments are collected by prepending and reversed, so their order is the cell order it always was. The tracing follows segments through the map from start point to end, popping a point's entry as it is followed and removing the ring's points when it closes, so it is linear in the segment count and terminates on every input; the first hop of a ring is the segment the cell order presents, as before. `Visualize.Contour.Density` accumulates each point's footprint into a map keyed by cell index, in point order, and materialises the grid once. The rings are the same, point for point: `test/support/contour_golden.txt` was recorded from the list-walking code over five grids with smoothing on and off, nested and flat, and three density configurations over forty weighted points, and the rewrite reproduces it byte for byte ([12-testing-and-conformance](12-testing-and-conformance.md) §6). §6.3 and §7.2 now state the complexity, and §7.2 the default-grid guarantee. Recurrence guard: `test/visualize/contour_test.exs` holds `Visualize.Contour.Density.compute/2` on the default grid over a handful of points to twenty contours inside a time bound, and holds four times the cells to at most about five times the time. The degenerate grid — a start point with two segments — now traces one ring and drops the other segment rather than looping; what it should trace is left open in §6.3 and filed as its own proposal, since the golden holds only non-degenerate grids.

**Consequences.** The default configuration is usable: the two points that took 159 s take milliseconds, and a chart's `:density` transform ([14-declarative-chart](14-declarative-chart.md) §5.4) can run on a plot-sized grid rather than a toy one. A grid of a million cells costs a million reads per threshold rather than a trillion, and a thousand points cost a thousand footprints rather than a thousand grid copies. The price is one tuple of the padded grid per call and a map of the segments per threshold, both linear in the input. A future rewrite of the tracing is held to the recorded rings, and the default-grid test fails before a consumer's chart does.

## 73. D-73 — The path records have an f32 form, and the encoder writes it by default because canvas coordinates are pixels

**Context.** #63's measurement (spec/14 §12.6) found the binary canvas path costing more bytes per update than SVG text for the same mark: a 200-point `monotone_x` line was 13,060 B through `render: :binary` against 10,635 B through `render: :svg`, and 65,328 against 52,355 at 1,000 points. The first of the three causes #73 names is in the wire format of [09-rendering-backends](09-rendering-backends.md) §4: every coordinate is an f64 — 16 bytes per polyline point against the ~12 characters of `L123.4,56.7`, 49 bytes per `C` against ~40 characters — for a value the canvas rasterises to a pixel. The precision was never chosen; the records were written with `Nx.tensor(type: :f64)` because that is what the first encoder had to hand, and nothing measured the stream against the text it was meant to replace. Two things constrained the change: `Visualize.Shape.LineNx.to_binary/6,7` (spec/04 §10.2) writes the f64 `path` record directly and a consumer may hold streams so written, and D-36's round-trip property `decode(encode(path)) == expand_smooth(path)` is exact, which an f32 coordinate cannot be.

**Decision.** Two new opcodes, `path32` (`0x05`) and `path_cubic32` (`0x06`), are the `path` and `path_cubic` records with every f64 coordinate a little-endian f32 and nothing else changed — the same counts, sub-opcodes, argument order and u32 arc flags (§4.3.8–4.3.9). The f64 records stay defined and decodable. `Visualize.Backend.CanvasBinary.encode_path/2` and `encode_element/2` take `precision: :f32 | :f64`, `:f32` by default, and §4.5 states the rule and its bound: an f32 holds a value below 16,384 to within 2⁻¹¹ px, far under the half pixel a rasteriser resolves, so the default is the pixel case and `:f64` is the opt-out for values that are not pixels, writing the stream byte for byte as before. The option governs the path records alone; `circles`, `rects` and `transform` keep f64. The decoder of [10-liveview-integration](10-liveview-integration.md) §8.3 and `test/support/canvas_binary_decoder.ex` gain the two cases, and the round-trip property becomes `decode(encode(path))` equal to the expanded path with every argument rounded to its nearest f32 — exact again, over the rounding the format states — with the bound itself held over random coordinates.

**Consequences.** A polyline point is 8 bytes and a `C` 25, half the stream for the same picture, before #73's second item removes the per-command sub-opcode from a cubic run. A consumer that decodes streams with its own reader sees two new opcodes and must add the cases, which the version bump carries; one that only feeds `Visualize.Hooks.CanvasBinary` sees nothing. The compact record's choice is unchanged — one `M` then `L`s, everything else the command stream (D-36) — so the selection rule gains a dimension, not a case. The one thing lost is the f64 exactness of a coordinate a stylesheet or a test reads back from the stream; the test suite reads it back through the rounding, and a caller who needs the value exact passes `precision: :f64`.

## 74. D-74 — A cubic run is one record with no sub-opcodes, because that is what every curve generator emits

**Context.** Work item 2 of #73. After D-73 halved the coordinates, a `monotone_x` line of `n` points was still `path_cubic32`: 5 bytes of header, 9 for the `M`, then 25 per `C` — a sub-opcode byte before every six f32, one byte in twenty-five spent saying "cubic" to a decoder that, for this path, already knows. The command stream is general on purpose (D-36 made it hold every command exactly), but the path a curve generator of [04-shapes-and-curves](04-shapes-and-curves.md) §5 draws is not general: `Visualize.Shape.Curve.monotone_x/1`, `monotone_y/1`, `cardinal/2`, `catmull_rom/2` and `natural/1` each emit one `M` and then `C`s and nothing else, and a curved `:line` mark ([14-declarative-chart](14-declarative-chart.md) §5) is exactly one such path. `basis/1` alone brackets its curves with an `L` at each end and so keeps the command stream, at one byte per curve more. The polyline already had its own record for the same reason; the cubic run did not.

**Decision.** `cubic_run` (`0x07`) is a start point and `count` absolute cubic curves, six f32 each, with no sub-opcode ([09-rendering-backends](09-rendering-backends.md) §4.3.10): `13 + 24·count` bytes. The encoder writes it, under the f32 default, for a command list that is one `M` followed by one or more `C` after smooth expansion (§4.5); a mixed or closed path stays a command stream, and under `precision: :f64` the run is a `path_cubic` record — there is no f64 run, since the f64 records exist for what already relied on them and nothing relied on this one. The decoder of [10-liveview-integration](10-liveview-integration.md) §8.3 and `test/support/canvas_binary_decoder.ex` gain the case, `test/support/path_gen.ex` a generator for runs, and the round trip holds as for D-73's records. The measurement of §12.6 is repeated with the record in place and recorded on #73.

**Consequences.** A curved line of `n` points is `13 + 24·(n − 1)` bytes before base64 — for 200 points 4,789 against 9,780 as `path_cubic` — which is what turns the binary path from larger than SVG text to smaller for the mark #63 measured. The stream has a third path record whose choice is by shape, so a reader that dispatches on opcode sees three shapes of path rather than one; each is the smallest exact encoding of its shape, and the selection is a property of the command list alone, so an author does not choose. A run whose points exceed the f32 bound of D-73 goes through `precision: :f64` as a command stream, at twice the size, which is the same trade the other records make.

## 75. D-75 — A tooltip reads the datum's fields from data attributes the server wrote on the element

**Context.** Work item 1 of #56. Hovering a chart showed nothing: the hooks of [10-liveview-integration](10-liveview-integration.md) zoom, brush, replay a canvas and report a size, and none shows a datum. Tucan gets tooltips from the Vega runtime, which holds the data in the browser; metresis's spec (§8.5) lists tooltip and crosshair as interaction that must feel instant and therefore lives in a hook. Three places could hold the fields: the LiveView, reached by a round trip per hover, which is the latency the requirement rules out; a JSON blob per chart with an index on each element, which is a second serialisation of the rows beside the SVG and needs Jason, an optional dependency; or the element itself, as data attributes, which the SVG already carries per datum and which a stylesheet, a test and a screen reader can read without a hook. The mark layer knows each element's datum (`%{element, datum, …}` is what every builder returns, spec/14 §5.6), so the attributes cost the mark nothing it did not have.

**Decision.** `Visualize.IR.Element.datum/2` writes `data-datum` (the field names in order) and `data-datum-<field>` per field — `_` as `-`, `to_string/1` of the value, `nil` omitted — under string keys, which the renderer prints as given (spec/09 §2.4), so no atom is created from a row's keys ([02-architecture](02-architecture.md) §2.1). A mark's `:tooltip` node (`fields`, `template`, `event`; [14-declarative-chart](14-declarative-chart.md) §5.7) writes them through it on every element, with `data-tooltip-template` and `data-tooltip-event` on the group; a per-series path carries its series' first row. `TooltipHook` (spec/10 §12) finds the datum element with `closest('[data-datum]')`, builds the text from the nearest template or as `field: value` lines, sets it as `textContent`, positions a `<div>` it owns, and pushes the fields on click only when an event is named; `destroyed()` removes what `mounted()` added (D-47). The hook joins `Visualize.Hooks.js_code/0` under D-48's export test.

**Consequences.** A tooltip costs one attribute per field per element in the SVG and no server time per hover; the template keeps formatting on the server, in the design, where the rest of the chart's text lives. The hook is a DOM interaction and so an SVG one: a mark the compiler sends to the canvas layer (spec/14 §12.3) has no element per datum, and its hover is the crosshair of work item 2, which reads an array the server writes once. Values are strings in the event and in the attributes, so a server that needs the number parses it — the price of not serialising types into markup. A field list of `[]` writes the whole row, which for a wide source is many bytes per element; a design names its fields.

## 76. D-76 — The crosshair snaps to an array the server wrote once and draws in an overlay it owns

**Context.** Work item 2 of #56. A crosshair — a rule at the nearest x and a marker per series there — is the hover a time-series panel needs (metresis §8.5), and it has to work over whatever the compiler chose to draw the mark with: a mark of a thousand points goes to the canvas layer (spec/14 §12.3, D-66), where there is no element per datum for a DOM hit test to find, and `TooltipHook` (D-75) is therefore SVG-only. Two things followed. Snapping must not read the DOM: the data the pointer snaps to has to reach the browser as data. And the rule and markers must not live inside the chart's markup: a LiveView patch re-renders the chart on every tick, and a node a hook appended inside the patched subtree is removed by the next patch unless the subtree is `phx-update="ignore"`, which would stop the chart updating — the very case the crosshair is for.

**Decision.** `Visualize.Hooks.Crosshair.attrs/3` computes, once per render and for one mark, the arrays of [10-liveview-integration](10-liveview-integration.md) §13.2 from the placed pixel rows the mark itself draws — `data-xs` (the distinct x pixels, ascending), `data-ys` (one array per series, `null` where the series has no datum), `data-plot` and `data-size` — printed through `Visualize.IR.Path.format_number/1`, so nothing depends on Jason. `CrosshairHook` (§13.3) finds the nearest x by binary search over `data-xs`, draws the rule and the markers in an overlay `<svg>` appended to `document.body` and placed over the container's bounding rectangle on every move, with the chart's `viewBox` so a CSS-scaled chart maps correctly, and re-reads the arrays on `updated()`. It emits nothing unless `data-crosshair-event` names an event, and then only `{index}` when the index changes; `destroyed()` removes the listeners and the overlay (D-47). The hook joins `Visualize.Hooks.js_code/0` under D-48's export test. A `:facet` or `:polar` frame raises: the crosshair is one plot area's interaction.

**Consequences.** The same attributes serve an SVG mark and a canvas one, so the hover a dashboard gets does not change when a series grows past the compiler's ceiling and moves to the canvas. The cost is `n + n·s` numbers per render beside the chart, sent whether or not anyone hovers; a chart with thousands of points is already paying for its coordinates once, and the arrays are the second copy — the price of not hit-testing. An overlay outside the container is positioned by measurement on each move rather than by the DOM, so it follows a scrolled or resized page without an observer and disappears with the hook. A host that wants the hovered values, not the index, pairs the event with its own rows: the index is into the mark's placed rows in x order, which the server can recompute from the same `attrs/3` call.

## 77. D-77 — A legend entry and a series path carry the same `data-series`, and the hook hides with `display`, not `hidden`

**Context.** Work item 3 of #56. The legend of D-60 explains a scale but names nothing: a click on an entry had no way to find the series it stands for, so a chart could not hide a series from its legend, which every dashboard legend does. The proposal sketched an `id` per series group; the layer draws a `:line` or an `:area` with a `series` channel as one `path` per series inside the mark's group (D-61), not as a group per series, and an `id` must be unique in the document, which two charts of the same design on one page would break. The proposal also said the hook toggles the `hidden` attribute. It does not work: the HTML user-agent stylesheet's `[hidden] { display: none }` rule is scoped to the HTML namespace in every engine, so `hidden` on an SVG `<path>` or `<g>` inside an HTML document changes nothing.

**Decision.** Every `legend-item` group carries `data-series`, `to_string/1` of the entry's value, and every per-series path carries `data-series`, `to_string/1` of its series value ([14-declarative-chart](14-declarative-chart.md) §4.5, §5.6) — always, not under an option: the attribute is the contract, the hook is what makes it interactive, and a hand-built chart writes the same two attributes to take part. The frame and design goldens move by that one attribute. `LegendHook` ([10-liveview-integration](10-liveview-integration.md) §14) joins the two by the string: a click on an entry sets `display="none"` on every `.mark [data-series="<value>"]` under the hook element — `display` is an SVG presentation attribute and applies in the SVG namespace — marks the entry `legend-item-hidden` at `opacity="0.35"`, re-applies its hidden set on `updated()` so a server re-render does not show a hidden series again, and pushes `{series, hidden}` only when `data-legend-event` names an event. The hook joins `Visualize.Hooks.js_code/0` under D-48's export test.

**Consequences.** A series is hidden by a string the SVG already carried, so no id is minted and two charts of one design on a page do not collide; the two sides coincide when the legend explains the scale the series read through, and an entry no element matches toggles nothing rather than failing. The hidden set lives in the browser: the server's rows are untouched, the scales do not re-infer, and a toggle costs nothing on the wire unless the host asks for the event. The price is that a series hidden by the client is still rendered, sent and patched on every tick — a host that wants the chart to re-scale without the series takes the event and filters its rows, which is the round trip the client-side toggle exists to avoid for the common case. A per-datum mark coloured by a field (a `{:field, f}` fill) carries no `data-series`, since only the `series` channel names one; widening that is a later proposal.

**Widened by D-126 (#508):** every mark type that draws rows, except `:percentile_band`, takes `series`. Every element a series draws, and every label drawn for it, carries `data-series`. The entry stays as the record of the original contract.

## 78. D-78 — The builder is fragments folded by `compose/2`, and every option is a key of the node the function builds

**Context.** Work item 1 of #91. A design is a nested map ([14-declarative-chart](14-declarative-chart.md) §1.1) and written literally at length it is precise and unreadable: the worked example of §13 is sixty lines of punctuation, and a caller assembling one by hand gets no arity checking, no completion and no documentation of what a mark's channels are. `Visualize.Chart.Presets` builds ten designs in Elixir and had to grow its own private construction helpers to stay readable, which is the evidence that the helpers belong in the library rather than in one module's private section. Two shapes were open. A *builder struct* threaded through setters — `Chart.new() |> Chart.line(…)` — is the shape the rest of the library uses for scales and shapes, and it is the wrong one here: it makes the design a value only its own module can assemble, so a standard label set, a house style sheet and a chart's own keys cannot be written separately and combined, which is exactly what §8 already gives the map form. The alternative is that every function returns a *fragment* of the map and the fragments are folded, which needs no new value, no new merge rule and no new failure mode: `Visualize.Chart.compose/2` already merges by the schema's per-key rules and already reports `:conflict` where two fragments disagree.

**Decision.** `Visualize.Chart.Build` is a module to `import` whose every function returns a fragment — a design map carrying the keys that one function sets and no others — and whose pipe is `Visualize.Chart.compose/2` ([14-declarative-chart](14-declarative-chart.md) §16). Nothing in it validates, defaults or realises: the fold is a fragment and becomes a chart through `Visualize.Chart.from_map/1` or `Visualize.Chart.apply/2`, which is where a design MUST validate. Each function sets its node's required keys from its positional arguments and takes the node's remaining keys as `opts`, checked against `Visualize.Chart.Schema.describe/1` of that kind, so an option that is not a key of the node raises `ArgumentError` at the call; a mark's channels and options are checked the same way against `Visualize.Chart.Schema.channels/1` and `options/1` of its type. `Visualize.Chart.Build.var/2` declares a variable and its default under `vars`, and `Visualize.Chart.var/1` builds the term that uses one: different modules, different arities, so a caller may import both.

**Consequences.** The builder is sugar in the strict sense — every design it writes is writable as a literal, and the literal stays the contract — so nothing downstream learns about it: the schema, the validator, the codec, the frame and the compiler are untouched. Composition's rules become the builder's rules for free, which is what makes a house style sheet and a chart's own styles disagree loudly rather than silently, and it is also the limit: the builder cannot express an ordering the merge rules do not already give, and a design that wants one writes the map. The check on options is a run-time raise, not a compile-time one — the price of reading the schema as data rather than generating a struct per node — and it fires at the call rather than at the validator, which is the whole of its value. What the builder does not check is that a node's *required* keys are present: an `:arc`'s `value` is required only when it carries neither `start` nor `end` (D-69), and a rule with that much context belongs to the validator, which sees the whole design.

## 79. D-79 — The builder's mark, transform and scale functions are generated from the schema, and a `use`d module tells the surface scanner what it injects

**Context.** Work item 2 of #91. D-78's builder gave the generic `mark/4` and `scale/3` and four transform appenders written by hand. Written that way the builder has to be edited every time the schema grows — #71 added three transform ops and two mark channels in one work item — and the failure is silent: a design that names the new op still validates and renders, the builder simply has no function for it, so the drift is discovered by a caller and not by a gate. The schema is already data ([14-declarative-chart](14-declarative-chart.md) §14.2) and already answers every question the functions need: `Visualize.Chart.Schema.mark_types/0` with `channels/1` and `options/1`, `transform_ops/0` with `transform_keys/1`, and `scale_kinds/0`. Two names collide across those vocabularies: `band` is a mark type and a scale kind.

**Decision.** `Visualize.Chart.Build` uses a hidden macro module that reads the schema at compile time and emits one documented function per mark type (`<type>(data, channels, opts \\ [])`), per transform op (`<op>(data, …the op's required keys in the schema's order…, opts \\ [])`) and per scale kind (`<kind>_scale(name, opts \\ [])`), each delegating to the generic form ([14-declarative-chart](14-declarative-chart.md) §16.6). Every scale kind takes the `_scale` suffix, not only the colliding one. The four hand-written appenders are deleted; the generated ones replace them with the same shapes. A generated function is not in the source of `lib/`, and `Visualize.Audit.CodeScan` reads source rather than compiled modules, so the scanner now asks a `use`d module what it injects — a zero-arity, undocumented export, the same mechanism that already listed `child_spec/1` for `GenServer` — and the surface golden carries every generated function (spec/12 §2).

**Consequences.** A mark type, transform op or scale kind added to the schema adds its builder function, its documentation and its golden rows with no edit to the builder, and the property test over `Visualize.Chart.Schema.mark_types/0` proves the correspondence rather than asserting it by example. The costs are three. The functions are invisible to a reader of `lib/visualize/chart/build.ex`, who sees a `use` and must read the schema or the generated documentation to know what exists — the same trade every code-generating macro makes, and the reason the generic `mark/4` and `scale/3` stay public, since a design assembled from stored data has a type as a value, not as a name in the source. The golden's `Version` column for a generated row is a constant rather than a content hash, so it records that the function exists and not what it does; what would change it is the schema, whose own rows are hashed. And the `_scale` suffix is a name the schema does not contain, which is the one place the builder's vocabulary is not the schema's — the alternative, a suffix on the colliding name alone, would be a rule a reader has to look up.

## 80. D-80 — A style derives from a parent with `extends`, flattened at the lookup, and the presets are written with the builder

**Context.** Work item 3 of #91. A style could only be written whole ([14-declarative-chart](14-declarative-chart.md) §3): a dashboard that wanted "the series style, but heavier" copied four keys, and the copy drifted from the original the first time the original changed. Composition (§8, D-65) does not solve it — it merges *between* fragments and reports `:conflict` where two fragments declare one name differently, which is the opposite of what derivation asks for. Nor does the theme: a slot is a value, not a set of keys. What was missing is derivation *within* a style. Two places could hold the flattening: the resolver, `Visualize.Chart.Style.resolve/3`, which would then need the whole `styles` map it does not take; or the lookup by name, which already has it and which every consumer — marks, axes, the legend, labels, the scales' colour inference — already goes through.

**Decision.** A `:style` node takes `extends`, a `{:ref, :style}` ([14-declarative-chart](14-declarative-chart.md) §3.5), resolved against the declared styles and the built-in four. The lookup by name flattens the chain — the parent's keys first, the child's over them, each link over the one above it — and consumes the key, so a flattened node never carries `extends` and the resolver, the codec and every consumer are untouched. The validator resolves the reference like any other and reports `:cycle` at the style's path for every style that reaches itself, directly or through a chain; a cycle reached at resolution raises `ArgumentError`, as an unresolved variable does. `Visualize.Chart.Presets` is rewritten as fragments folded by `Visualize.Chart.compose/1`, its private construction helpers deleted, and the ten component goldens are unchanged by the rewrite.

**Consequences.** A style sheet becomes a small hierarchy rather than a list of near-copies, and a change to a parent reaches every child, which is the point and also the risk: `extends` is a reference, so a design that derives from `:axis` follows the theme's axis style wherever it goes. The flattening is at the lookup and therefore repeated per consumer rather than cached — a style is a handful of keys and a chain is short, so the cost is not worth a cache that would have to be invalidated when the frame's styles change. Derivation and composition remain separate, and deliberately so: `extends` is a value inside a style node while `compose/2` unions the `styles` maps, so a house style sheet composed with a chart's own still reports `:conflict` on a name they both declare, whatever either says about its parent. The presets are the evidence for the builder: what is left in that module is the assign mapping and the two decisions the assigns force — the kind of a series chart's `x` scale and a hierarchy's colour domain — with every design node built by a library function.

## 81. D-81 — `stack/1` is a second closed operation over fragments: the cascade, total, and returning the fragment itself

**Context.** Work item 1 of #92. `Visualize.Chart.compose/2` (D-65) unions two fragments and reports a name declared differently by both as `:conflict` at its path ([14-declarative-chart](14-declarative-chart.md) §8.1). That is right for what it was built for — a standard label set, a house source list and a chart's own keys assembled into one design, where a name declared twice is two authors believing they own it — and it is wrong for the thing a dashboard is: a house theme, a shared frame and a panel's own overrides, layered, where the higher layer is *supposed* to replace what the lower one said. With only the union, a template could be concatenated and not layered; there was no way to override an axis or a scale, only to append another. Three shapes were open. A flag on `compose/2` (`on_conflict: :later`) makes the loud failure a default a caller can quietly disable, which is the failure mode the `:conflict` exists to prevent. A merge of style *nodes* key by key, so that a higher layer's `%{stroke_width: 3}` keeps the lower's `stroke`, is the CSS analogy taken one level too far: it produces a style neither layer wrote, and the library already has the key-by-key operation on styles — `extends` (§3.5, D-80). And an `{:ok, fragment}` result, for symmetry with `compose/2`, would wrap a function that cannot fail.

**Decision.** `Visualize.Chart.stack/2` is a second operation, closed over fragments like the first, and `compose/2` is unchanged ([14-declarative-chart](14-declarative-chart.md) §8.2). For every key the higher layer wins: `:override` keys cascade key by key into a node both layers give as a map and are the higher layer's value whole otherwise, exactly as `compose/2` recurses; `:union` keys take the higher layer's entry *whole* for a name both declare, where `compose/2` reports `:conflict`; `:concat` keys append. `stack/1` folds a list left to right, the later layer the higher. The cascade cannot fail — a disagreement is what it resolves, not what it reports — so `stack/1` and `stack/2` return the fragment itself rather than an ok tuple. Nothing is validated or defaulted, so the result is a fragment and is a layer of another stack. Where two fragments are disjoint the two operations agree: `stack(a, b)` is the map of `compose(a, b)`, which is the property that holds them together as the merge rules grow.

**Consequences.** A dashboard gets the layering it needs without the assembly losing its loud failure: which behaviour a caller gets is the function they named, visible at the call site and in a stored pipeline, rather than an option somebody set once. The schema's merge rules serve both, so a key added to the schema reaches the cascade as it reaches the union, and neither operation grows a table of its own. The price is that the two are genuinely different values — `{:ok, map}` against `map` — so a caller cannot swap one for the other by editing a name, which is the intent: the disjointness property is the only place they are interchangeable, and it is stated and tested rather than assumed. The whole-entry rule for `:union` names is the one place a reader may expect a merge and not get one, and it is why `extends` exists; the cost of the alternative is a style with a parent's colour and a child's width that no layer declared and no author can find.

## 82. D-82 — An element's identity is its `id`, an axis's is `{scale, side}`, and a matched element is replaced whole

**Context.** Work item 2 of #92. The cascade of D-81 gave a higher layer the last word on every scalar and every declared name, and nothing at all on a *collection*: `marks`, `labels` and `axes` are `:concat` keys, so a panel layer that wanted the shared frame's x-axis with six ticks got a second x-axis drawn over the first, and a layer that wanted the house line drawn as an area got two marks. Appending is right when the elements are different things and wrong when they are the same thing said twice, and the map had no way to tell those apart: an element is a bare node with no name. Three shapes were open. Position — the *n*-th mark of the higher layer replaces the *n*-th of the lower — reads well in a two-layer example and is unusable in practice, since inserting one mark in a lower layer silently re-points every override above it. Structural equality of some subset of keys — two marks with the same `type` and `data` are the same mark — makes the identity depend on the very keys a layer is overriding. And a name minted by the library would have to survive storage, JSON and re-composition, which is a design's problem, not a value's.

**Decision.** Identity is *declared*, and only where it is meaningful ([14-declarative-chart](14-declarative-chart.md) §8.2). A mark (§5.1) and a label (§6.1) take an optional `id`, a `:name`, `:content`, no default, `:override`, reaching the schema, the validator, the JSON codec and `Visualize.Chart.Build` like any other key and rendering nothing. An axis needs no `id`: its identity is `{scale, side}`, always, because two axes drawing one scale on one side *are* one axis, and both keys are required already. A transform has none: a data pipeline is a sequence. In `Visualize.Chart.stack/2` a higher element whose identity equals a lower element's replaces it **in place**, keeping the lower's position; an element with no identity appends exactly as it did. The replacement is whole, not key by key, for the reason the `:union` rule is whole (D-81): a higher `%{id: :series, type: :area}` merged over a lower `:line` would keep the line's channels under the area's type and draw a mark no layer wrote. `Visualize.Chart.compose/2` is untouched — it never merges elements — so `id` is inert under composition and a design that never stacks pays nothing for it.

**Consequences.** A house frame, a theme and a panel's overrides now layer the way a stylesheet does, and the thing a layer overrides is named in the design rather than implied by an index, so a stored stack survives an edit to any layer below it. The stacked list is ordered by each identity's first appearance and carries its last value however the layers are grouped, which is what keeps the cascade associative over `:concat` keys and is held by a property. Three costs. An `id` is not checked to be unique — a fragment is not a whole design, and uniqueness is only meaningful once the layers are known — so the merge of two lists reads both layers as one sequence and collapses duplicates in either, which is what makes the ordering above hold whichever way the layers are grouped; the price is that a layer that declares an `id` twice loses one of the two the moment anything stacks with it, where a lone fragment keeps both. Associativity is worth more than that: without the collapse, `stack(stack(a, b), c)` and `stack(a, stack(b, c))` genuinely differ, which a property found before this decision was written down. Identity is per key, so a mark and a label may share an `id` and never meet — the alternative, one namespace for the design, would make the two collections interfere for no gain. And `id` is a key of the map that renders nothing, which the schema's facets record honestly as `:content`: it identifies, and identification is what that facet is for.

## 83. D-83 — Provenance is computed from the layers, not carried in the fragment

**Context.** Work item 3 of #92. A four-layer template is unreadable by eye: a value in the result may have been written by any layer and overridden by any number of them, and the design record asked for the inspector a builder UI renders and a human debugs with — for every leaf, the layer that set it and the layers it overrode ([14-declarative-chart](14-declarative-chart.md) §17.1). The proposal sketched it as a "layer tag carried through `stack/1`", and that is the shape the decision rejects. A fragment with tags in it is not a fragment: the tags are keys no node kind declares, so the tagged map does not validate, does not survive `Visualize.Chart.to_map/1` or the JSON form, and cannot be a layer of another stack — the closure property the whole algebra rests on (D-81). Tagging in a parallel structure returned beside the map has the opposite problem: the map and its tags are two values a caller must keep together, and every function that touches a fragment would have to learn about the second one.

**Decision.** `Visualize.Chart.explain/1` takes the *layers* — the same list `Visualize.Chart.stack/1` takes, each a fragment or a `{name, layer}` pair — and recomputes the stack, reporting one entry per leaf path of the result with the layer that set it and the layers it overrode, ordered by path ([14-declarative-chart](14-declarative-chart.md) §17.1). `Visualize.Chart.stack/1` and `explain/1` are one walk with two results: the merge of §8.2 is written once, returning the merged node and the paths the higher layer contributed, and the cascade is that walk with the second result dropped. A layer's name is its zero-based position unless the caller pairs it with one, so a stored stack — a list of fragment references — explains itself without a naming scheme.

**Consequences.** A fragment stays a fragment, so a stack is still a layer, a design still round-trips, and nothing downstream of the algebra learns that provenance exists. What `explain/1` reports cannot drift from what `stack/1` produces, because a divergence would need two implementations and there is one; the property that every leaf of the stack has exactly one entry whose value is the value at that path is what holds it. The cost is that provenance is recomputed rather than remembered — an inspector on a large stack pays the merge again — which is the right trade for a function a UI calls when a human opens a panel, not per tick. Two limits are the cascade's own and are stated rather than worked around: a value discarded by a whole-entry or whole-element replacement has no path in the result and so no entry, and `overrode` names only the layers that wrote a value at the same path.

## 84. D-84 — `free_vars/1` walks where application walks, and a variable bound to a variable is an error by path

**Context.** Work item 4 of #92. A stored template's variables were tracked by hand: nothing said which variables a design still demanded, what kind of value each wanted, or which of them had a default, so a dashboard offering a parameters form had to walk the map itself and repeat the rules of `Visualize.Chart.apply/2` ([14-declarative-chart](14-declarative-chart.md) §7.3) — and a walk that repeats those rules is a walk that will disagree with them. Layering made it worse rather than better: a higher layer may write a literal over a lower layer's variable and close it, or write a variable over a literal and open one, so the answer is a property of the *stack* and not of any fragment in it. There was also a hole under the rules themselves. Resolution is single level by design (D-59): the value a variable takes is used as it is. A value that was *itself* a variable was substituted silently, so the chart left application carrying a `%Visualize.Chart.Var{}` that nothing had reported, and the fault surfaced much later as an `ArgumentError` from the frame naming a variable no one had asked about.

**Decision.** `Visualize.Chart.free_vars/1` takes a fragment, a chart or the layers of a stack and returns one entry per variable still in the result — its default from the stacked `vars` node, whether it is required, and every path it stands at with the schema's expectation there and the layer that set the leaf it stands in ([14-declarative-chart](14-declarative-chart.md) §17.2). Its walk is application's own: it descends where the resolution step of `Visualize.Chart.apply/2` descends — nodes, name-keyed declarations, lists, text parts — and stops where that stops, so what it lists and what `apply/2` demands are the same set by construction rather than by agreement. The layers come from the provenance of §17.1, so the two inspectors share one fold. And `apply/2` now reports `{:var_binding, name}` at every path where a variable stood whose value, from `vars:` or from its own declared default, is itself a variable (§7.1, §10.1). A variable *nested inside* a bound value needs no rule of its own: the `vars` declarations are dropped before validation, so the validator already finds it as `{:undeclared, :var, name}` at its path.

**Consequences.** A parameters panel is a query rather than a walk, and it is honest under layering: a variable a higher layer wrote over disappears from the list, one a higher layer opened appears in it, and neither costs the UI any knowledge of the cascade. The expectation reported is the schema's own type term, so the panel chooses its widget from the same table the validator reads, and a schema addition reaches the panel with no edit. Two costs. The list is computed by walking the stack, so it is recomputed rather than cached — the same trade `explain/1` makes, and for the same reason. And a variable the resolution walk does not reach — inside an `:extent`, inside a bound value — is deliberately *not* listed, because listing it would promise a resolution that does not happen; those are the validator's to report, and saying so is better than a list that is right about the paths it knows and silently wrong about the rest.

## 85. D-85 — The builder is a LiveComponent whose only output is one message, and the host owns the route, the library and the meaning of saving

**Context.** Work item 1 of #54. A design is data (spec/14) and D-78 made one writable in Elixir, but nothing lets a person build one without writing Elixir, so every consumer that wants its users to define their own panels — metresis's Explore view is the first — either writes an editor against the schema itself or does without. Three shapes were open for where that editor lives. A `Phoenix.LiveView` owns a route and a session, which makes it a page a host mounts rather than a tool a host embeds, and a dashboard that wants the builder in a drawer beside the panel it is editing cannot have it. A Kino cell reaches only Livebook and would have to be written twice. And an editor in the consuming application repeats, per consumer, the one thing this library is uniquely able to supply — forms generated from the schema the validator reads. A fourth question sat under all of them: an editor is Phoenix, and this library's whole claim is that an application which only renders charts never fetches Phoenix (D-45).

**Decision.** `Visualize.Chart.Builder` is a `Phoenix.LiveComponent` in this library, embeddable in any host LiveView, and optional exactly as `Visualize.Components` is: every module of it is wrapped in `if Code.ensure_loaded?(Phoenix.Component)`, `phoenix_live_view` stays an optional dependency, and `scripts/consumer_check.exs` asserts the builder's absence from a bare consumer beside the assertions it already makes for the components and for Nx ([14-declarative-chart](14-declarative-chart.md) §18.1). The division of ownership is the contract: the host owns the route, the starting layers, the pool of sample sources, the library of saved fragments — through the three callbacks of `Visualize.Chart.Builder.Store`, so the builder never learns where a fragment is kept — and what saving means; the builder owns the editing state alone. Its one output is a message, `{on_save, id, design}` sent to the host LiveView process with `Visualize.Chart.stack/1` of the enabled layers (§18.2); it pushes no event and patches no URL. The preview is the ordinary path — `Visualize.Chart.apply/2` then `Visualize.Chart.render/2` — so what a person sees and what the host is handed cannot differ, and application's faults are a panel of the tool rather than an error page, since an incomplete design is the normal state of one being built (§18.4).

**Consequences.** The builder embeds where the design is used rather than on a page of its own, and a host with two of them tells them apart by the `id` the message carries. The store being a behaviour rather than a table means the library ships no persistence and no migration, and a host that has none passes `nil` and loses only the picker. Two costs are accepted. The editing state lives in the component, so a host that wants to restore a half-finished edit across a mount cannot: the builder's state is deliberately not part of its contract, and a design is what is stored. And a fault the frame *raises* rather than reports — a value a scale cannot take — still raises out of the preview, because those are faults of the host's data and catching them here would hide a bug in the host; the builder reports what the validator reports and nothing more.

## 86. D-86 — The editor's nodes, controls and values come from the schema's types, and a composite value is read as a literal and never evaluated

**Context.** Work item 2 of #54. An editor for a design is a form per node kind, and the obvious way to build one is to write the forms: a list of the nodes a person may open, a list of the keys each has, and a control per key. That is the shape the proposal ruled out in one line — "no form or field list is written by hand" — because the schema already answers all three questions as data (D-58) and a hand-written form is a second copy of it that drifts the first time a key is added; #71 added three transform ops and two mark channels in one work item, and a hand-written editor would have gone quietly stale, exactly as the builder's functions would have without D-79. Two questions had no obvious answer. Which *nodes* a person may open is not in the schema as a list — it is the shape of the fragment in front of them, which the schema types but does not enumerate. And a key whose value is composite — a text, an extent, a channel, a whole options node — has no HTML control: a design may hold lists, tuples, maps, a `%Visualize.Chart.Var{}` and a `DateTime`, and nothing in a browser produces one.

**Decision.** Three functions, each total over the schema and none carrying a list of its own ([14-declarative-chart](14-declarative-chart.md) §18.5–§18.7). `Visualize.Chart.Builder.Editor.nodes/1` walks a fragment by the *type* of each key it carries — descending a `{:node, kind}`, each name of a `{:map, {:node, kind}}`, each element of a `{:list, {:node, kind}}` and a `{:one_of, …}` given as a map — and returns the path, the kind and a readable label of every node it reaches; the walk stops at values, which are edited on the node that carries them. `Visualize.Chart.Builder.Editor.widget/1` maps a `t:Visualize.Chart.Schema.type/0` to one of five controls, and `Visualize.Chart.Builder.Editor.parse/2` reads a submitted string back. A composite value is a `:textarea` holding what `inspect/1` writes, read back by parsing the string with `Code.string_to_quoted/1` — parsed, never evaluated — and accepting the quoted form only where it is a number, a string, an atom, a boolean, `nil`, a list, a tuple, a map, a `%Visualize.Chart.Var{}` or a `~U` sigil: the whole vocabulary a design may contain (§1.4), and nothing else. `Visualize.Chart.Builder.Editor.panel/1` renders the node's keys, tabbed by facet in the order of §1.3 and ordered within a tab as `Visualize.Chart.Schema.describe/1` returns them, with each key's validation errors under its control.

**Consequences.** A key added to the schema gets its control, a node kind added becomes openable, and a type added needs a row in the widget table and nothing else — the one place the editor can fall behind, and a test asserts every key of every kind has a widget. Reading a literal rather than evaluating one means a design map cannot acquire a function or a term the JSON codec has no form for (§11), which is the requirement of §1.1 enforced at the one place a person types a term. Three costs are accepted. Reading an atom from a submitted string creates it, as the JSON codec already does and for the same reason — a design is authored — so a host exposing the builder to an untrusted author bounds that itself. A composite value is typed as Elixir rather than assembled from controls, which is a tool for an author and not for a novice; controls for a text's parts or an extent's ends would each be a hand-written form, which is what this decision exists to avoid. And the errors shown against a key are the *stacked* design's, since a fragment is not a design and only the stack can be validated (§8): where a higher layer replaced a value at the same path, the fault shown belongs to that layer's value, which is stated rather than papered over.

## 87. D-87 — Disabling a layer is not deleting it, the inspector is `explain/1` rendered, and the hook exists only where the browser knows something the server does not

**Context.** Work item 3 of #54. The stack panel and the inspector are the two panels that are *about* the algebra rather than about a node, and each had a shape to choose. A panel over a list of layers could offer delete, and a person who wants to see a design without its house theme would then have to delete the layer and put it back, which is destructive for a question that is not. The inspector could be a tree of the design annotated with provenance, which is the shape a reader expects and the wrong one: the provenance of §17.1 is a flat list keyed by path, and rendering it as a tree would mean a second walk that can disagree with `Visualize.Chart.explain/1` — the exact drift D-83 exists to prevent. And the proposal said the builder's "drag, reorder and click-to-inspect JavaScript ships as hooks", which invites a hook that owns the layer list in the browser and tells the server afterwards, the shape that makes a LiveView list and its DOM diverge.

**Decision.** The stack panel offers select, move, disable and drag, and nothing else ([14-declarative-chart](14-declarative-chart.md) §18.8): a disabled layer stays in the list and is dropped from the stack, so the design the preview draws and saving sends is `Visualize.Chart.stack/1` of the *enabled* layers and asking "what does this look like without it" is one click and one click back. The layers are listed in the order `Visualize.Chart.stack/1` reads them, the higher last, because a panel that reversed the cascade's own order would teach the opposite of the specification. The inspector is `Visualize.Chart.explain/1` rendered as it comes — one row per leaf path, ordered by path, with the value, the layer that set it and the layers it overrode (§18.9) — so what it shows and what `Visualize.Chart.stack/1` produces cannot drift, and a row carries its path and its layer's position so that clicking it selects that layer and opens the deepest node whose path is a prefix of the value's. `Visualize.Hooks.Builder` is one hook serving both panels and only the two gestures the browser knows something about: a drop position and a click on a row (§18.10, spec/10 §16). It moves nothing itself — the server owns the list and the next render is the move — and it pushes through `pushEventTo` when `data-builder-target` names the component, so the events reach the builder and not its host. It joins `Visualize.Hooks.js_code/0` under the export-uniqueness rule (D-48), which is now ten hooks.

**Consequences.** The three questions a person asks of a stack — what does each layer contribute, what happens without one, and where did this value come from — are a click each, and none of them edits anything. Keeping the drag in the browser and the move on the server means a failed drop leaves the list exactly as it was, and a server re-render is always the truth. Two costs. The inspector recomputes the stack on every render, which is `Visualize.Chart.explain/1`'s own trade (D-83) and right for a panel a person opens rather than a function a tick calls. And the two limits of provenance are visible in the panel rather than papered over: a value a higher layer discarded whole has no row, and `overrode` names only the layers that wrote at the same path — a reader looking for a discarded value finds the layer that discarded it on the sibling rows it did write.

## 88. D-88 — The parameters form edits the builder's own copy of the bindings, its widget is the schema's at the variable's path, and an imported document replaces the stack

**Context.** Work item 4 of #54. Two panels were left, and each turned on a question about *whose* value a thing is. The parameters form is a control per entry of `Visualize.Chart.free_vars/1`, and the obvious wiring — the form writes the `vars` assign the host gave — is wrong twice over: a LiveComponent may not write its host's assigns, and the host re-passes them on every parent render, so a typed value would vanish the next time anything else on the page changed. Which control each variable gets was the second question: a variable is untyped by design (D-59), and the only thing that says what it should look like is the schema's expectation at the path it stands at, which `free_vars/1` already reports as `expects`. And import had a shape to choose: a document could be added as a layer over the stack, which reads as "compose", or replace it, which reads as "open".

**Decision.** `Visualize.Chart.Builder.Params.panel/1` renders `Visualize.Chart.free_vars/1` and edits the builder's own copy of the bindings, taken from the `vars` assign once and never written back ([14-declarative-chart](14-declarative-chart.md) §18.11): the host gives the values the builder *starts* with, and a person exploring a template is not the host changing its mind. The preview applies with that copy, so the chart on the screen is the parameterised chart, while the design saving sends carries the variables and not the values, because a template's parameters are supplied at application and are not part of what is stored (§7.3). The control is `Visualize.Chart.Builder.Editor.widget/1` of the *first use's* `expects`, and the value is read back by `Visualize.Chart.Builder.Editor.parse/2` of that same type, so the parameters form and the fragment editor choose from one table (D-86) and a schema addition reaches both. `Visualize.Chart.Builder.Document.panel/1` exports `Visualize.Chart.to_json/1` of the stacked design — which demands a design, so a fragment that does not validate is its errors, the same message the preview is already showing — and an imported document becomes the builder's **single layer**, named `imported`: a document is a whole design, not a layer of somebody else's stack, and dropping it on top would silently mix two designs. Both are behind the optional Jason dependency and report `{:missing_dependency, :jason}` in the panel rather than raising (§18.12).

**Consequences.** A stored template gains a parameters panel that cannot disagree with what application will demand, and the whole of the interaction between variables and the cascade — a higher layer that writes a literal closes a variable, one that writes a variable opens it — is visible in the form as one control appearing or disappearing. Three costs. A variable used at two paths of different types takes the first use's control, in path order; every path is listed beside the control, so a person can see what they are setting, and a form that offered two controls for one variable would be lying about how many values there are. The bindings being the builder's own means a host cannot drive them after mount — it can only give the values the builder starts with — which is the same trade the rest of the editing state makes (D-85). And import replacing the stack means a person who wanted to compose must add the document to the library and then add it as a layer, which is two steps for the rarer of the two intentions and no steps for the common one.

## 89. D-89 — The surface gate fails on any row that is not `specified`, and the golden's columns are described as the renderer emits them

**Context.** Work item 1 of #96. [12-testing-and-conformance](12-testing-and-conformance.md) §2 has said since it was written that `mix vis.surface --check` must fail "when the regenerated file differs from the committed one **or when any row is not `specified`**", and only the first half was built: the task read `API_SURFACE.md` and compared it byte for byte with what `Visualize.Audit.render/1` produced. The two halves look interchangeable and are not. The golden is *generated*, and it is regenerated as part of any change that moves the surface, so a public function nobody declared in `spec/` lands in the committed file in the same commit that introduces it — the byte comparison is satisfied by the very act that created the drift, the count line moves from `unspecified 0` to `unspecified 1`, and the pipeline is green. The one gate that exists to catch an undeclared public function could not catch one. That the count has stood at zero is evidence of a discipline kept by hand, not of a gate, and a discipline kept by hand is the condition under which a missing gate goes unnoticed. Two of §2's column descriptions had drifted the other way: **Version** was described as the library version at which the row was regenerated and **Locus** as a content hash, which is the scheme that predates the content hash `Version` carries today, and the golden's own header line had been saying the opposite for as long.

**Decision.** The specification is the source of truth for the gate, so the code moves: `mix vis.surface --check` fails when the rendered golden differs from the committed file **and** when any row's status is not `specified`, naming every offending row with the remedy for its kind — write the row for an `unspecified` function, build it or delete the row for an `unimplemented` one. Drift is reported first, because a stale file makes any statement about its rows an answer about the wrong document. Bare `mix vis.surface` keeps reporting the two backlogs without failing, which is the form to run while the work is in hand, and [12-testing-and-conformance](12-testing-and-conformance.md) §2 now says so. For the columns the code is right and the document was stale, so §2's bullets are rewritten to what `Visualize.Audit.render/1` emits — **Version** the content hash of the definition with all its clauses, which is what lets two work items that touch different functions regenerate the golden without conflicting on merge, and **Locus** the source file — and the row tuple is named for the `Function` column the renderer actually writes rather than the `Item` it never did.

**Consequences.** A public function added without a row in `spec/` now fails `mix vis.check` at the `verify:api-surface` job rather than being absorbed into the golden, and so does a `spec/` row whose function has been deleted or renamed; the failure names them, so the remedy is the message rather than a diff. The specification's own rule — every public function has a contract row — is enforced by the gate that claims to enforce it instead of by whoever reads the stats line. Two costs are accepted. A work item that adds a function and its `spec/` row in separate commits has a red commit in the middle, which is the same shape the spec-before-code order already imposes and is not a new constraint on how work lands. And the gate now depends on the scan's judgement of what is public, not merely on two byte strings, so a scanner defect can fail a build that would previously have passed: that is the trade the specification asks for, and the scanner's judgement is already what the committed golden's every row is built from.

## 90. D-90 — A documented example is run or it is not documentation, and the IR neighbourhood is gated on it

**Context.** Work item 1 of #90. `Visualize.IR.Element`'s moduledoc ended its `## Examples` block with `%Visualize.IR.Element{type: :path, path: path, style: %{…}, ...}`, and `...` is not Elixir. The example had never been executed: no test file named the module, so nothing ever compiled the block, and the error surfaced only when #87 added `doctest Element` for `Visualize.IR.Element.datum/2` and had to write `except: [:moduledoc]` to get its own tests green. Reading the neighbourhood showed the same hole twice more — `Visualize.IR.Path` and `Visualize.IR.Transform` each carry examples in their moduledoc and in their function docs, and no test file named either module, so nine correct doctests were also going unrun. The failure is not that an example was wrong; it is that nothing in the suite could tell a wrong example from a right one.

**Decision.** The example is rewritten as a doctest that asserts what the constructor actually returns — the element's `type`, its `style` and its `path` — rather than an approximation of the struct's inspect form, and `except: [:moduledoc]` is dropped. `Visualize.IR.Path` and `Visualize.IR.Transform` are doctested where their transform contract is already tested; nothing in either example changes, because both were already correct and only unrun. `test/visualize/ir/moduledoc_examples_test.exs` is the guard ([12-testing-and-conformance](12-testing-and-conformance.md) §6): every module under `lib/visualize/ir/` whose moduledoc contains an `iex>` prompt MUST be named by a `doctest` somewhere in `test/`, with `except: [:moduledoc]` not counting, and the scan resolves a `doctest` through its file's aliases so the short form is what a test file may keep writing.

**Consequences.** The three IR modules' examples are compiled and checked on every run, and a new example added to that namespace is red until a test file runs it — the class of defect, not the instance. Two limits are accepted. The guard is scoped to `lib/visualize/ir/`, which is where the defect was found and where the examples are load-bearing for backend authors; extending it library-wide would fail today on modules whose examples have the same rot and is a separate change with a separate cost. And the scan reads test sources rather than the compiled suite, so a `doctest` inside a comment would satisfy it; that is a false negative a reviewer sees and not a false positive that blocks work.

## 91. D-91 — A warning in a test file is a red build, and `mix compile` was never the gate that could see one

**Context.** Work item 1 of #85. `mix test` printed one compiler warning on `main` — Elixir 1.20's "pattern matching on 0.0 is equivalent to matching only on +0.0", raised by the viewport test of [14-declarative-chart](14-declarative-chart.md) §12.5, which matched the exposed strip as `{{80.0, 0.0, 20.0, 100.0}, records}` while writing the record's own `dy` as `+0.0` two arguments earlier. The warning arrived with the Elixir 1.20 upgrade and stayed, and the reason it stayed is the part worth recording. The pipeline already had a warning gate — `mix compile --force --warnings-as-errors` in `MIX_ENV=test` — and §4 read as though it covered the suite. It does not and could not: `mix compile` compiles what `elixirc_paths/1` lists, which in the test environment is `lib`, `audit`, `specs` and `test/support`, and a `*_test.exs` file is compiled by `mix test` and by nothing else. The gate that looked like the guard was structurally incapable of being one, which is why nobody noticed its absence.

**Decision.** The pattern is written `+0.0`, which is the value `Visualize.Backend.CanvasIncremental.exposed_regions/2` produces — every coordinate reaches the encoder as a float — so the assertion is unchanged in what it demands and now says so in the spelling Elixir 1.20 requires. The `check` stage's test job becomes `mix test --warnings-as-errors --cover`, in `.gitlab-ci.yml` and in `scripts/check.exs` alike, and [12-testing-and-conformance](12-testing-and-conformance.md) §4 states why the two warning gates are both needed and what each one sees. `--warnings-as-errors` on `mix test` exits non-zero *after* the suite has run, so a warning never hides a failure and a failure never hides a warning.

**Consequences.** A warning raised while compiling a test file is red rather than scrolled past, which is the class this defect belongs to; the suite carried exactly one such warning, and a sweep of the whole of `test/` for the same construct found no other bare `0.0` in a pattern position — every other `0.0` in the suite is a value. Two costs are accepted. Warnings raised by a test's own `IO` output are not covered and never were — the ExTLA checker prints several by design — so the gate is about diagnostics the compiler emits, not about the word "warning" appearing on the terminal. And a deprecation introduced by a future Elixir will now stop the build at the `check` stage rather than being absorbed, which is the point and is also work that arrives with the upgrade instead of after it.

## 92. D-92 — A struct is a leaf of every walk, and a whole-node variable at a `:union` key is a value two fragments can disagree about

**Context.** Work item 1 of #101. The `:union` clause of the composition walk (`lib/visualize/chart/compose.ex`) guarded `is_map(va) and is_map(vb)` and then `Map.merge`d the two by name. A `%Visualize.Chart.Var{}` is a map, and [14-declarative-chart](14-declarative-chart.md) §1.4 admits one "wherever a value of any type is expected, including in place of a whole node" — so `styles: Visualize.Chart.var(:sheet)`, the documented way to leave a whole declaration to application, composed with a fragment that declared a style into `%{name: :sheet, series: …, __struct__: Visualize.Chart.Var}`: a struct carrying foreign keys, which is neither a variable nor a styles map, and which validation (§10), the JSON codec (§11) and the resolution of §7.3 each mis-read in their own way. Both argument orders produced it, so a lower fragment could corrupt a higher one's value. The audit the proposal asked for found the walk divided against itself: the cascade (`stack.ex`), resolution (`template.ex`), validation (`validator.ex`) and the builder's node list (`builder/editor.ex`) all exclude a struct at every map descent; the codec (`codec.ex`) excludes one on the encoding side and not on the decoding side; and composition (`compose.ex`) excluded one under `:override` and not under `:union`. Four walks had the rule, two had holes, and nowhere was the rule written down — which is why the sixth walk could be written without it. The property test could not have caught it either: composition's generator builds fragments from concrete value generators and never puts a variable at a `:union` key, so the class was outside the generator rather than outside the assertions.

**Decision.** The rule is stated once, in [14-declarative-chart](14-declarative-chart.md) §1.4, and it is *a struct is a leaf of every walk*: a variable stands **for** a value rather than being one, so no walk descends into a struct or merges one as a map, whatever type the schema gives the key it sits at, and that a struct is also a map in Elixir is never a licence to treat it as one. Composition's `:union` clause now unites only where both fragments give a node map, and the codec's decoding clauses exclude a struct as its encoding clauses already did.

What a variable AT a `:union` key *means* is the second half, and the two operations answer it differently, as §8.3 says they must. Under `Visualize.Chart.compose/2` the key is one value: equal in both fragments it is taken once, and different — a variable against a declaration, or two variables of different names — it is **`:conflict` at the key's own path**, with the later fragment's value in the result. The alternative was to let the later fragment win silently, which is what `:override` does; it was rejected because it would erase the whole point of the operation. `compose/2` assembles parts *meant to be disjoint* (§8.3), and a fragment saying "the styles are application's to supply" against one that declares `:series` is precisely the case where two authors each believe they own the node and neither wrote the result. Composing them silently would discard a house style sheet without a word — a worse failure than the malformed struct, because it is quiet. Under `Visualize.Chart.stack/2` the same pair is the higher layer's value whole, which is what the cascade already did and what a cascade is for. `:concat` keys are unchanged: a struct is not a list, so the later fragment's value is taken whole and no fault is reported, because a collection has no names for two fragments to claim.

**Consequences.** The reproduction returns a well-formed result in both orders, and no walk in `lib/visualize/chart/` can produce a struct with foreign keys. The guard found a second thing on the way, and it is a limit rather than a defect: the cascade is **not** associative over a mergeable key that some layer gives as a struct. `stack([%{vars: %{n: …}}, %{vars: var(:sheet)}, %{vars: %{}}])` is `%{vars: %{}}`, while `stack(a, stack(b, c))` of the same three is `%{vars: %{n: …}}`, and the same holds for a node key such as `frame`. A struct at such a key *erases* what the layers below declared, and an erasure in the middle of a fold can only survive regrouping if the intermediate result carries a mark saying so — which would make it something other than a fragment, and §8.2's whole premise is that it is one. `Visualize.Chart.stack/1` is defined as the left fold and computes it, `Visualize.Chart.explain/1` folds the same way, and [14-declarative-chart](14-declarative-chart.md) §8.2 now states the qualification instead of claiming associativity unconditionally. It was invisible for the same reason the corruption was: no generator had ever put a variable at one of those keys. The recurrence guard is in the generator, not in an example: `test/visualize/chart/compose_test.exs`'s fragment generator now offers a whole-node variable as an alternative at every `:union` key — `sources`, `vars`, `styles` and `frame.scales` — so the existing associativity, order-independence and conflict-shape properties exercise the class on every run, and `test/visualize/chart/stack_test.exs` holds the cascade over the same fragments to the higher layer's value. Two costs are accepted. A design that used to compose — by accident, into a term that could not validate — is now an explicit `:conflict`, which is a behaviour change for anything that was relying on the corruption; nothing could have been, since the result did not validate. And the conflict is reported at the key's path rather than per name, which is less precise than a name-level conflict and is the most that can be said: a variable has no names to attribute a fault to.

## 93. D-93 — The undoctested-example guard covers every module the library ships, and three examples were wrong

**Context.** Work items 1 and 2 of #118. D-90 scoped the guard to `lib/visualize/ir/` and said why: that is where the defect was found, and extending it library-wide would fail on modules whose examples had the same rot. Running the same scan over the whole library confirmed it — ten modules carried an `iex>` example that no test file compiled. Eight are the ones #118 lists; `Visualize.SVG.Path` and `Visualize.SVG.Element` are the same defect and were folded in rather than filed as a second proposal of one class. The scan the proposal reproduced reads the function documentation as well as the moduledoc, which is the honest boundary: `Visualize.Backend.CanvasIncremental` and `Visualize.SVG.Renderer` document their examples on functions, and an example under `@doc` is as much a published claim as one under `@moduledoc`.

**Decision.** Every module the application ships whose moduledoc or function documentation contains an `iex>` prompt MUST be named by a `doctest` somewhere in `test/`, with `except: [:moduledoc]` not counting, and the guard moves to `test/visualize/documented_examples_test.exs` because it is no longer about the IR namespace and no longer only about moduledocs. An example that genuinely cannot be a doctest — a random seed, a timestamp, a process identifier — is named in the guard's own exclusion list *with its reason*, so a skip is a reviewable row rather than an absence; the list is empty, because nothing in the library documents a non-deterministic value.

Three examples were corrected rather than merely wired, and an example is corrected, never weakened: if the documented result is wrong the document was lying and the fix is the truth.

- `Visualize.Chart.Schema.channels/1` documented `channels(:line)` as `%{required: [:x, :y], optional: []}`. It returns `%{required: [:x, :y], optional: [:series]}`, and [14-declarative-chart](14-declarative-chart.md) §5.2 gives `:line` the optional `series` channel that §5.6 draws one path per series from. The code was right and the document was lying — in the example a reader of §5.2 is most likely to copy.
- `Visualize.Backend.CanvasIncremental.copy_beneficial?/2` and `Visualize.Backend.CanvasIncremental.exposed_regions/2` wrote all five of their calls unqualified, as if the reader stood inside the module, and two of them trailed an explanatory comment on the expected-value line. Every documented *result* was correct; what was wrong is that neither a reader nor a doctest could run them. Each block now carries its own `alias` and the comments are prose.
- `Visualize.SVG`'s moduledoc example ended `|> SVG.render()` with no expected value. As a doctest that runs and asserts nothing, which is the same silence this guard exists to break, and it concealed that `Visualize.SVG.render/1` returns iodata rather than a binary. The example now flattens the result and shows the markup.

**Consequences.** Forty-two doctests run where twenty-eight did, and a new module anywhere in `lib/` that documents an example no test compiles is red rather than prose — the class, not the instance. The guard was proven to bite before shipping, with a temporary module under `lib/` carrying an `iex>` example and no `doctest`: the guard failed and named it, and the fixture was removed. Three costs are accepted. Widening to function documentation means a module that documents one example on one function must be doctested whole, which is stricter than the moduledoc rule and is the point: a `doctest` names a module, not a chunk. The scan still reads test sources rather than the compiled suite, so a `doctest` inside a comment satisfies it — a false negative a reviewer sees, and not a false positive that blocks work, exactly as D-90 accepted. And the exclusion list is a place where an example can legitimately stop being executed, which is a door D-90's rule did not have; it is narrow by construction — an entry states the module and the reason, and an entry whose reason is "it fails" is a corrected example wearing a disguise.

## 94. D-94 — The builder ships its own stylesheet and renders it inline, because a component nobody can read is not a component

**Context.** Work item 1 of #141. Every panel of the builder emitted stable, well-formed class names — `vis-builder-toolbar`, `vis-builder-layers`, `vis-builder-layer-selected`, `vis-builder-nodes`, `vis-builder-node-open` — and nothing in the repository styled one of them: `grep -rln "vis-builder" --include=*.css` returned nothing. The browser therefore laid the component out as eight sibling blocks in source order, every one full width, the preview below three forms, and the stack — the thing the tool exists to edit — an `<ol>` of bullet points. Both of the structural complaints that followed were presentation faults over events that already worked: `Layers.panel/1` emitted `draggable="true"` and `Visualize.Hooks.Builder` already turned a drop into a `reorder`, so dragging a layer reordered the stack and simply never looked like it could; and `<nav class="vis-builder-nodes">` was already a one-button-per-node control marking the open one, rendered as a wrapping wall of buttons.

**Decision.** The library ships the builder's stylesheet, and the builder renders it. `Visualize.Chart.Builder.css/0` returns it as a binary and `Visualize.Chart.Builder.styles/1` renders it in a `<style>` element, which follows `Visualize.Hooks.js_code/0` and `Visualize.Hooks.install!/0` rather than inventing a second convention for client assets. Rendering it inline by default is the substance of the decision: the alternative — documenting the class names and leaving the host to write the CSS — is what produced the current state, and it fails every host that has no asset pipeline, which includes the examples application this library is looked at through. Two invariants bound it: every rule is scoped under `.vis-builder`, so a guest component cannot restyle its host's page; and every colour and metric is a custom property on `.vis-builder`, so a host retheming sets properties rather than out-specifying rules it did not write.

**Consequences.** The builder is legible from the `<.live_component>` call alone, and the shell it draws — layer sidebar, node tab strip, editor, preview (spec/14 §18.13) — is a placement of the existing panels rather than a change to any of them: no event, assign, message or panel signature moves, so every test asserting on a panel's markup keeps its meaning. Three costs are accepted. The library now has an opinion about appearance, which it did not before, and that opinion will be wrong for somebody — the custom properties are the answer, and `styles={false}` is the escape for a host that would rather own the file. A stylesheet in a heredoc is not linted or minified by any tool in the pipeline, so the scoping invariant is held by a test that parses `css/0` for a selector escaping `.vis-builder` rather than by a CSS toolchain. And rendering `<style>` inside the component means the rules arrive once per builder on the page; the component is a whole-page tool and a second instance is not a case worth complicating the render for, so the duplication is accepted rather than deduplicated through the host.

## 95. D-95 — The stack is seeded and not controlled, because the builder is the thing that edits it

**Context.** Work item 1 of #143. The builder's `update/2` was unconditional — `assign(socket, assigns)` over everything the host passed — and the host passes `layers={@layers}` on every render. Since `layers` is precisely what the builder edits, every parent render replaced the edited stack with the host's pristine one. Reproduced against the component's own lifecycle: mount and update give `[:house, :shared, :panel]`, a `reorder` from 2 to 0 gives `[:panel, :house, :shared]`, and the next `update/2` with unchanged host assigns gives `[:house, :shared, :panel]` again. It presented as a dragged layer snapping back, but dragging was only the visible case: `move_layer`, `add_layer` from the library, a value written into a node by `edit`, and `import` were all discarded the same way, which is why choosing a fragment from the library and pressing Add layer appeared to do nothing at all.

**Decision.** `layers` is seeded from the host and thereafter owned by the builder. The component keeps the list the host last gave it and compares on each `update/2`: an incoming list that differs from the last-seen one is a host deliberately replacing the stack and is adopted, and an incoming list that is the same is a host re-rendering for its own reasons and is ignored in favour of what the builder has. Every other assign — `sources`, `vars`, `theme`, `store`, `on_save` — stays controlled in the ordinary way, because the builder never writes to any of them. The alternative, lifting the stack into the host so that every reorder is a message and a round trip through the host's state, was rejected: it makes the host own the editing session, which is exactly the division §18.1 draws the other way, and it would put a fragment-shaped assign and five event handlers in every page that wants a builder.

**Consequences.** An editing session survives an unrelated host render, which is what makes the builder usable at all; and a host that swaps the stack — loading a saved design, changing which chart is being edited — still gets what it asked for, because a changed list is adopted. The cost is that the builder's stack and the host's assign can differ, so a host reading `@layers` after a session sees what it passed and not what the person built. That is already the contract: the design leaves the builder through the save message of §18.2 and by no other route, and a host that wants the stack itself gets it there. A host that passes a freshly-built list on every render — `layers={build_layers(@thing)}` returning an equal-but-new list is fine, since the comparison is by value, but one returning a *different* value each render — would reset the session every time; that is a host bug the contract now names rather than a case to defend against.

## 96. D-96 — A reference is a choice among declared names, and the palette is what declares them

**Context.** Work item 4 of #143. Two halves of one gap. A design's `styles` is a map of name to style node and a mark refers to one by name, but nothing in the builder could add a name: §18.5 says adding a node is adding the key, and no control added one, so a style was editable only where a layer had already declared it. And a mark's `style` key, typed `{:ref, :style}`, got the `:text` widget — a person spelled a name into a box and found out from the error list whether it named anything.

**Decision.** The palette is a view of the **stacked** design and creating a style writes into the **selected** layer. Those are different designs on purpose: the stack is what a mark's reference resolves against, so a palette scoped to the selected layer would hide styles a mark may legitimately name; and every other edit the builder makes goes to the selected layer, so creation following the same rule keeps one answer to "where did that go". Selecting a style opens the node in the **highest** layer that declares it, which is the layer whose value the stack takes and the rule the inspector already follows. Separately, `Editor.panel/1` gains a `refs` assign and renders a select wherever it knows a reference's names, while `Editor.widget/1` stays a function of the type alone. Putting the narrowing in the panel rather than in `widget/1` is the substance: `widget/1` being total over the schema's types is the property that says the editor cannot fall behind it, and a `widget/2` taking a context would have made that property a statement about two arguments instead of one.

**Consequences.** A style can be made, seen and chosen without typing a name, and a `{:ref, kind}` anywhere else becomes a select the moment a caller can supply names for that kind — the mechanism is general, not a special case for styles. A name still becomes an atom from submitted text, through `Editor.parse/2` at `{:ref, :style}` so that the conversion has one home; that is the same exposure a typed reference already had, bounded by a person typing into a form rather than by anything a request can reach. Two costs are accepted. The palette lists what the stack declares, so a style declared by a layer the person cannot see is still offered — which is correct, and is why selecting one moves the selection to the layer that owns it. And a fragment must be allowed to gain a key it does not carry, so `update_node/3` now treats a missing container as an empty map; that is what "adding a node is adding the key" always meant, and it was previously a crash rather than an edit.

## 97. D-97 — A library item's name carries its place in the tree, and the parse is forgiving

**Context.** Work item 1 of #155. The store is a flat map of name to fragment (§18.3), browsed through a dropdown, and a library of any size is unusable that way. The tree has to group by something. Three candidates: a classification stored beside each fragment, one computed from the keys the fragment carries, or one carried by the name itself.

**Decision.** The name carries it: `<category>:<sub-category>:<fragment name>`, split on the first two separators. Storing a classification would change `Visualize.Chart.Builder.Store` — a behaviour hosts implement — to carry metadata the library alone cares about, and every host would have to migrate for a panel's benefit. Computing it from the fragment's keys sounds tidier and is worse: a fragment that sets a theme *and* an axis has no single answer, the answer changes when a person edits the fragment, and a person cannot put something where they want it. A name is what the person already types, it needs no schema change, and a category exists precisely because some item names it — which is what makes inventing one possible without a registry. The parse is total and forgiving, mapping a two-part name to a category and a leaf, a one-part name to `Uncategorised`, and an empty sub-category and the literal `none` to the same absent thing, so a store populated before this existed keeps working and a form that submits `none` agrees with a person who types nothing. The split takes the **first** two separators rather than the last, so a leaf may contain a colon and a fragment can be called `2:1 aspect`; the category and sub-category are picked from short lists, and only the name is free text.

**Consequences.** The tree is a pure function of `Store.list/0` and needs no state, so it cannot drift from what the store holds, and `Store` is untouched — a host that implemented it against §18.3 needs no change. Two costs are accepted. A colon is now significant in the first two positions of a stored name, so a host whose existing names contain one will see them grouped; that is a visible re-grouping rather than a loss, since the fragment is still there under whatever it parsed to. And renaming a fragment is how it is moved between categories, which means moving something is a save and a delete rather than an edit — acceptable while the store's contract has no rename, and the honest thing to fix in `Store` rather than to work around here.

## 98. D-98 — A style site takes a stack, and an edit lands in a local style that is created when it is needed

**Context.** Work item 1 of #163. Five keys refer to a style by name, each holding exactly one, and two problems followed from that. Applying a fragment from the library needed the fragment to know the *name* its target used, which it cannot — a fragment saying `styles.series` is inert against a design that called its style anything else (#161). And there was no safe place for an edit made at a site: changing a mark's stroke width meant editing the shared style it referenced, which silently restyled every other mark referencing the same name.

**Decision.** A style site takes a **stack** — a reference, an inline node, or a list of them — cascading left to right with the later winning, each entry resolved through its own `extends` chain first. A bare reference is a stack of one *by definition*, not by a compatibility shim, so every existing design is untouched and the private `node/2` of `Visualize.Chart.Style` remains the single point where a reference becomes a node. The last entry may be an inline **local style**, which is where an edit at the site lands, and it is **created on first edit** rather than always present: a document should not carry an empty map at every site of every mark, axis, label and legend to make one branch in the editor go away. The local style is anonymous in the document and `Visualize.Chart.explain/1` synthesises `<position>.local` for attribution — a display name, never a reference, since an inline node is not a key of `design.styles`.

Two rejected alternatives. Binding an identity into the fragment at drop time (#161's option A) answers "which name did you mean" with a dialogue; the stack answers it with the gesture, because a fragment dropped *onto a site* has already said where it goes. Storing style bodies rather than design fragments (#161's option C) would split the library into two kinds of item and break "a stack is a fragment", which is what makes saving a stack compose.

**Consequences.** Several styles compose at one site in the order a person put them, an edit at a site changes that site only, and dropping a style fragment onto a site needs no name to be chosen. The cascade rule is the one the document level already uses, so `explain/1`'s question — which layer said this, and what did it replace — is answerable at a site for the same reason it is answerable for a design. Three costs are accepted. `style` is no longer a single value, so anything reading it as one must read a stack — contained, because the eight readers all go through `node/2`. A local style has a path but no name, so the inspector shows a synthesised one and a person may reasonably try to reference it; the answer is that it names nothing, the same answer any undeclared name gets. And a stack makes it possible to write the same style twice at one site, which is harmless — the second merges over the first to no effect — and not worth forbidding.

## 99. D-99 — Identity lives in the library; a reference is an id; a declaration in place is only values

**Context.** #172, from the research of #162, #170 and #171. §18's builder held identity inside the thing being composed: a fragment declared styles by name in its own `styles` map, a mark referred to one by spelling that name, a library entry was a name that composites pointed at by spelling it, and a layer was its position in a list. Every one of those became friction the moment two things had to agree. Renaming a library entry meant finding and rewriting every reference in a collection of any size, atomically or not at all. Renaming a style inside a fragment meant the same walk over one document — cheap — but *across* fragments it could not be fixed by rewriting at all, because `panel`'s `style: :series` was panel's half of an agreement with whichever house layer it was stacked with, and rewriting it would break every other pairing. And ids alone would not have fixed that case either: two house layers minting their own ids could never be swapped for one another, because the "same" style has to be *agreed*, and a generated id cannot be agreed.

**Decision.** Three tiers, and a thing sits in the lowest that serves it. A declaration **in place** is values, with no name and no id — the local style of D-98 was already this. A **local key** is a name within one document, referenced only within it, where a rename is one walk of one immutable term and there is no second fragment to disagree; the hand-authored design of §13 lives here, unchanged. A **library entry** is where a thing goes when a second document needs it: the store issues it an id, its name and group become attributes, and nothing references the attributes. Promotion is a gesture, one tier at a time, reversible by copying the value back in, and nothing is forced upward. The cross-fragment agreement that names used to carry is replaced by pointing at the same id, and the swap-the-house-layer pattern that agreement served becomes explicit: the composite binds a variable to a style id, per site or globally, through the bindings of D-101. The key-versus-label split considered along the way — a stable authored key with a mutable display name — was withdrawn: it protected the rendezvous instead of removing it.

**Consequences.** Design is not a tier a thing can be *stored* at: the library holds bodies — one kind, values — and composites — sites, one kind each — and a map that is many things at once is refused, since the composite is what "many things" is for and Deploy is what a design is for. Nothing in the model ever has to hunt for a word: renaming touches one record, and there is no operation whose cost scales with the size of a collection except import, which is bounded and once (D-103). A fragment's body is readable as values, and a style fragment is `%{style: %{…}}` — a singular wrapper naming the kind, and nothing else — which is what keeps a body's kind derivable without the store having to say it. The costs are three. The hand-authored form and the builder's form are two rungs of one ladder, which is honest but means the `{:ref, kind}` type admits both a local key and an id, and the validator must treat them differently. A site's reference can now be to something that no longer exists, which D-100 resolves as a mask. And a thing shared by two sites in one document is *not* a library entry — a scale a mark and an axis share stays a local key in that composite — which is the correct reading of "only when it needs to be shared" and was briefly over-applied before being corrected.

## 100. D-100 — An id is tagged with its kind, issued by the store, never reused, and transparent when missing

**Context.** D-99 makes the id the reference. Three properties of the id were open: whether it carries its kind, who issues it, and what a reference to a deleted entry does.

**Decision.** An id is `{kind, n}` — `{:style, 11}`, `{:theme, 32}`. The tag makes the raw data and a JSON export readable without a lookup, and it is a type: the validator refuses `{:scale, 4}` where `{:ref, :style}` was expected *before* resolution, so a wrong-kind reference is an error at the cheapest moment rather than a silent mask. `n` is a per-kind sequence the store owns and never repeats; time-ordered unique ids were considered so that no store would have to persist a counter, and rejected because the store owns uniqueness regardless and a small integer is what makes the id readable — sortable by creation is a side effect. An id is unique within its store, and one store per builder is the contract; if that changes the store's identity joins the reference, not the number. A reference to an id the store no longer holds is a **mask of everything** at that site: lower entries show through, or the site's default applies, rendering never fails, and the builder shows the site as *no longer exists — showing through*.

**Consequences.** Deleting a library entry degrades every composite that used it, visibly, and breaks none of them — which is the behaviour a person wants from a library and the opposite of a dangling pointer. A stale id can never resolve to something new, so an old export imported late cannot silently pick up a stranger. The cost is that a missing reference is *not* a validation error, and a document that renders may be rendering less than its author meant; the builder's dangling row is what carries that information, and a host that wants strictness asks `flatten/2` for the unresolved set.

## 101. D-101 — A use site has four parts, four surfaces edit them, and the order of operations is stated once

**Context.** #171. A layer in a composite and an entry in a style stack were two different things — a `{name, fragment}` pair and an atom-or-map — and both were identified by position. Neither had anywhere to put what a placement needs to say about itself: what its variables mean here, and what should *not* apply here.

**Decision.** One struct, `%Visualize.Chart.Use{id, ref, mask, vars, local}`, for both. A struct is a leaf of every walk (D-92), which is the property that keeps `stack/1` from descending into a site and merging its parts. `ref` is which fragment; `mask` is what is removed here; `vars` is what the fragment's variables mean here; `local` is what is added here; `key` is the name a name-keyed kind is declared under here, so the composite and never the fragment says the name a reference will use. Each is written by exactly one surface — the library click, the context panel's checkboxes, the Variables tab, the field forms, the context panel's key control — so there is never a question of where an edit went. The order is `ref → local → mask → bind → stack`, with mask and bind after local so both speak for everything the site contributes — an inline site, which is nothing but local, is masked and bound like any other; mask-before-local, so a masked key could be re-set locally, was dropped because the local cascades over the reference regardless. A site has its own id because position was the wrong identity: the selection and the disabled set had to be remapped on every insert and move, a local style was named by an index a reorder changed, and a drop target was a number a client could not be trusted with. Disabling a layer is a mask of everything, so the one per-layer control the builder had is the degenerate case of the new part and not an exception to it.

**Consequences.** `moved/3` and `inserted/4` in the builder — which existed only to remap positions — go, and `explain/1` attributes to a site by id with a display name. The cost is a struct in the document where a tuple was, and a JSON form for it (§11) without which a composite exists only on the BEAM.

## 102. D-102 — Bindings are substitution at the site, before the cascade; a rename is legal there and refused at apply

**Context.** #171. A style carrying `var(:color)` at several sites needed, sometimes all in one composite, to share one colour, to take a literal at one site, and to take one of the *composite's* variables at another. Variables were one flat namespace bound once at `apply/2`, and binding a variable to another variable there was refused (#100) because nothing was left to bind it to. That refusal was right and was not the problem; the problem was that nothing happened earlier.

**Decision.** `Use.vars` is applied by `bind/2` on `Visualize.Chart`, a pure substitution over the fragment — every `%Var{name: k}` becomes the map's value for `k`, a literal or another `var/1` — at the site, before the cascade. The three needs are three states of one map: absent, a literal, a variable. The renamed case is legal here because substitution rewrites one name into the other before anything tries to bind it, and `free_vars/1` of the composite reports the outer name. Bindings nest like function application. The alternative — an identity hole in the fragment bound at drop, from #161 — was withdrawn once the style stack made the site the answer to "which"; and the alternative of lifting variables into a scoped namespace was not taken because a substitution produces a fragment the existing rules already accept, and a scope would have changed every one of them.

**Consequences.** `stack/1`, `apply/2`, `free_vars/1` and the validator are untouched. The Variables tab is where `Use.vars` is written, per site, and a row's state reads as one of three. The cost is that the binding is visible only in the structure: a flattened design carries the substituted result and no record that a rename happened, which is correct for a consumer and is why `explain/1` names the binding at the site.

## 103. D-103 — Save writes structure; flatten is a deployment verb; import is a relocation

**Context.** #170. "Save the stack as" wrote `Chart.stack/1` of the enabled layers — a snapshot that lost which layers, their order, and that a layer was a library reference. With D-99 that would have thrown away everything a reference is for. Separately, D-100's "unique within a store" means a collection crossing into another store must be renumbered, and renumbering rewrites references — the operation D-99 exists to avoid.

**Decision.** Save writes structure, always: a composite is stored as its uses and its own `vars`. `flatten/2` on `Visualize.Chart` resolves every use recursively through a store and stacks the result; a host calls it in its own pipeline at deployment, and the builder may offer it as a separately-labelled action, because saving and flattening are different verbs and offering the second never loses the first. A composite that reaches itself through any chain is refused as an `extends` cycle is. Import is the one place references are ever rewritten, and it has every property that ruled out renaming by name: bounded to a known set, once at the boundary between stores, and one call committed whole or not at all. Export takes the closure, so an import cannot dangle by construction; a bundle carries provenance so a repeated import is detectable; a reference outside a bundle — possible only in a hand-edited file — imports as a mask and is reported.

**Consequences.** A composite reopens as what was built. The save message of §18.2 carries structure, which changes the contract for a host that read a flat design from it; `flatten/2` is the one call that restores what it had. The store gains `import/2`, and the examples `Library` and metresis's store are the two implementations known to migrate.

## 104. D-104 — Edit and compose are two faces of every fragment, and compose is one operation at every level

**Context.** #170. §18's layout promoted the composite's composer — the layer stack — to a permanent column, and gave a kind no editor of its own, so a style could only ever be a layer and never a thing. Three operations were wanted: edit a kind, compose within a kind, compose the whole entity.

**Decision.** Composing within a kind and composing the entity are one operation, `stack/1`, differing only in what the stack may take: only style fragments, and the result is a style; anything, and the result is a design, with the node tabs and the preview appearing because there is now a chart to look at. Every fragment therefore has two faces — a form shaped by its kind, and a stack constrained to its kind — and the editor is one component whose shape is the open fragment's kind. The layer stack is the composite's compose face, not a region; §18.16's four regions are three. Click opens; drag composes; new-of-a-kind starts empty and valid; per-kind validity is the validator restricted to a kind.

**Consequences.** A style, a theme, or a composite can be opened, edited, composed, saved and reopened as what it was, through one editor. The style stack at a site (§3.6) turns out to have been this model applied at one site, which is why it fit. The cost is that the strip over a non-composite fragment is nearly empty — a style has one root and its variables — which is correct, and means the strip is mostly a composite's instrument.

## 105. D-105 — A rendered node carries its path only when asked, and the frame stamps what it places

**Context.** #259 (proposal B of #242). The builder's graph is a picture: nothing a mark, an axis, the legend or a label renders says which design node it is, so a click on the graph cannot select one and a stylesheet cannot outline it. The layer's own goal is a chart that is byte for byte the direct call (D-61), and a page's chart has no use for a path on every group.

**Decision.** `Visualize.Chart.Frame.generate/2` and `render/2` take `paths:`, `false` by default; with it every design node's element carries `data-node` with its path in the validator's spelling (§4.7). The frame stamps what it places — a generator's element is tagged at the position the frame drew it — rather than each generator being told its path, so a facet frame's copies of one mark carry one path, an axis keeps its position among `axes` whatever kind draws it, and no generator's signature changes. The builder is the caller that asks (§18.4).

**Consequences.** The default render is unchanged, held by a test that the output without the option equals the output before. A click on the graph can name what it hit and the ownership rule of §18.15 applies to it (#261). The option is a caller's intent about markup, like `resolve:`, not a tunable. A host that wants paths on a deployed chart may ask for them; nothing in the layer reads them.

## 106. D-106 — The scroll record names what scrolls

**Context.** #294. The `0x60` record of spec/09 §5.5 carried an offset and the exposed strips and nothing about the rectangle they belong to, so `CanvasIncrementalChart` could only copy the whole canvas. A chart that scrolls inside a margin — the High-Performance page's plot, with its axes in the margin around it — had its leftmost columns copied into the margin every frame and never cleared: after a minute the y axis sat under a smear of everything that had scrolled off the plot. The page could not say "the plot scrolls, the margin does not" because the record had no place to say it.

**Decision.** The record carries the viewport — `vx vy vw vh` after the offset, one fixed header of 27 bytes in every mode — and the decoder copies that rectangle alone, clipped to it, touching no pixel outside. `encode_incremental/4` takes it as `viewport:`, the whole canvas by default; `scroll_mode/2` classifies against the viewport's size, since that is what is copied. A full redraw writes the canvas size (or zeros from the arity-1 call) and does not consult it.

**Consequences.** Every existing caller — `Visualize.Incremental`, the benchmark, the examples' pages — encodes the same picture with sixteen more header bytes. A page whose plot sits in a margin passes the plot and its axes stay clean; the High-Performance page does, and its stream test holds the viewport to the plot. Both decoders read one header. The alternative, a full-canvas region every frame to clear the margin, would have replayed the whole chart and made the copy pointless.

## 107. D-107 — A consumer acknowledges frames, and a producer that skips a scroll frame resyncs with a full one

**Context.** #304. The High-Performance page pushes a frame per tick and LiveView's channel applies no backpressure, so at a frame rate the browser cannot draw, frames queue in its message loop, the canvas falls behind and every click waits behind the backlog. The page could not show it because nothing measured it, and could not bound it because nothing told it what the browser had drawn.

**Decision.** `CanvasIncrementalChart` acknowledges frames when its element asks with `data-ack`, at most once per interval with a trailing flush, reporting the last `seq` handled and the frames handled since (spec/10 §9.2). A producer that reads the acks may bound frames in flight by not sending; when what it skipped was a scroll frame, the next frame it sends is a full render, because copy-shift frames are consecutive by contract (spec/09 §5.3) and a strip against a canvas that missed one draws the wrong picture.

**Consequences.** The ack is opt-in per element and per payload, so every existing use of the hook is unchanged. A page can show client FPS, frames in flight and lag, and keep its controls responsive at any target rate by dropping frames the browser would not have drawn in time anyway — a live chart's stale frame is worth nothing. The cost of a drop is one full frame on resync; a producer that drops often is one whose rate is wrong, and the metric says so. A hold must time out (#306): a producer that waits for an ack it may never get — a client on an older hook, an element without `data-ack`, a `pushEvent` that failed — has turned flow control into a stopped stream; past the timeout it releases the window, and a run that never saw an ack stops holding at all and says so.

## 108. D-108 — A scroll strip draws its rows and three neighbours

**Context.** #332. A compiled chart's incremental scroll (spec/14 §12.5) drew the whole window into every exposed strip, on the reasoning that a path needs its neighbour beyond the strip's edge and the client clips anyway. The payload of a scroll was then the payload of a full redraw — 108 KB for the Scrolling series against the 4 KB its hand-built page sent — and the saving the incremental path exists for was the client's redraw alone.

**Decision.** A strip is built over the rows whose reading on the viewport scale lies within the strip's range, widened by three rows each side per source. Three is the most neighbours a curve of spec/04 reads: `:linear` none, `:monotone_x` one, `:basis` two; a fourth would be waste, a second would break a basis curve at the edge. The clip removes the overhang. The full redraw is the whole window, as before.

**Consequences.** A scroll payload is proportional to the strip, not the window. A mark whose geometry depends on rows far from the strip — a stacked area's baseline, a transform's output — is not a scroll candidate and `dynamic_regions/1`'s reasons already say when a canvas layer goes full per tick; a design author who sees seams at a strip's edge has a curve reading more than three neighbours, which no curve of the library does.

## 109. D-109 — A band within a band is an offset scale, not a transform

**Context.** #132. A grouped bar chart is two band scales, the inner ranged over the outer's bandwidth, and a mark read exactly one scale per channel family: nothing in the layer could put a datum *within* its band. Three workarounds were considered and rejected — one band over the category/series pairs loses the grouping, a facet frame is a different chart, precomputed pixels put the layout back in the caller.

**Decision.** The mark declares `offset: %{x: field}`; the frame declares an `x_offset` (or `y_offset`) scale of kind `:band` whose `:auto` range is the outer scale's bandwidth and whose domain is inferred from the offset fields; the channel reading adds the inner band's placement to the outer's. The alternative — a `:group` transform yielding band fractions — was rejected because a transform runs before the scales exist (§5.4) and would have had to be told the bandwidth some other way.

**Consequences.** A grouped bar is one `:rect` mark over one row per bar, expressible in the builder and the library like any other; `Examples.Charts.GroupedBarChart` converts. The reading adds, so an offset on `y` groups horizontal bars and an offset scale with one member draws the ungrouped chart. A family whose outer scale is not a band places nothing with an offset, since there is no band to be within.

## 110. D-110 — A graph's nodes are a second source, not columns of its flows

**Context.** #346. `:chord`, `:sankey` and `:force` read one source, the flows, and derive the nodes from the ids the flows mention, so a node row had an `id` and a position and nothing else. The force graph colours its nodes by `group` and the sankey names its nodes by a `name` that is not the `id`; neither had a row to come from. The sankey conversion made the names the ids; the force graph could not be converted at all.

**Decision.** The three graph steps take `nodes`, a source reference, whose rows are the node table keyed by the step's `id`. The layout's node rows carry the table's columns under the layout's own, in the table's order; a flow naming an id the table lacks is a fault, and a node the flows omit is laid out isolated. The pipeline's context grows the bound sources for this one read, and nothing else in it reads a second source. The alternative — node attributes carried on the flow rows as `source_group` and `target_group` — was rejected: a graph is two tables in every library the layer draws from, and a flow row is not the place for a node's columns.

**Consequences.** `Examples.Charts.ForceGraph` converts with its groups; `:force` also takes `strength` and `distance`, since a layout whose forces cannot be set fits one graph. A design without `nodes` behaves as before. The context of `Visualize.Chart.Transform.apply/3` is no longer only the plot and the projection, but it is still never a scale (D-62).

## 111. D-111 — A canvas draws by the effective style, not the element's own

**Context.** #231. The frame puts a mark's paint on the mark's group (spec/14 §5.6) so a theme reaches every element in one place, and the SVG backend inherits it as SVG does. The canvas backends set the group's paint into Canvas state (D-37) but decided whether to call `fill()` and `stroke()` — and the binary encoder whether to write the `fill`/`stroke` records — from the element's own style alone, which the generators leave empty for a line or an area. The state was set and never applied: a line chart on the canvas drew nothing, and every chart whose paint is per element (a bound fill) drew, which is why the gallery's Canvas mode looked right for bars and wrong for lines.

**Decision.** Both backends carry the inherited style down their walk. A group emits its own style (the JS backend did; the binary encoder now writes the style record inside the `save`/`restore`), and a leaf's draw calls are decided by its effective style — its own keys over the groups' above it, nearest last, so a leaf's `fill: :none` still suppresses an inherited fill. The alternative — the frame stamping the resolved paint onto every element for a canvas render — was rejected: it would make the SVG and canvas trees differ, and the group is the right place for a theme to reach.

**Consequences.** A design draws the same marks on every backend. A binary stream of a line chart gains one style record per mark group and one draw record per path — bytes the SVG never spent on the group either. The guard is a test over every gallery chart through the binary stream asserting a draw record per mark.

## 112. D-112 — A contour crossing never sits on a corner, and a ring closes on its exact start

**Context.** #81, after D-72. Two quirks of the marching-squares tracing survived D-72's rewrite: a corner whose value equals the threshold gave an interpolation `t` of exactly 0 or 1, so the crossings of the four cells meeting there landed on one point and the tracing — which joins segments by point identity — followed one ring through it and dropped the other's segment; and a ring closed when the current point came within 0.001 of its start, which along the padded border (a `min − 1000` rim makes `t` tiny) closed on the neighbouring crossing and emitted a two-point polyline. spec/07 §6.3 said the corner case was not specified, and the golden of #80 recorded the two-point rows.

**Decision.** `t` is clamped to `[10⁻⁶, 1 − 10⁻⁶]` with smoothing on, so a crossing is always on an edge's interior and two cells share a crossing only through the edge they share — the join by point identity is then unambiguous, since both cells compute that edge's crossing from the same corners. A ring closes only on its exact start point. The alternative, d3-contour's tracing by cell adjacency, was not taken: it replaces the whole walk to solve a case the clamp removes, and the clamp moves a crossing by less than a thousandth of a pixel at any plot size.

**Consequences.** Every ring a padded grid yields is closed, with at least four points; a property test over random integer grids and integer thresholds holds it, smoothing on and off. The contour golden changes on the rows that held a two-point polyline and on any grid with a corner on a threshold; the rest is byte for byte. `render/2` writes `Z` when the last point is the first, no tolerance.

## 113. D-113 — An absent declaring map is the empty set, not the unknown

**Context.** #368. The validator's reference context (`scales`, `styles`, `paints`, `sources`, `vars`) was built by `declared/1`, which returned `:unknown` for anything that was not a map — including `nil`, the value of a key the design simply did not write. A reference checked against `:unknown` passes, so `frame: %{axes: [%{scale: :x, …}]}` in a frame with no `scales` key validated and then raised `KeyError` in `Furniture.axis/2` when drawn, while the same axis beside `scales: %{y: …}` was the undeclared-scale fault it should be. `:unknown` was meant for one case only — a fragment validated alone as its kind (§19.6), where there is nothing to look a reference up in — and had leaked into the whole-design path through `nil`.

**Decision.** For a whole design, a declaring map that is absent is the empty set: every reference into it is `{:undeclared, kind, name}`. `:unknown` is reserved for `open_context/0`, the per-kind fragment check. A declaring key that is present but not a map is a type fault of its own and its references are not checked twice.

**Consequences.** A design that validates draws; the builder's preview lists the fault where it stood instead of crashing. Designs that relied on the hole — a `data: :s` with no `sources`, a `{:paint, g}` with no `defs` — now report it, which is what a validator is for.

## 114. D-114 — A cost claim is asserted by the work done, never by the clock

**Context.** #462, after #271. `Visualize.Chart.CompiledTest` held three cost claims of the scrolling canvas (#404, #410, #423) as ratios of two `:timer.tc` readings, and the file runs `async` beside CPU-heavy tests. Under load the #404 test failed — a 17 ms scroll against a 48 ms full redraw, on a bound of one third — and passed on every rerun, with nothing wrong in the code: the scroll does a ninth of the redraw's work. Four more files held absolute millisecond bounds (a fixed-domain frame, the default density grid, path building per curve type, the spectrum). #271 had already found the same defect in the contour test and fixed that one test by count.

**Decision.** No default-suite test asserts on wall-clock time (spec/12 §6). A cost claim is asserted by what the code under test exposes as a count, or by the reductions the calling process spends on one warmed run (`Visualize.Work.reductions/1`), which are the BEAM's own count of the work executed and do not move with load. A ratio stays a ratio — reductions of the cheap case against the dear one — so it tracks the algorithm across OTP releases rather than pinning an absolute figure. A timing worth keeping is a `@tag :benchmark` test, excluded by default. `test/visualize/no_wall_clock_test.exs` scans the suites and fails on a clock read outside a benchmark-tagged test.

**Consequences.** The suite no longer flakes on a loaded box, and a regression shows as a count rather than a slower run nobody chases. Counting the per-type curve test exposed three quadratic curves — `:natural`, `:basis_closed` and `:cardinal_closed` read points with `Enum.at/2` — which the two-second bound had hidden; they read a tuple now (spec/04 §5.1). Reductions miss work done in another process, so a function whose work is handed to a task is measured by a count of its own instead.

## 115. D-115 — A time scale's zone comes from the host's database, and an unreadable zone is a fault

**Context.** #447. `Visualize.Scale.Time` reduced every value to Unix seconds and drew every boundary in UTC: a "day" tick in New York stood at 20:00 or 19:00 local, a week began on Sunday evening, and an hourly axis across a transition repeated or skipped an hour without saying so. A caller could not fix it by shifting timestamps first — that turns instants into naive local times, so the repeated hour collides and the skipped one invents a gap. Reading a zone needs a time zone database, which Elixir does not ship: `Calendar.UTCOnlyTimeZoneDatabase` is the default, and `tz` or `tzdata` are the packages a host configures. spec/01 §1.5 allows the library no required dependency.

**Decision.** A time scale takes an optional `zone` (`Visualize.Scale.Time.zone/2`, the declarative `zone` key) and reads it through `DateTime.shift_zone/3` and `DateTime.new/4` against whatever `Calendar.TimeZoneDatabase` the host has configured; the library depends on no database. Storage stays Unix seconds, so `apply/2` and every position are unchanged and only the boundaries move: calendar units step on local dates and land on each date's local midnight, and sub-day units are the real instants whose local time is aligned, so a repeated hour ticks twice and a skipped hour not at all. A name the database cannot show is refused at the setter with `ArgumentError` and in a design as the validation fault `{:zone, name, reason}`, where `reason` is the database's own answer — `:time_zone_not_found`, or `:utc_only_time_zone_database` on a host with none. There is no fallback to UTC. Without a zone every result is the UTC one byte for byte. The test suite configures `tz` (`only: :test`, pure Elixir, no runtime dependencies of its own) in `test/test_helper.exs`, and one synchronous test swaps in `Calendar.UTCOnlyTimeZoneDatabase` to hold the refusal.

**Consequences.** A host that wants zones configures a database once — it almost always has one already for its own `DateTime` work — and a host that does not is told by name which zone it could not show, rather than drawing every tick hours off. The zone's rules are the host's, so two hosts on different database releases may tick a future transition differently; that is the database's contract, not the chart's. Sub-day ticks with a zone cost a database lookup per tick, and the transitions inside a step are found by bisection over the offsets, since `Calendar.TimeZoneDatabase` exposes offsets at an instant but not the instants at which they change. `scripts/consumer_check.exs` asserts that `Tz.TimeZoneDatabase` never reaches a consumer.

## 116. D-116 — A sync group shares hover in domain units, over DOM events on `document`

**Context.** #449, #466. A dashboard is a grid of charts over one time axis, and the interaction it needs most is the shared cursor: pointing at 14:05 on one panel shows every panel's value at 14:05. The crosshair and tooltip hooks each acted on one chart, so a host had to write JavaScript against hook internals that spec/01 keeps internal. Panels differ in width and margin, so a pixel position means a different instant on each. The crosshair already snapped to x pixels the server wrote (D-76), but no hook knew how to turn a pixel back into a value.

**Decision.** A design joins a group with `interaction: %{sync: group}` (spec/14 §2.9). The sync frame's markup then carries `data-vis-sync` and `data-vis-sync-x`, five numbers `d0,d1,x0,x1,width`: the x domain, its ends in chart pixels, and the chart's width. A time domain is in milliseconds since the Unix epoch, JavaScript's own unit. That is the whole scale a client needs for a linear or a time x, so the hooks map pixel to value and back by interpolation, with no scale code in the bundle. A hover publishes the pointer's x **in domain units** as a `CustomEvent` named `vis:sync:<group>` on `document`, with `detail: {x, source}`. A clear is `x: null`. Each member maps the value through its own five numbers, and a value outside its domain draws nothing. A member ignores its own echo by element identity: the source, an ancestor of it, or a descendant of it. A received hover never publishes and never pushes to the server. Only `:linear` and `:time` x scales sync, and the validator refuses any other kind by name, because the interpolation is that scale and no other: a band has no value between its categories, and a log scale is not linear in its pixels. The bus is one object of the bundle, `VisualizeSync`. Each hook binds its listener once, moves it on `updated()` and removes it on `destroyed()`.

**Consequences.** A host enables shared cursors by setting one design key per panel, with no JavaScript, and the attributes do nothing without the hooks (D-77). `document` is the bus, so members need no common ancestor and no registry. Any two hooks naming a group hear each other, which is the contract and also the only coupling. A group spans a page, not a LiveView, since events on `document` cross LiveView boundaries. The x mapping is the realised scale at render. A member whose domain moves on the server re-reads it on `updated()`, and between a patch and the next hover it maps through the domain it last read. A non-linear x, such as a log axis, is refused rather than approximated. Supporting one later means shipping the scale's mapping to the client, and that would be a new entry. The brush is #449's second work item, and it rides the same bus.

## 117. D-117 — A sync group shares the brush as a domain extent on its own event, and only the chart brushed pushes

**Context.** #449, #467. D-116 put hover on a bus in domain units. The brush is the other interaction a dashboard shares: a window brushed on one panel should show on every panel, with no server round trip for the rubber band. A brush, unlike a hover, ends in a server event: the window is what the LiveView acts on. Sent from every member, one gesture on a group of `n` charts would be `n` requests for one change of window, each in a different chart's pixels.

**Decision.** `BrushHook` joins the group its markup names, as the tooltip and crosshair do. The chart brushed publishes the extent `[lo, hi]` in domain units — ascending, the same encoding as a hover's `x` — on its own event, `vis:sync-brush:<group>`, with `detail: {extent, source}`; `null` clears. It publishes on every move of a brush in progress and once more on release. Each member maps the extent through its own five numbers **clipped to its domain**, and shows nothing when the two are disjoint. Only the chart brushed calls `pushEvent`, once on release. A received extent never pushes and never publishes. In a group, `brush_select` also carries `domain: [lo, hi]`, so the server reads the window without knowing which chart sent it. The brush event is separate from the hover event rather than a second shape of it. A crosshair then never mistakes an extent for an x, and the prefix differs (`vis:sync-brush:` rather than a suffix on `vis:sync:<group>`), so no group's name can spell another group's brush event. `BrushHook` may sit on a container around the SVG, as every other hook does, because a server-rendered chart is a string inside a `<div>`.

**Consequences.** One window change is one request, whatever the group's size, and the band follows the drag on every member at no server cost. A member whose domain holds part of the window shows that part, so panels over different spans still agree on the instant. A window set by the server through `data-selection-*` is not published, since the server chose the chart it set. A server that wants every panel to show a window it chose sets it on each. The bus carries x only: an `"xy"` brush shares its x extent, which members draw full-height, and a `"y"` brush joins no group. The bus object moves ahead of `BrushHook` in the bundle so that every hook using it follows it.

## 118. D-118 — A PNG is the library's SVG rasterised by resvg, through the optional `resvg` package

**Superseded by D-121 (#481).** The entry stays as the record of the NIF choice and the trial behind it.

**Context.** #450, #472. Alert notifications, scheduled reports and link previews need a picture without a browser, and HTML email strips SVG. §1.5 of spec/01 allows no required dependency, and #450 ruled out a headless browser. Two backends render the library's SVG faithfully without one: resvg (Rust, `Apache-2.0 OR MIT`), and libvips through librsvg. Checked 2026-10-08:
- **The `resvg` Hex package:** 0.6.0 (2026-07-15, MIT, one maintainer, wrapping resvg 0.47). It ships NIFs through `rustler_precompiled` for ten targets, x86_64-unknown-linux-gnu among them, and adds only `rustler_precompiled` and `castore`.
- **`vix` 0.42.0 and `image` 0.72.0:** they reach SVG through librsvg in an LGPLv3 bundle, read fonts only through the process-global fontconfig, and substitute a missing family silently.
- **A NIF of our own:** it would need a Rust toolchain and a per-target release pipeline in a CI that is otherwise pure Elixir.

A trial on OTP 29 with no Rust installed compiled, doubled the dimensions exactly at `zoom: 2.0`, and rendered text from a font directory alone. It also found two defects:
1. **The render NIFs run on a normal scheduler.** On a VM with one normal scheduler, a 1 ms timer fired 1.6–1.7 s late across five 800×400 renders at zoom 2. A branch marking them `DirtyCpu` kept it within 1–8 ms (#477).
2. **Text in a family the font database lacks is dropped** without an error or a warning.

**Decision.** `Visualize.Render.to_png/2` rasterises the SVG the library already renders, through `{:resvg, "~> 0.6", optional: true}`.
- **One module** is the only caller of `Resvg`, so the backend can be replaced in one place.
- **Without the package,** `to_png/2` returns `{:error, :no_rasterizer}` and `to_png!/2` raises, as `CanvasBinary` does without Nx (spec/01 §1.4).
- **Options:** `scale` maps to resvg's zoom and `background` to its background. A string input is given a `resources_dir` the library controls.
- **Fonts** come from a font database built once per font configuration (`init_fontdb/1`): the host's `font_dirs`, with or without system fonts.
- **Missing families:** resvg reports nothing, so the library computes them itself, by resolving every family list it wrote into the SVG against that database's families (#474).
- **The scheduler defect** is fixed upstream (#477), not worked around. Until a fixed release exists, `to_png/2` documents that a render holds a scheduler for its whole duration. If the fix is not released, this entry is reopened for a NIF owned by this project, forked from the same crate.
- **vix and image are rejected:** no per-call font directory, silent substitution, and a copyleft native bundle for one operation.

**Consequences.**
- A consumer that does not list `resvg` fetches nothing new, and `scripts/consumer_check.exs` asserts `Resvg` and `RustlerPrecompiled` are absent.
- A host that opts in downloads a checksum-verified precompiled NIF at compile time and needs no Rust. depdep's store carries it in `_build`.
- CI images have no system fonts, so the conformance suite renders with bundled test fonts and `skip_system_fonts: true`, which also makes raster goldens independent of the host.
- The backend's release cadence is one maintainer's (a 16-month gap before 0.6.0), and keeping up with resvg depends on upstream contributions.
- resvg 0.6 requires `rustler_precompiled ~> 0.8.1`, so the lock holds `rustler_precompiled` at 0.8.x (it was 0.9.0). Only the test-only Explorer also uses it, and it accepts `~> 0.7`. Moving it forward waits on a resvg release that allows it.

## 119. D-119 — Basemap tiles are beneath by type, at the nearest zoom, over a projection fitted without moving its centre

**Context.** #451, #475. A `:tiles` mark draws slippy-map tiles under a `:geo` frame so that a track drawn through the frame's projection sits on its roads. Three choices decide whether it does. The first is where the tiles go in the drawing order. A design composed from fragments does not control which fragment's mark comes first, and a basemap drawn after a track hides it. The second is which zoom level's tiles a projection gets, since its scale is rarely a power of two times 256. The third is how the view fits the data: `Visualize.Geo.Projection.fit_extent/3` centres on the latitude midpoint, and a Mercator projection with a centre latitude is no longer the Web-Mercator plane, so no tile can lie under it.

**Decision.** A frame draws its `:tiles` marks first among its marks, whatever their position in the design, and every other mark after them in design order. "Beneath" is a property of the mark type, not an ordering the author must keep. The zoom is `round(log2(2πk / 256))` clamped to `[0, max_zoom]`, and the tiles are scaled by `2πk / 2^z / 256` to the projection, so the scaling factor stays within `[1/√2, √2]`. The projection is never moved to suit the tiles. Tiles exist only under a tile-aligned projection, `:mercator` with a zero centre latitude and no tilt or roll, and anything else is a validation fault rather than a basemap drawn off the data. A design fits its view with a `fit` node on the projection, realised by the new `Visualize.Geo.Projection.fit/3`. That function is d3-geo's `fitExtent` over points: it sets scale and translate alone, so it keeps the projection aligned. The attribution is required, drawn as text in the plot's bottom-right corner, and styled through the style grammar over `:label`. The library never fetches a tile.

**Consequences.** A design can put its tiles mark anywhere in its marks, and a fragment can add one, without hiding the data. Tiles are sharp or nearly so at every scale, and they line up with the projection to floating point, because the tile grid and the projection are one plane at two scales. `fit_extent/3` keeps its approximate, projection-independent fit for existing callers. A compiled chart draws an unfitted tiles mark once, in the static SVG layer. On a hybrid page that layer sits over the canvas, so a canvas-layer track in the same frame is hidden until the canvas can draw images in order, which is #476 (resolved by D-120: the tiles go beneath the canvas instead). The attribution sits with its tiles, beneath the other marks, so a design that draws data into the corner pads its fit.

## 120. D-120 — A basemap is the page's backdrop: its tiles beneath the canvas, its attribution over it, and no image record in the binary stream

**Context.** #451, #476. D-119 put a `:tiles` mark first among a frame's marks, which is beneath every other mark on SVG. On a hybrid page it is not: a compiled chart draws the tiles in its static SVG layer, and the page stacks the SVG layers over the canvas (spec/14 §12.4), so a GPS track dense enough to go to the canvas is hidden under its own basemap. There were two ways to put the tiles under the track. One was to draw them on the canvas, in order, with a new image record in the binary stream: an opcode in the encoder, in `executeVisualizeBinary` and in the reference decoder. The other was to give the page a layer beneath the canvas.

**Decision.** A compiled chart has a *backdrop* layer, beneath the canvas layer, and a `:tiles` mark's images are drawn in it as SVG `<image>` elements: `Visualize.Chart.Compiled.backdrop/1` is the static backdrop document, rendered once, and a tick's payload carries `backdrop` for a fitted basemap, whose tiles move with the data. The attribution stays in the SVG layer, over the canvas, in the mark's group with its images removed, because it is text that has to be read over whatever the data draws. The page stacks five documents bottom to top: the static backdrop, the tick's backdrop, the canvas, the static SVG and the tick's SVG. The binary stream gains no image record. Only a basemap's images are in the backdrop. Every other mark is the data and goes to the canvas or the SVG layer by its target.

**Why not an image record.** A canvas image is fetched asynchronously, so drawing it in order means the decoder must wait for every tile before it draws anything after it, on every frame, and the canvas is cleared each frame. A whole-frame stream would hold the track back behind a network fetch and redraw the tiles from cache every tick. An `<image>` in the DOM is fetched once, cached and composited by the browser, and the backdrop is drawn once at compilation and never sent again unless the view moves. A cross-origin image drawn on a canvas also taints it, which forbids reading its pixels back (`getImageData`, `toDataURL`). The incremental canvas's copy-shift uses `drawImage` from its own canvas, which still works on a tainted canvas, but any host that exports the canvas would stop working. The opcode would also be a fourth place to keep in step, after the encoder, the JS decoder and the reference decoder, for a mark that draws no rows.

**Consequences.** A dense track on the hybrid backend sits on its roads, with the attribution readable over it. A page that does not stack the backdrop loses the basemap and nothing else. The canvas hooks already clear to transparent, and spec/10 §8.2 now says so, because the backdrop depends on it. On the whole-chart canvas backends the tiles stay as they were: `Visualize.Backend.Canvas` draws an image when it loads, over the synchronous commands, and the binary stream drops it. A design with a basemap is drawn on SVG, or by a compiled chart on a page that stacks the backdrop. If a host ever needs a basemap in a pure binary stream, the image record is a proposal of its own. `Compiled.static/1` no longer holds a static basemap's images, so a caller that read them there reads `backdrop/1`.

## 121. D-121 — A PNG is rasterised by the resvg command-line tool over a port, and PNG output takes no Hex dependency

**Context.** #481, superseding D-118. D-118 reached resvg through the `resvg` Hex package, a NIF, and #473/#474 found four costs in it. A render held a normal BEAM scheduler for its whole duration: on one normal scheduler, a 1 ms timer fired about 1.7 s late across five chart renders, and the fix waited on an upstream PR (#477) to a one-maintainer package. Text in a missing family was dropped silently, so #474 re-implemented resvg's family matching in Elixir over parsed `list_fonts/1` strings (`Raster.Families`). resvg 0.6 required `rustler_precompiled ~> 0.8.1`, holding the lock back. And native code ran inside the VM, where a crash takes the node down. Two trials on 2026-10-09 with resvg CLI 0.48.1 (the upstream `resvg-linux-x86_64.tar.gz`), the second with the SVG on stdin and no temp file, rendered the same 800×400, zoom-2 chart in 247–281 ms (about 4 ms of it process start) against the NIF's ~220 ms with a warm font database, held no scheduler, and reported a missing family on stderr: `No match for '"NoSuch"' font-family.`

**Decision.** `Visualize.Render.to_png/2` runs the resvg command-line tool the host installs, as an Erlang port, and the library drops the `resvg` dependency (spec/09 §9).
- **No temp file.** The SVG goes in on stdin and the PNG comes back on stdout. A port cannot half-close stdin, so a POSIX `sh` wrapper hands resvg exactly the SVG's bytes, then end-of-file: `head -c "$n" | exec "$b" "$@" - -c 2>&1`. stderr is merged into stdout; resvg prints its warnings before the PNG, so the output splits at the PNG signature.
- **Flags.** `--resources-dir` always; `scale` → `--zoom`, `background` → `--background`, `font_dirs` → `--use-fonts-dir` (repeated), `system_fonts: false` → `--skip-system-fonts`, and the generic families → `--sans-serif-family` and the rest. `font_family` stays a root `font-family` attribute, because resvg's `--font-family` takes one name and the theme's value is a list.
- **The binary** is host intent: `config :visualize, :resvg, path`, else `System.find_executable("resvg")`. A configured path that names nothing is `{:error, :no_rasterizer}`, never replaced by the `PATH`'s.
- **The minimum version is 0.45.0**, read once per binary file from `resvg --version` and cached; an older binary is `{:error, {:rasterizer_version, found, required}}`. 0.45.1 is what Debian bookworm and trixie package, and the thirteen raster goldens rendered on it differ from the 0.48.1 goldens in no pixel beyond the tolerance (none at all, measured 2026-10-09), so the oldest packaged version is supported rather than a newer floor that would push hosts off their distribution's package.
- **Warnings are resvg's own.** `No match for '<list>' font-family.` becomes `{:missing_family, list}`, the printed list with the quotes around each family name removed; any other line becomes `{:rasterizer, line}`. The text is not a stable resvg API: it is parsed in one function, pinned by a test to the CI's version. The tuple loses #474's third element, the families tried: resvg does not print them, and recomputing them would bring back the Elixir matching this decision removes.
- **Timeout.** A render takes `timeout` (default 30 s); on expiry the port's process group — the wrapper `sh`, `head` and resvg, which OTP starts in a session of their own — is killed with `SIGKILL` by group number, and `{:error, :timeout}` returned. Closing the port alone would leave resvg running.
- **No cache.** resvg loads fonts per call, a few milliseconds for a font directory. The `:persistent_term` font database of #474 goes, and with it `Raster.fonts/1`.

**Consequences.**
- A render cannot hold a scheduler or crash the VM, and #477 (the upstream `DirtyCpu` PR) is unnecessary: it closes unposted.
- PNG output costs a consumer no Hex dependency. The lock drops `resvg` and `castore`, and `rustler_precompiled` moves from 0.8.4 to what the test-only Explorer resolves, 0.10.0. `scripts/consumer_check.exs` still asserts `Resvg` and `RustlerPrecompiled` absent.
- The host installs resvg: `apt install resvg` (Debian, Ubuntu), the upstream tarball, or `cargo install resvg`. A release container adds one package or one file.
- Each render pays for a process start and a font load (about 4 ms and a few ms), which a chart render of hundreds of milliseconds absorbs. A host rendering many small PNGs pays it per PNG.
- CI downloads a pinned release (v0.48.1, SHA-256 checked) in the `test` job, and the goldens are rendered with it; a job without the binary excludes the `:resvg` tests and is the absent case (spec/12 §4).
- `{:missing_family, list}` replaces `{:missing_family, list, tried}`. `to_png/2` is unreleased, so no released caller breaks.

## 122. D-122 — A colour outside its domain is the scale's `unknown`, the theme's `:axis` by default, and a missing colour is `:none` on every backend

**Context.** #487. The gallery's Donut was invisible. Its slices were filled by `{:field, :index}`, `0` to `4`, through a `color` scale whose domain was the category names. `Visualize.Scale.Ordinal.apply/2` returned its `unknown`, `nil`, for every slice, and `Visualize.Chart.Style.bind/4` dropped a `nil` colour. The element was left with no `fill` at all, and the backends disagree about that: the SVG initial value painted the slices black, and a canvas filled nothing, leaving only the white outline on a white page. Nothing reported the mismatch, although the design said enough to see it.

**Decision.**
- **The `color` scale has a visible `unknown`.** An `:ordinal` scale named `color` whose node gives no `unknown` takes the theme's `:axis` slot, d3's `scaleOrdinal.unknown` with a default. `:axis` was chosen over a fixed mid grey because it is a slot: it is a neutral in every built-in theme (`#666666` light, `#a0a4ab` dark), visible on the background and distinct from the series colours, and a stylesheet or a custom theme moves it with the rest of the chart. A node that gives `unknown` keeps it, `:none` included, and a scale under any other name keeps the library's `nil` (spec/03 §8.2, spec/14 §4.3).
- **A missing colour is `:none`, never absent.** A colour key whose reading is `nil`, or whose scale yields `nil`, is bound as `:none`, which SVG writes as `fill="none"` and a canvas skips. The backends are not changed: an absent fill stays the SVG initial black and a canvas's nothing, because the IR is the caller's and a hand-built path relies on the initial value. The layer that can tell "no paint" from "not said" says it (spec/09 §3.3.3, spec/14 §3.4).
- **The validator reports what it can see.** `{:outside_domain, scale, what}` for a constant colour channel outside a declared ordinal domain, and for a field whose source declares a column type no value of the domain can have. A `:category` column, an untyped field, an inferred domain and a source read through transforms are not decidable and are not reported (spec/14 §10.1).

**Consequences.** A value outside the colour domain is drawn, in a colour that reads as "not one of these", and a series line outside it is drawn in that neutral rather than the series colour of its position. A design that wants such values hidden says `unknown: :none`. A missing colour draws nothing on SVG and canvas alike. The check is a fault, so a design that sends a typed column to a domain it can never meet does not validate; the gallery's Donut, whose columns are untyped, would not have been caught, which is why the gallery fix (filling by `:category`, the ring sized from the plot area) is tested on its render.

## 123. D-123 — A label on a fill is inked by contrast with that fill: `:contrast`, the theme's text or background per element, as a literal

**Context.** #486. The gallery inked every label drawn on a fill — pie slices, treemap cells, packed circles, sunburst sectors, force-graph nodes — with `fill: :background`, the one slot spec/08 §7.1 named for "a label drawn over a mark". Whether that reads depends on the fill under it, not on the theme: white on mid-tone fills, near-black on the dark theme's pale fills, and on a pale palette — the gallery's "sunrise" treemap, `#ffd098` cells on a `#fff8f0` background — about 1.3:1, invisible. No single slot can be right on every fill, because a categorical palette spans light and dark colours by design, and a sequential one spans them from end to end. A per-chart rule ("this palette is light, use `:text`") would move the failure to the first palette whose middle colours are mid-tone. #485's renders showed a second fault in the same labels: the pie's small slices drew their labels over each other, the mark label's `fit` defaulting to `:none`.

**Decision.** A `:colour` style value `:contrast` (spec/14 §3.2): the theme's `text` or `background`, whichever has the higher WCAG 2.x contrast ratio against the colour the text is drawn on, by `Visualize.Theme.ink/2` (spec/08 §7.3).
- **The colour under a mark's inline label is its element's fill** (spec/14 §5.5): the element's own after its bindings, else the mark group's; a CSS reference read by its fallback, a paint by its first stop; a translucent fill composited over the background by its `fill_opacity` × `opacity`. Every other text has nothing under it and is inked against the background, which on a theme that holds spec/08 §7.4 is its `text` — so `:contrast` is safe wherever a colour is accepted, and means "the ink that reads here".
- **Black or white when neither slot reaches AA.** Two slots can both fail a mid-tone fill: a grey of luminance 0.18 is 4.0:1 against `#333333` and 4.3:1 against white. Then the ink is `#000000` or `#ffffff`, whichever contrasts more, and one of them is at least 4.58:1 against any colour — the minimum of `max((L + 0.05) / 0.05, 1.05 / (L + 0.05))` over `L`, at `L ≈ 0.179`. So the value guarantees WCAG AA (4.5:1) for every fill, which is what a test can hold every gallery palette to, and the theme's own inks are kept wherever they suffice.
- **A literal in both modes.** The ink is the chosen colour's literal, not `var(--vis-text, …)`: the choice is made between literals, and a stylesheet that re-pointed `--vis-text` under it would undo it. Under `resolve: :css` it is chosen against the slots' fallback literals, so the SVG and the canvas carry the same colour. A consumer who restyles a theme through CSS custom properties alone therefore restyles everything but the contrast inks, which follow the Elixir theme the chart was rendered with; that is the price of a choice that cannot be expressed in CSS without `color-contrast()`, which no browser ships.
- **Text on a tie.** Equal ratios take `text`.
- **The gallery** writes `:contrast` on every label it draws on a fill, and a `fit` on each so a label stays on the element it was inked for: `:hide` on the pie and the force graph (a slice or a node is too small for a cut name to mean anything), `:truncate` on the treemap, the circle pack and the sunburst (kept from #138). Its themes take a font stack — `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` — in place of the generic `sans-serif` (`Examples.ColorPalettes.theme/1`); the library's default theme keeps `sans-serif`, since a library cannot assume a host's fonts.

**Consequences.**
- A label on a fill reads on every palette, and a palette swap needs no label change. A test over every gallery palette and every gallery chart that labels a fill holds each label's ink to 4.5:1 against its element's fill.
- `:contrast` is not a slot: it has no custom property, `Visualize.Theme.slots/1` does not list it, the validator accepts it on every theme, and its JSON is `"contrast"` (spec/14 §11). The builder's colour well shows it by its word, with no swatch, since its colour differs per element.
- A label that overhangs its element (`fit: :none`) is still inked against its element, and may not read where it overhangs. `fit` is the remedy, and is why the gallery sets one.
- The components of spec/10 (the `pie_chart` and `sunburst_chart` components and their presets, `Visualize.Chart.Presets`) were left inking their labels with `:background` by #486, since their goldens held the hand-built components' output byte for byte. **#494 brought them under this decision**: both presets write `:contrast` and the gallery's fit — `:hide` on the pie, `:truncate` on the sunburst — and the components print the labels the layer fitted, in their own ink, after the slices or arcs. The byte-for-byte equivalence is retired for those labels and kept for everything else — every path, every attribute of the shell, the donut and every empty case — because the old labels were the defect this decision names (white on `:category10`'s olive is 2.0:1). The sunburst's own label thresholds (spec/10 §3.4) went with them, since a label the layer's fit drops has no text left to pair with its arc. The treemap component drew its labels itself, not through its preset, and kept its own rule — `:background` with a CSS text shadow — until **#497 brought it under this decision too**: its preset carries the label, `:contrast` with the gallery treemap's `fit: :truncate` at the cell's centre, and the component prints the labels the layer fitted after the cells, with no shadow. Its goldens' equivalence with the hand-built component is retired for the labels on the same ground, and a rectangle's label `fit` now holds the element's height to the font size as well as its width (spec/14 §5.5), so a zero-area or thin cell still draws no label to spill over its neighbours.

## 124. D-124 — A `:text` mark's constant position on an inferred domain is a fault; a constant on any other mark is data

**Context.** #490. The gallery's *Downsampling* captioned each of its three frames with a `:text` mark at data `x: 0`, over a one-bin count of the frame's own pipeline. Inference reads a constant channel as data (spec/14 §4.3), so `0` was held in the inferred `x` domain. At rest the series starts at row `0` and nothing showed; during a run the window scrolls 250 rows a tick, and at tick 60 the domain was `[0, 64999]` for rows `15000..64999`: the line started 160 of 694 px in, the left of every plot empty, and `:m4`'s columns — split over the series' own extent — no longer lay on the plot's pixel columns, so its guarantee of drawing the same pixels as the whole series was lost exactly when it was being shown. The goldens and the still render are tick 0 and never saw it.

**Decision.**
- **The validator reports `{:feeds_domain, scale, n}`** for a `:text` mark's position channel that is a literal number naming a scale whose domain is inferred, through any adoption (spec/14 §10.1). It is a **fault**, not a warning. The validator has no warning level: `validate/1` is `:ok | {:error, errors}`, and the builder's form, `apply/2` and `compose/2` all read that one shape (spec/14 §10.1, §18.7); a second channel of diagnostics would change every reader for one check.
- **It is scoped to `:text`**, because a fault must not break a design that means what it says. A constant on a `:rule`, an `:area`'s `y0`, a `:band`'s edges or any other geometric mark is routinely a baseline or a threshold the author *wants* the domain to reach — `y: 0` under a series that never touches zero — and stays valid. A `:text` mark is its label (spec/14 §5.2): its place is where the words go, never a reading the axis must reach, so a constant there is a caption or an annotation, and an inferred domain that holds it open is a fault the design can be seen to make. Only what is decidable from the design is reported: a field, a scale end, a variable, a fixed or variable domain are passed over.
- **The remedy is already in the vocabulary.** `{:scale, :min}` or `{:scale, :max}` (#420) places a text at an edge of whatever the domain turns out to be and feeds nothing; a frame label (spec/14 §6.3) places one in the plot's corner. A text meant to sit at a number the data may not reach fixes that end of the domain.
- **The gallery's caption reads `x: {:scale, :min}`**, not a frame label: a frame label has no datum (spec/14 §6.2), and the caption's count is the frame's own pipeline counted by a one-bin `:bin` — the point of the chart is that the number is what the frame draws, not a figure written beside it. At tick 0 the domain's low end is `0`, so the still render is byte for byte what it was.

**Consequences.** A design whose text marks stood at constant positions on inferred scales does not validate and names the channel and the scale; the gallery has the one, fixed here. A `:text` mark over a field, or at a constant on a fixed domain, is unaffected. The test generators drop designs with the fault as they drop D-122's. Other mark types can still widen an inferred domain by a constant without a report, which is what a baseline is; a design that did not mean one sees it in the axis.

## 125. D-125 — `:insideout` is d3's `stackOrderInsideOut`: appearance order, balanced sides, the earliest peak in the middle

**Context.** #496. spec/04 §8.4 said `:insideout` sorted the series by sum, descending, and alternated them so the largest sat in the middle. The code's `interleave/3` sent the first, third, … series to one half and the second, fourth, … to the other and returned the halves in the wrong order, so the heaviest series was at the **bottom**: keys `a b c d` with sums `5 1 3 2` stacked `a d b c`. D-22 (#26) had left `:insideout` out of scope and added a test titled "keeps its interleaving" that pinned the generator's output as it stood, with a comment deriving that output from the code, not from the spec's sentence or from d3, so the suite enforced the defect. Neither description was d3's. d3-shape's `src/order/insideOut.js` does not sort by sum at all: it takes the series in `stackOrderAppearance` order (ascending by the index of each series' peak, `src/order/appearance.js`), and places each on whichever side has the smaller running sum, the first on the bottom side; the order is the bottom side reversed, then the top. Its documentation: "the earliest series (according to the maximum value) is on the inside and the later series are on the outside", recommended for streamgraphs. The library's `:wiggle` and `:diverging` are already d3's ports (D-22), and the declarative layer's `:inside_out` is documented as `Stack`'s `:insideout` (spec/14 §5.4.1), whose gallery Streamgraph says it puts "the earliest peaks in the middle".

**Decision.** `:insideout` is a port of d3's `stackOrderInsideOut`, and spec/04 §8.4 describes d3's rule rather than the old one. The appearance order is a stable sort of the key indexes by each series' peak, the index of its first greatest value (d3's `peak` compares with `>`, so a tie keeps the first). The sums are d3's `sum`: signed, so a negative series lowers its side's total. For each series in appearance order, if the top's sum is less than the bottom's it joins the top, otherwise the bottom (so the first joins the bottom); the stacking order is the bottom list reversed, then the top list. `:appearance` stays a declarative-layer order (spec/14 §5.4.1) and does not become a `Stack` order.

**Consequences.** Any stack ordered `:insideout` takes a different order. With one datum every peak is at index `0`, so the appearance order is key order and the result depends on the sums alone: the old test's `5 1 3 2` now stacks in key order, `a b c d`. The reference cases in `test/visualize/shape/arc_stack_test.exs` are d3's algorithm run by hand, each citing the source lines, and include a tie in peak position and a negative sum, where a sum-by-magnitude or unstable reading would disagree. No gallery chart changes: the Streamgraph, the one that was ordered `:inside_out`, fixes its order as `{:keys, list}` since #492, ranked by weight with the heaviest in the middle — its own ranking, not this one.

## 126. D-126 — `series` is a channel of every mark that draws rows, and every element and label a series draws carries `data-series`

**Context.** #508. On `/interaction`, clicking a legend entry hid the series' line but left its points drawn. The demo draws a series as two marks: a `:line` with `series` and a `:circle` for the points. The circle coloured its points with a `{:field, :series}` fill. `LegendHook` hides `.mark [data-series="<value>"]` (D-77), and D-77 gave `data-series` only to the per-series path of a `:line` or an `:area`. Those were the only two types the schema let take a `series` channel. So the demo could not name its points' series: a circle mark with `series` failed validation with `{:invalid_for, :circle}`. D-77 had recorded the gap ("a per-datum mark coloured by a field … carries no `data-series`; widening that is a later proposal"). The hook's tests only read its source as text. The frame and design goldens hold the attribute on the path. No test asked whether every element a series draws carries it, so the gap went undetected. Inline labels had the same gap: a `:line`'s end label, drawn for one series, carried no `data-series` and stayed drawn when its line was hidden.

**Decision.** Every mark type that draws rows takes an optional `series` channel. The exceptions are `:percentile_band`, which draws one band from all its rows, and `:tiles`, which draws none ([14-declarative-chart](14-declarative-chart.md) §5.2). The channel means the same thing on every type. Every element the mark draws for a series carries `data-series`, `to_string/1` of the series value, and so does every inline label drawn for that element (§5.6). A row whose reading is `nil` carries no attribute. Every such element is painted the series' colour: the `color` scale's value, else `{:series, j}` in order of first appearance. That colour gives way to a paint key the element already sets, and to a `{:field, f}` binding of the key. `Visualize.Chart.Mark` does both in one place, after the type's generator has drawn its elements. The generators no longer stamp the attribute or the paint themselves. A `:line` or an `:area` still splits its rows into one path per series. Every other type draws its elements as it did and gains the attribute and the paint. A `:rule`'s or an `:x_band`'s attribute sits on the datum's group, and its paint replaces the one set on the line or rectangle inside. A label lifted out of that group carries the same attribute. The guard executes `LegendHook` over the markup a chart with a `:line` and a `:circle` renders (spec/12 §6): one toggle hides that series' path, every one of its points and its labels, and no element of another series.

**Consequences.** A series drawn as a line with points is hidden whole by its legend entry once both marks carry `series`. The points then take the line's colour without a `{:field, :series}` style. A design that has no `series` channel on its per-datum marks renders byte for byte as before, as do the goldens: the move of the line's and area's attribute and paint into `Mark` emits the same attributes. A per-datum mark coloured by a field without the channel still names no series. That is now the design's choice, because the channel is available. A `:percentile_band` does not take `series`. One band per series is a different feature, and it would need the band split as a line is.

## 127. D-127 — A projected path is clipped on the sphere as d3-geo clips it: cut at the antimeridian of the rotated frame and closed along the seam, with d3's spherical winding

**Context.** #504, #509. spec/07 §2.2 said "vertices whose projection is `nil` are dropped from lines and rings; lines are not split at clipped vertices", and `Visualize.Geo.Path` projected each vertex on its own. Nothing ever cut a ring at the antimeridian, so under a projection's `rotate` every ring that crossed the rotated seam was joined the long way round, across the whole map. The gallery's World Map turns its Natural Earth projection a degree per frame, and at tick 45 North America was a stripe across the Pacific and the Atlantic and Antarctica split into slabs — #499's PNGs, which drew Natural Earth 1:110m land, made the bands plain, and the hand-drawn outlines before it had them too. d3-geo, the reference the geo module follows, never draws such a band: its projection stream clips on the sphere before projecting, `clipAntimeridian` for every projection without a clip angle (`src/projection/index.js:71`), so a line becomes several and a polygon is closed along the seam and the poles. A ring that crosses no seam but encloses a pole, as Antarctica does, is found by d3's `polygonContains`, which needs a winding convention: d3's is spherical, the exterior ring clockwise.

**Decision.** The path clips lines and polygons on the sphere, in the rotated frame — after `rotate`, before `center` and the raw projection — as d3-geo 3.1.1 does, by a port of its `src/clip/index.js`, `antimeridian.js`, `rejoin.js` and `buffer.js`, `src/polygonContains.js` and d3-array's `Adder` (the internal module Visualize.Geo.Clip and those beside it), each function citing the lines it ports. A projection without `clip_angle` is cut at ±180°. The port is faithful in its decisions — the same cut latitudes, the same nudges off the seam, the same ordering of intersections and the same rejoin — so it is held to d3's own output, not to a description of it; it works in degrees with d3's radian tolerances restated, so that a vertex the clip passes through is the float the projection would have had and an unrotated map that crosses nothing draws exactly as before. The clip is a behaviour with four callbacks — d3's `pointVisible`, `clipLine`, `interpolate` and `start` — so the small-circle clip of a `clip_angle` (#510, the milestone's second item) is a second clipper beside the antimeridian and not a second walk. Two departures, both stated in spec/07 §2.2.1: a ring the clip leaves whole keeps its closing vertex, which d3 drops and `Z` makes redundant, so existing paths keep their commands; and because the library does not resample as d3 does, the edge the clip walks is sampled every degree at any `precision` but 0 — the Projection struct's `precision`, stored and never read until now, says which, `0` meaning d3's `precision(0)`.

**Consequences.** A line crossing the seam is several subpaths; a polygon crossing it is closed along it; a polygon around a pole fills to the map's edge. The rotating World Map draws no band at any tick. Winding now matters: a ring wound anticlockwise is the rest of the sphere, as in d3, and data in RFC 7946's winding must be rewound; the gallery's Natural Earth data is wound for d3 and needs nothing. Data that crosses the seam unrotated is drawn differently — Natural Earth's Antarctica runs along ±180° to the south pole and is now rejoined along the seam, sampled every degree — while a ring that crosses nothing keeps its vertices exactly. `bounds/2` and `centroid/2` read the clipped vertices; `area/2` still measures the geometry's own rings, unclipped. A projection with `clip_angle` kept the dropped-vertex rule until #510, which replaced it with the small circle (below). `test/support/geo/clip_golden.jsonl` holds the port to d3-geo's output (spec/12 §1).

**The small circle (#510).** The second clipper is d3's `clipCircle` (`src/clip/circle.js`, with `src/circle.js`'s `circleStream` for its interpolation), ported as faithfully, and the dropped-vertex rule is gone: under a `clip_angle` a line is cut where it crosses the circle, an edge whose ends are both outside a small circle may still pass through it, and a polygon is rejoined along the circle every 2°, closed by the whole circle when it contains the clip's start — so the Globe's land closes along the horizon and never by a chord through the disc. Faithful here means three things the library had differently. The azimuthal types' default clip angles are d3's — the orthographic's `90 + 10⁻⁶` and the whole-sphere azimuthals' `180 − 10⁻³` — because at exactly 90° d3's clip takes its small-circle branch and at 180° the circle is a point; these are the angles d3's own output, which the conformance golden is, was computed with. The orthographic's raw projection places the far hemisphere, as d3's does, and the azimuthal equal-area places everything but the antipode, because the clip circle lies just beyond the horizon or just short of the antipode and the raw projection must place it. And the circle is taken in the clip test's rotated and centred frame (spec/07 §1.5), where `sphere/1` draws it, so a path and the sphere's outline agree for any `center`; with `center` `{0, 0}` that is d3's frame. Polygon winding now matters under the azimuthals as at the seam: a ring wound anticlockwise on a north-up map is the rest of the sphere and fills the visible disc but for itself.

## 128. D-128 — A backend switched mid-run starts a new measurement segment, and acks at or below the switch's sequence floor are ignored

**Context.** #502. The chart page's backend buttons were disabled while a run went on, so comparing *SVG* and *Canvas* on a moving chart meant stopping, switching and starting again, the animation back at tick zero. Nothing required it: the run's renderer already re-prepared a running backend's state in place for a resize (#434). Two things did need deciding. The drawer's metrics — FPS, client FPS, render time, payload, lag, dropped, stalls — are measurements *of a backend*: averaged over a window of frames that spanned a switch they would describe neither backend, and the point of switching live is to compare the two. And flow control (D-107) counts acks against sequences: an ack from the old backend's hook can still be on the wire after the switch, and counted against the new backend it would release a window that the new hook never drew or, as a sign of life, keep a backend that never acks from failing open.

**Decision.**
- **A switch closes a segment.** The run's frame and ack metrics restart at zero at a switch, and the run keeps what they stood at as a closed segment — backend, frames, FPS, client FPS, mean render time, last payload, dropped, stalls, skipped — newest first. The metrics shown are always one backend's since the switch; no average, count or rate ever spans two backends. The data's metrics (the measured data rate, pending samples, carry) belong to the data clock and carry over. The drawer shows the closed segments beside the current one in a per-backend table, because the drawer already compares backends (its *Compare* table); a new run starts with none.
- **Sequences keep rising; the switch marks a floor.** The run does not restart its sequence numbers (that would make an old hook's ack for sequence 5 indistinguishable from the new one's), nor tag acks with an epoch (that would change the hooks' ack payload, spec/10 §8.2, §9.2, for a fact the producer already holds). It records the last sequence sent before the switch as the floor and treats an ack at or below it as never received: not a frame drawn, not a sign of life. Nothing is in flight for the new backend at the switch.
- **The first frame after a switch is full**, the rule a resize follows (#456): the run's `needs_full` is raised and the renderer's freshly prepared chart has no window behind it.
- **The frame in flight at the switch is forgotten**, not waited for: it was asked of the old backend, its answer is stale, and the next tick asks again.

**Consequences.** A reader switches backend mid-run and the chart goes on from its tick and its sample; the drawer's numbers are the new backend's from the first frame, the old backend's kept in a row beside them. A hook that acks late after its element is gone costs nothing. The epoch approach stays available should a page ever need to tell two *concurrent* producers' acks apart, which a floor cannot.

## 129. D-129 — A projection's `phi` and `gamma` are d3's: `phi` tilts the globe and `gamma` rolls it; only `lambda` keeps the opposite sign

**Context.** #511, found by #510 while building the d3-geo conformance oracle. `rotate/3,4` takes `{lambda, phi, gamma}` as d3's `projection.rotate` takes `[λ, φ, γ]`, and spec/07 §1.4 called `phi` a tilt and `gamma` a roll, but the rotation turned the point about the x axis by `-phi` and then about the z axis by `-gamma`. In the frame the library rotates in, the x axis runs through the frame's origin — the viewing axis of every azimuthal view — so `phi` *rolled* the globe in the plane of the view, which is d3's `γ`, and `gamma` turned the rolled globe about its pole, which d3 has no single angle for. Nothing could tilt the globe toward the viewer. Measured against d3-geo 3.1.1's own `geoRotation` on reference points, the library's `{-λ, -γ, 0}` reproduced d3's `[λ, 0, γ]` to the last digit, and no library rotation reproduced d3's `[0, 30, 0]`: d3 brings `(0°, 0°)` to latitude 30, the library left it at the origin. #510's oracle therefore had to pass d3 `[-lambda, 0, -phi]` and refuse any library `gamma`. It went unnoticed because the projection tests compared the projection with values derived from itself — the rotation round trip is a property of any rotation, right or wrong — and nothing compared it with d3; and the one caller that set `phi`, the gallery's Globe, read plausibly either way.

**Decision.** `phi` and `gamma` mean exactly d3's — axis, order and sign: the rotation is d3's `rotateRadians` at `[-lambda, phi, gamma]`, its `rotationPhiGamma` and inverse ported line for line (spec/07 §1.4). `lambda`'s opposite sign stays, as D-44 kept it: it is the convention every map in the gallery and every tile origin (§8.1) is written in, and flipping it is a separate contract change with nothing to gain for #511. The conformance oracle passes d3 `[-lambda, phi, gamma]` — one sign, documented, and no remapping of axes — and holds projected points under several `[lambda, phi, gamma]` through nine types, and the closed-form types' inverses of them, to d3 within `1e-9` (spec/12 §1); the land cases add tilted and rolled globes, and the Globe's tick 60 is kept rolled as well as tilted, the roll being the case with a pole on the horizon. The Globe's nod was meant as a tilt — its hand-written predecessor computed `{-lon, -lat}`, d3's idiom for centring on `(lon, lat)` — so it keeps its `phi`, which now tilts the globe as its author intended: north toward the viewer at rest, the centre's latitude swinging ±15° as it turns.

**Consequences.** A projection with a non-zero `phi` or `gamma` draws a different map: what was `phi` is now `gamma` with its sign flipped, and what was `gamma` has no equivalent. In the gallery only the Globe sets one, and it now tilts where it rolled; its rest view shows the northern hemisphere from 20° N. Tiles are unaffected (a tile-aligned projection has both at zero). A design ported from d3 can copy `rotate`'s `φ` and `γ` unchanged and negate `λ`.

## 130. D-130 — A projection's clip hides a row, it does not drop it: clipping is the view's, not the data's

**Context.** #522. The user reported that on the gallery's Globe the continents were drawn correctly but their colours flashed as though they changed at random. The land is filled through `ordinal_scale(:color)` with an inferred domain, and the `:projection` step dropped every row whose geometry projected to nothing (spec/14 §5.4.4, #349), while domains are inferred from the transformed rows (§4.3, D-62). So the colour domain was whichever land masses faced the viewer: 46 of the 127 at tick 0, 73 at tick 40, 106 at tick 80. A land mass's place in the domain, and therefore its colour, moved as the globe turned — `land-8` sat at index 3, then 2, then 3. The same trap waits for any design whose projection clips and which reads a scale with an inferred domain: an orthographic or clip-angle map coloured by category, a bubble map sized by value, a rotating or panning map. Pinning the Globe's domain would hide this one instance and leave the trap in place.

**Decision.** Clipping is the view's, not the data's. A `:projection` step keeps every row it is given: a geometry it clips whole (`field:`) keeps its row with `path`, `x` and `y` all `nil`, and a point it clips (`fields: [lon, lat]`) keeps its row with `x` and `y` `nil`. A row whose coordinates are missing or not numeric, or whose geometry is not a map, is bad data, not a view, and is dropped as before. The marks already place nothing for a `nil` reading (spec/14 §5.2): a per-datum mark drops the datum, a `:path` mark draws no element for a `nil` `path`, a path mark breaks there, and `:delaunay`, `:voronoi`, `:density` and `:hexbin` drop the point. One gap closed with it: a `:line` or an `:area` path none of whose data is placed — a series the projection clips whole — draws no element, where it drew an empty `<path d="">`. So nothing new is drawn; only domain inference changes, and it now reads every row whatever the viewpoint.

**Consequences.** A caller that counted the `:projection` step's output rows now sees the clipped rows too, with `nil` `path`, `x` and `y`. A bubble map's size domain, and any domain over a projected mark's columns, no longer depends on what is in view, and the Globe's land keeps one colour per land mass through the whole animation. The compiler's render target counts every row a mark is given (spec/14 §12.3), so a turning map keeps its target instead of crossing the ceiling as land comes into view. A `:line` or `:area` with no placed point — no rows at all included — now emits no element instead of an empty path. `fit` (#475) reads the source before the transforms and is unaffected.

## 131. D-131 — A circle on the sphere is d3-geo's `geoCircle`: a GeoJSON polygon wound as d3 winds it, so the path fills the cap about its centre

**Context.** #524, #525. The gallery's *Day, night and GPS* map needs the night side drawn over the land: the hemisphere of 90° about the antisolar point. d3-geo, the reference the geo module follows, makes such a shape with `geoCircle`, a GeoJSON `Polygon` of the small circle of a radius about a centre, and the library had no port. A chart could not write its own ring and expect it to draw: since D-127 the path clips on the sphere with d3's spherical winding, under which a ring bounds the region on its right, so a hand-made ring wound the other way fills the rest of the sphere, and a ring whose points are not where d3 puts them meets the seam at other latitudes than d3's. The library also already held the walk itself: `circleStream`, which the small-circle clip (#510) uses to close polygons along a horizon, is in the internal clip module.

**Decision.** `Visualize.Geo.Circle.polygon/1` is a port of d3-geo 3.1.1's `src/circle.js`, faithful as the clip ports are: `circleStream` walking the whole circle about the origin from the angle `radius + 2π` down, and each point taken to the centre by the inverse of `rotateRadians(−λ, −φ, 0)`, the rotation chosen and normalised as d3 chooses and normalises it, in radians (spec/07 §2.5). It returns plain GeoJSON — a map with string keys, as `Visualize.Geo.Path` and the `:path` mark's `geo` channel take — rather than a struct or path data, so a circle goes wherever any other polygon goes and the clip decides how it is drawn. It is a function of keyword options rather than d3's configurable generator, because a design calls it with values, never with accessors. The module is public and its own, beside `Path`, rather than an option of `Path`, because a circle is a geometry and drawing it is the path's business. It does not share the clip's internal `circleStream`: that one walks between two intersections in degrees in a rotated frame and is the clip's; this one is the whole circle in d3's radians, and keeping them apart leaves the clip's code, which its golden holds, untouched. Its default `precision` is 6°, the value d3-geo's documentation gave for years and the one #524 chose — 61 positions for a whole circle, smooth at gallery sizes — where d3-geo 3.1.1's code defaults to 2°; every other default and every number is d3's. A new oracle, `test/support/geo/circle_golden.mjs`, beside the clip's, records d3's own rings for centres at the origin, at and beside the poles and across the antimeridian, radii from 0 to past 180° and negative, and several precisions, and the test holds every position to d3's within `10⁻⁹` degrees (spec/12 §1).

**Consequences.** A cap of the sphere is one call: `polygon(center: antisolar, radius: 90)` is the night side, and under any projection the path fills that cap, cut at the rotated frame's seam and closed along the map's edge, with no chord the long way round. Because the ring is d3's, a circle copied from a d3 design draws the same shape point for point, and the winding a caller gets is always the one the clip reads as the cap. A caller who wants the rest of the sphere instead reverses the ring. The ring starts where d3's starts, at the angle `radius` about the circle's axis, which for a 90° circle about the equator is a pole, so its first longitude is whatever the arithmetic leaves there; a consumer should not read meaning into the first position. The suite carries a second one-off JavaScript oracle, run by hand like the first.

## 132. D-132 — A compiled chart's force layout is warm: each tick moves the graph from where it was, and the marks of one graph share one run

**Context.** #527. The gallery's Force-Directed Graph looked like a different graph on every frame. The `:force` step (spec/14 §5.4.3) called `Visualize.Layout.Force.run/1` from the cold initial layout every time it ran, for its 300 iterations, and the gallery breathes the link distance with the tick, so each frame relaxed from scratch to whichever equilibrium it fell into — often a reflection or a rotation of the last. Measured over ticks 0..60 at the gallery's 600 × 400, the largest move of any node between consecutive ticks was 327 px, 161 px on average. Nothing carried a node's position from one frame to the next, and the two marks of the graph — the links and the nodes — ran the same simulation separately, as did domain inference and the render target's count: compiling the graph cost 160 ms. d3 does not work this way: a simulation keeps its nodes' `x`, `y`, `vx` and `vy`, and a change to a force followed by a reheat moves the nodes from where they are. Three things had to be decided: where the state lives without making `apply/2` impure, what identifies a graph across ticks when the step's own keys change every tick, and how two marks are made to agree.

**Decision.**
- **`apply/2` and `render` stay pure and cold.** The same input gives the same layout, from the spiral at `alpha` 1 over 300 iterations. A node source row with a numeric `x` and `y` seeds that node's start, as d3 honours a given position; that is input, so it is cold too.
- **A realised frame lays out each distinct `:force` step once.** `Visualize.Chart.Frame.new/2` runs every distinct step of its marks before it infers a domain and holds the layouts in the frame's `forces`; every pipeline that reaches the step during that realisation reads it. Two steps are one run when their nodes are equal apart from `output` and their input — size, nodes and their seeds, flows — is equal. So the links and the nodes of one graph are one simulation and agree exactly, and a realisation runs it once, not once per pipeline. A step whose input differs falls back to running cold, as before.
- **The warm state lives in the compiled chart**, as the easing does (#360, #417): `forces`, per step, each node's `x`, `y`, `vx`, `vy` by id. Each tick realises its frame from it (`Frame.new/2`'s `warm:`), reheated to the step's new `alpha` (`:number`, `:geometry`, `0.3`) for at most its new `ticks` (`:integer`, `:geometry`, `3`) iterations at d3's decay, and returns the chart holding the new layout. `Visualize.Layout.Force.run/1` gains `:alpha` and `:alpha_decay` for it (spec/06 §8.3); its defaults are unchanged.
- **The state is keyed by the graph, not its forces**: the mark's source, the step's `fields`, `nodes` and `id`, and its place among the frame's steps with those in common. A change to `distance`, `strength`, `alpha`, `ticks` or `size` perturbs the graph; another source is another graph. Within it identity is the node id: a new node starts at the mean of its laid-out neighbours (or of every laid-out node) offset by its spiral position, and a gone node is forgotten.
- **`carry/2` carries it**, for the keys the fresh chart has, so a caller that recompiles per tick because its marks changed — the gallery's — keeps the layout, and a different graph inherits nothing.

**Consequences.** The Force-Directed Graph swells and settles as one graph: the largest per-tick move of a node over ticks 0..60 falls from 327 px to 10.7 px, and under 2 px once the first swell from the cold layout has settled, while the layout still follows the breathing distance. A node added to a settled graph moves the others by about 11 px on the tick it arrives. Compiling or applying a graph runs the simulation once per distinct step instead of once per pipeline, so the gallery's compilation is cheaper by an order of magnitude: preparing the force graph for a frame took 160 ms and takes about 20. A warm layout depends on the ticks before it, so a compiled chart's tick is no longer a function of that tick's input alone — it never was for an eased chart — and a run started at tick 30 draws a different, equally settled, graph than one that reached tick 30 from tick 0. The gallery keeps a compiled chart on *SVG* for such a design, as it does for an eased one. The default `ticks` is d3's pace: three iterations a tick at the gallery's 20 ticks a second is d3's one per animation frame at 60. Thirty a tick, measured, moved a node by up to 78 px while the graph swelled out of its cold layout — a cold layout is frozen as `alpha` decays, not settled, and a reheat lets it finish — so the default is the smaller. A design that wants a stiffer or a livelier graph sets them.

## 133. D-133 — The docs carry the README, the guides and the changelog; the specification, the conformance record and the usage rules are read on GitHub

**Context.** #529. The user asked, on 2026-10-10, that the *Specification* and *Conformance* sections leave the documentation — hexdocs and `mix docs` — since anyone who wants them will find them in the GitHub repository; the same day the request widened to `usage-rules.md`. Until then every `spec/*.md`, `spec/README.md` (as `specification`) and `API_SURFACE.md` were extras, in two sidebar groups after the *Reference* group, and fifteen long documents written for maintainers and conformance stood beside four guides written for a reader starting out; `usage-rules.md`, the short form written for agents and the usage_rules tooling, sat in the *Reference* group beside the changelog. About forty links from the README, the guides and the changelog pointed at them relatively, which worked only because the targets were extras too: ExDoc rewrites a link to an extra's file into its page, and a link to any other file is dead on hexdocs. A section anchor in those links was ExDoc's slug of the heading (`#7-3-application` for `### 7.3 Application`), which GitHub's rendering does not produce (`#73-application`). `skip_code_autolink_to` in `mix.exs` exists because the specification refers on purpose to internal and to recorded-absent functions.

**Decision.**
- **Hexdocs carries the README, the guides and the changelog**, and nothing else: the README ungrouped, then *Guides*, then *Reference*, which holds the changelog alone (spec/12 §6). `mix.exs` drops `spec/README.md`, `spec/[0-9]*.md`, `API_SURFACE.md` and `usage-rules.md` from `extras` and the *Specification* and *Conformance* groups from `groups_for_extras`.
- **The specification, the conformance record and the usage rules are read on GitHub.** A link to any of them from a shipped document — an extra or a moduledoc — is absolute, `https://github.com/dcoai/Visualize/blob/main/<path>`, and a section anchor is GitHub's heading slug, checked against the target's headings. A plain-text reference such as "spec/14 §5.4.4" stays as it is.
- **The package still ships them.** `files:` keeps `spec`, `API_SURFACE.md` and `usage-rules.md`: the first two are the contract a consumer holds the library to, and a reader of the package source has them beside the code; the usage rules are read from `deps/` by the usage_rules tooling. Only the documentation changes.
- **A test holds the class shut** (spec/12 §6): a relative link from a shipped document to a file that is not an extra fails, naming the file and the link, with the extras read from `mix.exs` itself.
- **`skip_code_autolink_to` keeps one reference.** `@internal_prefixes`, `@recorded_absent` and `internal_reference?/1` existed for the specification's deliberate references to internal and recorded-absent functions. A docs build without them, and without the specification, warns about one reference only: the changelog records `Visualize.Render.with_backend/2` as removed (D-2). So the option becomes that one string, and the attributes and the function go.

**Consequences.** The sidebar a newcomer meets is the README, four guides and the changelog. A specification link from hexdocs opens GitHub's rendering of `main`, not the tagged version the docs describe; between releases the two may differ, and the spec's own change history is there for a reader who needs the older text. The guides' section links to spec/14 now resolve on GitHub, where before they resolved only on hexdocs. A new link to a file outside the extras is a red test rather than a dead link found by a reader.

## 134. D-134 — An axis without a format or a count derives them from its scale's domain and its own length; the hand-built axis keeps d3's defaults

**Context.** #532, from #531's review item D. Three presentation choices are pure functions of what a realised chart already knows: the time tick format from the span shown, the number format from the magnitude, and the tick count from the size of the plot. Measured before the change: `Visualize.Scale.Time` had no `tickFormat` (only the interval its ticks use, `interval/2`), `Visualize.Axis` asked every scale for ten ticks, and its default label printed a time tick as ISO 8601 (`2024-03-05 12:00:00Z`) and a number unformatted (`9000000`). So a design that wanted a readable axis had to be given a `format` and a `ticks`, and a host whose window and width vary had to compute both: metresis does, in `MetresisWeb.Charts.Design`, passing the panel's `:width` into binding to get the count — which also breaks the rule that size reaches a chart only through `apply(size:)` (#427). Every other host would have repeated it, and the hosts would drift.

**Decision.**
- **The declarative axis derives what the design leaves out** (spec/14 §4.4). With no count in `ticks`, the count is `Visualize.Axis.tick_count/2` of the axis's length — `⌊length / 80⌋` across, `⌊length / 35⌋` down, Observable Plot's spacing, in `2..10` (spec/05 §1.5). With no `format`, the format is `Visualize.Format.Auto.formatter/2` of the realised scale (spec/05 §3.1). An explicit count, list or format always wins. There is no new key: the system has the knowledge, so it derives, and `format` and `ticks` stay the designer's override.
- **Time: one format per axis, from the span and the interval.** Clock times within a day, days within a week, dates within 92 days, months beyond — the thresholds metresis uses — each with the time of day while the interval the count chooses is under a day, and the year alone when the interval is whole years. d3's multi-scale `tickFormat` was considered and not taken: it formats each tick by the boundary it lands on (`Tue 05`, `12 PM`, `Wed 06`), so one axis mixes three shapes of label.
- **Numbers: by magnitude.** Plain below 1,000 (the default label, already right, is kept), grouped from 1,000, SI from a million with **one prefix for the whole axis** (`0.5M, 1M, 1.5M`), as d3's `tickFormat` with an `s` specifier does; a log or symlog scale, whose ticks are orders of magnitude apart, gives each tick its own prefix.
- **A locale parameter, English only.** Every function takes `locale:`, `"en"` by default; the locale holds the separators and the time formats, and an unknown one raises. Adding a locale later changes no signature (#531 item 7).
- **The seam is a function of the domain.** `notation/2` and `time_format/2` return data (`:grouped`, `{:si, 6}`, `"%b %d"`) and `number/2` and `formatter/2` the function, so units and quantities (#536) build their display scaling on the notation rather than on an axis.
- **The hand-built `Visualize.Axis` keeps d3's defaults.** Ten ticks and the default label stay, so an axis built by hand is still d3-axis and its golden is unchanged; it gets the derived ones by calling the two functions (spec/05 §1.5).
- **Derived on every realisation.** A fixed scale derives the same count and format each time, so its axis stays a static region; a moving scale's axis re-derives per tick, and a resize re-derives the count at the new size.

**Consequences.** Every axis in a design without a format or a count changes: time axes read `Mar 05` or `12:00` instead of ISO timestamps, axes over thousands are grouped and over millions carry one SI prefix, and an axis shorter than 800 px across (350 px down) carries fewer ticks than ten — the gallery's goldens move accordingly. A numeric axis over years (`1990..2020`) is grouped (`1,990`), as d3's default is; such a design says `format: "d"`. metresis can drop `:width` from binding and its own time and number formats, which a follow-up proposal there does once this ships. A design that relied on ten ticks on a small axis states `ticks: 10`.

## 135. D-135 — A theme carries meaning as four ramps and five roles, whose hue order and lightness the library fixes and checks when the theme is built

**Context.** #533, from #531 §3 and its review item E. Colour carries meaning a reader brings with them — the stop-light, cold blue and hot red, below and above a target — but a theme offered only categorical `:series_n` slots and chrome, and the sequential and diverging colours of a scale were the background-to-first-series run or a fixed d3 scheme. A palette position promises *distinct*, never *red-ish*: a status chart written with series slots — the gallery's Gauge coloured its ok, warm and hot zones `:series_4`, `:series_2`, `:series_3` — draws success, warning and failure as orange, grey and light blue under some host's theme, and nothing notices. The direction for boards and feeds (#531) needs a vocabulary a feed's schema can name without naming a colour — `status: %{"ok" => :good}` — so that colour is chosen only by the theme or by a deliberate choice in the design. Building it also showed `Visualize.Color.to_lab/1` was wrong: it converted sRGB through the D65 matrix but divided by the D50 white point that `lab/4` (d3-color's constants) uses, so the two were not inverses — `#808080` read back as `#758194`, and every grey had a chroma of about 12, which no contract on "neutral" could have been written against.

**Decision.**
- **Four ramps, each with a meaning the library fixes:** `status` (good → caution → bad), `thermal` (cold → neutral → hot), `diverging` (below → at → above a midpoint) and `sequential` (less → more). A ramp is a list of anchor colours evenly spaced along `0..1`, interpolated in CIE Lab; a position is `{:ramp, name, t}`, continuous, so any number of ordered levels fits. A theme holds them in a `ramps` map of anchor lists — plain data, so the theme-as-a-stored-value work (#534) has nothing to translate.
- **Five roles are the meaning vocabulary:** `:good`, `:warning`, `:bad` are the `status` ramp at 0, 0.5 and 1 — fixed points, not colours of their own, so a role and its position cannot disagree — and `:info` and `:neutral` are the theme's `roles`. They are theme slots: `var(--vis-good, …)` in CSS, declared by `stylesheet/1`. A ramp position resolves to a literal in both modes, as the contrast ink does, since CSS has no property per position — except the status roles' own positions, which resolve as the roles, so the two spellings of one meaning are one colour even under a stylesheet.
- **Contracts are checked when a theme is constructed** — `new/1`, `merge/2`, an inline design theme — and a breach raises `ArgumentError` naming the theme, the ramp or role and the term broken, with the measured values; the validator reports the same message as `{:contract, message}`. The terms, in CIE LCh with honest tolerances: hue regions as arcs (red < 45° or ≥ 345°, amber 45–100°, green 100–200°, blue 200–315°, each placed by measuring pure sRGB primaries); chromatic at ≥ 20 C*, neutral at ≤ 10 C*; lightness monotonic, either way, at ≥ 3 L* a step between consecutive anchors (about three just-noticeable differences — visible on a screen, not only on a meter; and linear Lab interpolation makes the anchors sufficient); one hue family within 45° for anchors of ≥ 10 C*. `status` is green-or-blue → amber → red with its hue falling across every chromatic anchor and its lightness monotonic; `thermal` is blue → neutral → red; `diverging` has chromatic ends at least 60° apart about a neutral middle; `sequential` is one hue family with monotonic lightness. `:info` is a chromatic blue and `:neutral` is neutral.
- **Lightness is monotonic either way; the built-ins run it away from their background.** #533 wrote the sequential contract as "light → dark", which on a dark page would make the smallest value the most salient: the dark theme's `sequential` runs dark → light, and its `status` rises from good to bad, so on every built-in theme the low end recedes and the role that must be noticed, `:bad`, stands furthest from the page. That direction is held by the built-ins' tests and recommended to hosts, not made a contract: tying it to `background` would make `new(background: "#000")`, which keeps the default's light-page ramps, refuse to build, and a contract that fails on a one-field change is a trap, not a guarantee. What the contract fixes is that lightness carries the order, which is what a reader who sees no hue relies on.
- **The colour-vision-safe variant is a whole theme,** `colour_safe/0` (`theme: :colour_safe`): the default with `status` as blue → orange → red, still monotonic in lightness by 6 L* or more a step so that order survives with no hue at all. Its series stay the default's: no ten-colour palette is both colour-vision-safe and 3:1 on white, and the series promise only distinctness.
- **Fallback is the default theme's ramp or role, never a series colour.** `ramps` and `roles` merge key by key in `new/1` and `merge/2`, and a struct that still lacks a key takes the default's at resolution.
- **Scales range over ramps.** `range: {:ramp, name}` on an ordinal colour scale spreads its domain end to end (category i of n at `i / (n − 1)`), on a threshold scale its `k + 1` buckets the same way, and on a sequential or diverging scale interpolates the ramp; a quantize or quantile scale, whose range length is its bucket count, refuses one. The `:auto` range of a `:sequential` scale is the theme's `sequential` ramp and of a `:diverging` one its `diverging` ramp, where they ran from the background to the first series colour.
- **Precedence:** a design's literal or its colour scale, then the theme's rendering of a role or ramp position, then the theme's default. A feed's schema (#531) will name meanings and sit between the last two.
- **`to_lab/1` uses the D50-adapted matrix** of d3-color, the inverse of `lab/4`'s, and treats an exact grey as neutral, so Lab and LCh read true and `lab(to_lab(c))` is `c`.

**Consequences.** A design says what a colour means and every theme draws it in its own tones with the meaning held: the Gauge's zones are `[:good, :warning, :bad]` and are green, amber and red on the light theme, brighter on the dark, blue, orange and red on the colour-safe one. A host theme that reorders a ramp, or flattens its lightness, fails when it is built rather than drawing a misleading chart. The four ramps and the contracts are the library's vocabulary: a host cannot add a ramp or a role yet, and a request for one is a proposal. Physical anchors (a temperature's freezing point placed on `thermal`) are #531's next step and not in this one; a ramp here is positioned by `t` alone. Every `:sequential` and `:diverging` colour scale left at `:auto` changes colour: the gallery's heatmap, contour, hexbin and radial bar charts now draw along the theme's blue sequential ramp, Lab-interpolated, instead of white → `#3b6fa8` in RGB. Lab and LCh values from `Visualize.Color` change for every chromatic colour, as do `brighter/2`, `darker/2`, `interpolate_lab/3` and `interpolate_hcl/3`; nothing in the library drew through them before this decision.

## 136. D-136 — A feed describes its fields by meaning in a schema identified by a content hash; a delivery names the version and never carries the schema; a design states a contract the schema must meet

**Context.** #535, from the research of #531 (review A–C and the revision that sources carry meaning, never colour). A source gave rows only, and a design declared its slot's `fields` and `types` (spec/14 §2.3). Everything else a chart says about the numbers — their unit, a series' name, the order of a set of states and what each means, the gap beyond which a line breaks — was computed by each host before binding (metresis's `MetresisWeb.Charts.Design` builds them into variables from its catalog), so every host would repeat that step and two hosts would drift. Sending a description with every data point is out of the question at a feed's rates. Five things had to be decided: what a description holds and what it must not; how a description is identified so that a delivery can name it cheaply and every node agrees on the name; where the descriptions are held, before any board process exists to own them; what a delivery is; and how a design says it needs a description and reads one.

**Decision.**
- **A feed has a schema; a delivery names its version.** `Visualize.Data.Schema` maps a field to a description of what it means: `type`, `unit`, `quantity`, `label`, `order`, `status`, `polarity`, `target` and `continuity` (spec/08 §10.1). A delivery carries the schema's version and never the schema.
- **Meaning, never colour.** A description has no colour key: `color`, `colour`, `colors`, `colours`, `palette`, `fill` and `stroke` are faults that say so. A state's meaning is a role atom under `status` (#533's names, carried and not checked here), which a theme turns into colour. A literal colour is the design's deliberate choice, never the data's.
- **Seams, not guesses, for what other proposals own.** `unit` is any non-empty string behind one private check that #536 replaces with UCUM validation; `quantity` is any atom until #536 fixes the list; `status`'s roles are any atom until #533 defines them.
- **The version is a content hash of our own canonical encoding.** It is SHA-256 over a tagged, length-prefixed encoding whose first line names the encoding (`visualize.schema/1`). Field names are encoded as strings, since the two spellings are one column (spec/08 §6.2). A map's entries are sorted by their keys' encoding, and floats are written in their shortest round-trip form. `:erlang.term_to_binary/2` is not used: its format is not promised across OTP releases, and `:deterministic` holds only within one. A test pins one version as a literal string (spec/08 §10.2). `:crypto` joins `extra_applications` for the hash.
- **The cache is a value, not a process.** `Visualize.Data.Schema.Cache` maps `{feed, version}` to a schema. `resolve/4` answers a cached version without calling anything. For an unknown version it calls a given `describe` function once and returns the grown cache. Its owner holds it: the board process that #531 plans, and until then the caller of `apply/2`, which takes it as `schemas:` with `describe:` and returns it on the applied chart. A global ETS table would need an owner process, a name and a lifetime this library does not have. It would also make `apply/2` impure, which D-65 and D-132 kept it from being.
- **A delivery is a struct and one more source shape.** `%Visualize.Data.Delivery{rows, window, resolution, capped, reduced_by, schema}` is read by `Visualize.Data.Table.rows/1` as the source it carries. `apply/2` binds it as that source, and a compiled chart takes one per slot.
- **A design states a contract; the schema supplies the facts; `apply/2` checks one against the other** (spec/14 §7.4). A slot writes `requires: %{field => [attribute, …]}`, which names attributes rather than giving values. Its keys are the slot's fields and its values `Visualize.Data.Schema.attributes/0`, a map of lists because a design's JSON form has no tuples (§11). A design reads a fact through `Visualize.Chart.fact(source, field, key)`, a `%Visualize.Chart.Fact{}` allowed wherever a value can appear and as a text hole `#{fact(:source, :field, :key)}`. It is resolved at application from the bound schema, as a variable is from `vars:`. An axis title is then the plotted field's `label` or `unit`, and an ordinal domain its `order`. **Reading a fact is a requirement too**, so a design cannot read an attribute it did not, in effect, require. A slot whose schema lacks one is `{:missing_attribute, slot, field, key}` at the path of the requirement or of the use, which names all three. A declared type the schema contradicts is `{:schema_type, field, declared, described}`. `Visualize.Chart.contract/1` lists a design's requirements so that a picker can test a feed before offering it.
- **A schema change recompiles,** as a resize does (spec/14 §12.1, §12.5). An applied and a compiled chart record the version each slot's delivery named. `Visualize.Chart.Compiled.stale?/2` tells the caller that a delivery names another version. `tick/3`, `step/3`, `render/2` and `svg/2` refuse such a delivery with an `ArgumentError` rather than draw facts that no longer hold, such as a label, a domain or an ordered state. The compiled chart cannot recompile itself because the design and the cache are the caller's. The caller therefore recompiles and `carry/2`s the easing across, as for a resize.

**Consequences.** A host stops computing facts into variables: a metresis feed describes its columns from the catalog once per catalog change, and every host draws the same labels and orders from the same design. A design that needs a unit says so, and a feed without one fails at `apply/2` with the slot, the field and the attribute named, never by drawing a blank label. Delivering a cached version costs a map lookup per slot. Variables return to being genuine choices. Two hosts computing a schema's version independently agree, so a version can cross a wire. The version also changes whenever the content does, labels included. A relabelled field therefore costs one recompilation, which is the price of never drawing a stale label. A caller that ignores `stale?/2` is told at its next tick. The schema's checks of units, quantities and roles are deliberately loose until #536 and #533 land; each tightens behind its own seam with no change here. No mark reads `continuity` yet, and colour from `status` waits for the theme's roles (#533).

## 137. D-137 — A reader refuses a newer schema by name before it reads a key, and every reader applies sanity limits the host may tighten

**Context.** #541, from #531's review (H, item 2). visualize is on Hex, and the boards milestone makes its documents travel: a design or a board is written in one host (metresis) and read in another (Exio), across releases and across trust boundaries. A design already carried `version` and `Visualize.Chart.Migration.migrate/1` already refused one above the current, as `{:unsupported_version, v}` worded "version 3 is not supported" — which neither said what the document needed nor what this library reads, and was the same word as a version of `0`. `from_json/1` ran the codec before the migration, so a newer document was decoded by the older schema first, its unknown keys made atoms, and only then refused. A library fragment's JSON (`Fragment.to_json/1`) carried no version at all, and a bundle's provenance none. And no reader bounded what a document could ask for: a design is authored, so the codec has always trusted its names (D-57), but a host reading designs from elsewhere had no way to say how deep, how long or how many ticks it would accept.

**Decision.**
- **One version policy for every document** (spec/14 §9.1): a writer stamps the schema version it wrote — a design its `version`, a fragment document `"$version"`, a bundle's provenance `schema`; a reader refuses a newer one as `{:newer_schema, needs, this}`, worded "needs schema v3, this is v2", **before anything else is done with it** — on the parsed JSON before the codec, before the limits, before migration and validation, before a bundle's first put; an older one migrates; an unstamped fragment document or bundle reads as the current version, which is what it was written at. `{:unsupported_version, v}` stays for a version below `1`. The board (#539) follows the same rule, and its reader reads every cell's design through `from_map/2` with its limits.
- **Sanity limits on every reader** (spec/14 §9.2): `depth`, `items`, `ticks`, `iterations` and `counts`, with generous defaults in `Visualize.Chart.Limits.defaults/0`, applied by `from_map`, `from_map!`, `from_json` and `Fragment.from_json` whether or not `limits:` is given. A fault is `{path, {:limit, name, max, given}}` in the validator's own `{path, reason}` shape, so `Validator.format/1` words it and one reader serves every error. The structural limits run on the document as given — on the parsed JSON before decoding — and the schema's on the migrated design.
- **`items` is the rows limit.** A design holds no rows: a source is a slot the pool fills at application (spec/14 §7.2). So the proposal's "rows" can only mean a list held inline — a variable's default, a tick's `values`, a domain — and the limit is on the length of any list or map, which also bounds the keys a decoded map can make atoms of.
- **The host holds the number.** The defaults are this library's sanity values and every gallery design passes them; a host loading untrusted boards knows its inputs and tightens them at its layer, through `limits:`. They are sanity limits, not a render budget: runaway protection while a chart draws belongs to the board runtime's supervised cells (#540).

**Consequences.** A host on an older release gets one sentence naming both versions instead of a list of unknown keys or a raise in a migration, and no atom is made from a document it cannot read. `{:newer_schema, …}` is a new reason where `{:unsupported_version, v}` was returned for a newer version, so a consumer matching that reason for a newer version must match the new one. `Fragment.to_json/1` now writes a `"$version"` key; documents written before read as they did, and `from_json(to_json(f))` is still `f`. `Fragment.from_json` gains two error reasons, `{:newer_schema, …}` and `{:limits, errors}`, in its existing single-reason shape. A design over a default limit no longer reads through `from_map/1`; the defaults sit orders of magnitude above any gallery design, and a host with a genuine need sets its own. A variable's value is the host's at application and is not checked, so a limit bounds the document, not every value the document can be bound to. `Composite.flatten/2` is not limited: it reads the host's own store, whose cycles it already refuses, and a fragment that arrived from elsewhere passed `Fragment.from_json/2`'s limits on the way in.

## 138. D-138 — Units are UCUM codes and a fixed list of quantities chooses display scaling; an axis takes them as a `measure`

**Context.** #536, from #531 item 6. A unit needs one name: if metresis says `"s"` and Exio `"sec"`, labels and formatting drift, and a dashboard that embeds both shows two words for one thing. A field also needs to say what it measures so it is displayed sensibly — 1,500 ms as 1.5 s, 2,500,000 bytes as 2.5 MB — and that choice depends on the domain, so it belongs beside #532's derived formats (D-134) and not with each host. Measured before the change: an axis's only unit was `unit`, a literal suffix (#135: `"M"`, `" c/s"`, `"/yr"` in the gallery), the one scaled format was `:duration` over seconds (#191), and metresis labels its y axis with the unit — else the quantity — by building a frame label from its query result in every panel (`MetresisWeb.Charts.Design.y_labels/1`), a stopgap its own comment names as waiting for visualize.

**Decision.**
- **Units are UCUM codes** (ucum.org), as OpenTelemetry and FHIR use them. `Visualize.Unit` reads a strict subset of UCUM's grammar — products `.`, quotients `/`, signed integer exponents, integer factors, `{annotation}` blocks — over a fixed table: prefixes `k M G m u n Ki Mi Gi`, atoms `s min h d By bit % Hz Cel [degF] K m g W J Pa`. A symbol is matched whole before it is split into prefix and atom, as UCUM requires, so `min` is the minute; a prefix is refused on a non-metric atom (`kmin`). Strict enough that `sec`, `B` and `m//s` fail; permissive enough for every code OpenTelemetry's conventions and `Telemetry.Metrics` produce. Parentheses and UCUM's other atoms are left out until a source sends one.
- **A code has a dimension and an exact factor** (rationals, so 1,500 `us` is exactly 1.5 `ms`), and `Cel` and `[degF]`, UCUM's special units, have no factor. Scaling between two codes of one dimension is multiplication by the ratio; **conversion between unit systems (°C/°F) is out of scope** and raises.
- **`Telemetry.Metrics` units map by a table of atoms**, with no dependency on `telemetry_metrics`. `:native` is refused (it has no size until converted) and so is an unknown atom, rather than becoming an annotation that would hide a typo.
- **A fixed list of twelve quantities**, each with a base unit and a display family: `duration`, `information`, `data_rate`, `frequency`, `ratio`, `count`, `temperature`, `length`, `speed`, `power`, `energy`, `pressure`. The list grows by proposal. A unit implies its quantity by its dimension; a quantity alone means its base unit; a pair whose dimensions disagree is refused.
- **Decimal information by default; the data picks the family.** A unit in `kBy` (UCUM's 1,000 bytes, and `Telemetry.Metrics`' `:kilobyte`) or `By` scales `B → kB → MB → GB`; one written with a binary prefix (`MiBy`) scales `KiB → MiB → GiB`; one in `bit` scales in bits. Likewise a frequency in `Hz` scales in hertz and an event rate (`{request}/s`) in `/h`, `/min`, `/s`, and an energy in `kW.h` in watt-hours. There is no setting: which family is a fact the unit already states.
- **The display unit is the largest family member in which the domain's magnitude is at least one**, and the ticks are formatted by #532's `notation/2` and `number/2` in that unit, then suffixed with its symbol (`1.5 s`, `45%`, `12/s`). One display unit per axis, as one SI prefix per axis (D-134); a log axis gives each tick its own. A `count` is SI from a thousand.
- **The axis key is `measure`, a node of `unit` and `quantity`.** `unit` on an axis is already #135's literal suffix, used by the gallery with non-UCUM strings; giving it a second meaning would make `unit: "M"` ambiguous, and renaming it would be a version-3 migration of every stored design for a word. A node also matches the field description's shape (#531), so #535 supplies an axis's measure from a feed's schema in one place. `measure` and `quantity` are `:content`, and **they are facts, not knobs**: what is shown (`format`, `ticks`) stays the design's to override, and an explicit `format` wins over the scaling.
- **The label rule is "the unit, else the quantity, else nothing", and the axis says it once.** When the ticks carry the unit, nothing more is drawn; when they cannot — a count (`1`, `{request}`), an explicit `format`, a non-numeric scale — the axis draws a title by the rule, placed as an `{:axis, s}` label and inside the axis's region, unless the design anchors its own label there. Drawing both a suffix and a title of the same unit (`s` above `0.5 s, 1 s, 1.5 s`) was considered and not taken: it says one thing twice, and a title in the data's unit (`ms`) beside ticks scaled to `s` would contradict them.

**Consequences.** No existing design changes: `measure` is a new key, and the goldens move only where a design or a fixture opts in. A host stops computing an axis's unit label (metresis's `y_labels/1` and its number formats can go once it passes `measure`, a follow-up there) and a source's unit reaches the screen in one spelling. Information defaults to decimal, so a host that wants `MiB` says so in its unit. Temperature, speed and ratio display in the unit they are given; °C/°F conversion, physical anchors and the `thermal` ramp a temperature selects (#533) are later proposals that can read the same measure.

## 139. D-139 — A theme is a stored document read like any other, edited in a theme mode of the builder whose checks are the library's contracts and whose colour-vision simulation is a pure transform

**Context.** #534, from #531 and #533. Themes were code: `Visualize.Theme` is a struct, `default/0` and `dark/0` are functions, and a host that wanted its own look wrote Elixir and saw nothing until a chart drew. Once a theme carries meaning-bearing ramps and roles with contracts (D-135) and colour comes from the theme rather than from a design's literals (#531), the theme is where a host sets its look, and it needs a tool. Five things had to be decided: what a theme is when it is stored and how it is read back; how a host ships one and a design finds it; where the tool sits in the builder; what it previews; and how a person judges what they cannot see — a contract broken by an edit, and a ramp's reading under a colour-vision deficiency.

**Decision.**
- **A theme document is the inline `:theme` node of spec/14 §2.5, stamped.** `Visualize.Chart.Theme.to_map/1` is `%{version: v, theme: node}` with every field of the struct; `to_json/1` is the fragment document `{"$version": v, "theme": {…}}` that `Visualize.Chart.Fragment.to_json/1` writes for a theme fragment. There is no second schema and no second version: the theme node is a node of the design schema, so it is versioned by `Visualize.Chart.Migration.current/0` and read under D-137's one policy — the version gate before anything else, then the `depth` and `items` limits, then the migration of §9 (no step so far changes a theme node, and one that does will migrate stored themes with the designs that carry them), then per-kind validity, which includes the meaning contracts. A theme document holds no variable, since nothing applies it, and a name that is not built-in is not a theme; both are faults in the validator's shape, so one reader serves every error. The store keeps the fragment, `%{theme: node}`, which is already a library kind (§19.8); nothing about a library entry changes.
- **A host ships a theme as a value, found by its name.** `Visualize.Chart.Builder.Store.theme/2` loads the stored theme whose own `name` — else the last segment of whose entry name — is the one asked for, read through the codec. The builder's preview resolves a design's `theme: name` that is not built-in through it, which is the `theme:` option of `apply/2` (§2.5) supplied by the library rather than by the host's code. A host outside the builder does the same with `from_map/2` and `apply(…, theme: theme)`.
- **The theme mode is the builder's shape when the open chart is a theme** (§19.9: the editor's shape is the open fragment's kind). It is entered by opening a theme entry, by *New → theme*, or by *Theme* on a chart, which opens the chart's own theme — the stored entry when the design names one, else its built-in or inline theme as a new theme. In the mode the main column, which had nothing to show for a theme, is the **preview sheet** and the inspector is the **theme editor**: series colours, chrome (background, surface, text, axis, grid), fonts, each ramp's anchors with the interpolated ramp drawn live, the roles, and *derive from* — a built-in or a stored theme whose values replace the open theme's, so dark from light or colour-safe from either is one choice and then a change of tones. Edits write the open site's `theme` node as every form writes a node, and saving is the existing *Save to library* under the kind's per-kind validity, which already refuses a broken contract.
- **The preview sheet is fixed samples, not the person's chart.** A theme is judged against everything it must serve, so the sheet draws one sample per thing a theme carries — categorical series, a status chart coloured by the five roles, a thermal and a diverging chart, and axes, grid and text — each through `apply/2` and `render/2` in the theme being edited, built unchecked (`Visualize.Theme.build/2`) so a theme that breaks a contract still draws while it is fixed. Designs from the store can be added to the sheet, drawn the same way.
- **Live checks are the library's contracts, reported per ramp.** `Visualize.Theme.violations/1` is `check/1` run on each ramp and role alone, so every broken one is named, not only the first; the editor shows each under the ramp or role it concerns with the contract's own words. Role *positions* are the library's (D-135: `:good`, `:warning` and `:bad` stand at 0, 0.5 and 1 of `status`), so the editor draws each role at its position and sets the colour there — the anchor that stands at it — rather than moving it: a role that moved would break the guarantee that a role and its position are one colour.
- **Colour-vision simulation is a pure colour transform.** `Visualize.Color.Vision.simulate/2` applies the Machado, Oliveira and Fernandes (2009) matrices at severity 1.0 for protanopia, deuteranopia and tritanopia in linear sRGB, then re-encodes and clamps. The model is physiologically based and one matrix per deficiency, so it is cheap enough to run over every colour of a preview on every edit; Brettel, Viénot and Mollon (1997) is more exact for tritanopia but is two half-planes per deficiency, and Viénot, Brettel and Mollon (1999) covers dichromacy only by a projection that does not grade severity. The sheet is simulated by mapping every literal colour of its rendered markup through the transform, so an interpolated ramp colour is simulated as it is drawn, not as an interpolation of simulated anchors. Each ramp carries a lightness strip — the CIE L* of each step as a grey — because lightness is what a reader with no hue at all still reads, and it is what the monotonic-lightness contract protects.

**Consequences.** A theme is data a host stores, exports in a bundle and ships, and a theme from another host is refused by name when it is newer and bounded when it is large, as a design is. `Visualize.Chart.Fragment.read_json/2` returns the version a fragment document was stamped with, which `from_json/2` drops, so a reader that migrates what it reads has it. A design that names a stored theme previews in it in the builder; outside the builder the host still passes `theme:`, because `apply/2` takes no store. The editor cannot save a theme that breaks a contract as a valid one, and cannot move a role off its position; a host that needs another meaning proposes a ramp or a role (D-135). The simulation is a model of the commonest dichromacies, not a measurement of a reader: anomalous trichromacy (a severity below 1) is not offered, and the transform is not used for anything but judging by eye.

## 140. D-140 — A feed is a behaviour whose window, budget and push are requests visualize finishes; the loop is a value the board's process holds; live and history combine by window in visualize

**Context.** #537, from #531 (review F and the further considerations of 2026-10-11, §1–§5). D-136 gave a feed's answer a shape — a schema by version and a delivery — but nothing asked a feed for one: a host polled and re-applied by hand. Providers differ: some answer a window and a point budget, some push, and some can only hand over rows. Five things had to be decided: the contract's form and its minimum; what visualize does when a provider does less than it was asked; how pushes are ordered and absorbed at a feed's rate; who runs the loop, when the board process that will own it (#540) does not exist yet; and, from the acceptance, how one slot shows the last hour from Exio's `:telemetry` and older data from metresis.

**Decision.**
- **A behaviour plus messages** (spec/08 §11.1). A feed is a struct whose module implements `Visualize.Feed`: `describe/1` answers `{version, schema, supports}`, `supports` a subset of `[:window, :budget, :push]`; `fetch(feed, window, budget)` answers `{:ok, delivery}`; `subscribe/2` and `unsubscribe/2` are optional. A protocol was not taken: providers are modules and processes, not data types, and a protocol would dispatch on a value that is mostly a handle. Every callback answers `{:error, reason}` rather than raising, so a feed that is down is a cell's state.
- **The window and the budget are requests; only rows are required** (§11.2). Where `supports` lacks one, `Visualize.Feed.finish/3` does it: it trims to the window by the schema's one `:time` field, and reduces to the budget with `lttb/3` or `m4/3`, grouped by the `:category` fields so each series keeps its shape, every `:number` field reduced and the union of the kept rows kept whole, recording `reduced_by: :visualize`. A feed that names `:budget` is never reduced again. A schema with no time field is reduced against the row's position and not trimmed, so a feed without one honours its window itself.
- **Push is two messages, schema before data, with `describe` as the fallback** (§11.3): `{:visualize_feed, ref, {:schema, version, schema}}` and `{:visualize_feed, ref, {:delivery, delivery}}`, a pushed delivery an increment. The subscribe handshake sends the schema first. Ordering makes push cheap; the fallback makes it correct.
- **The loop is a value, not a process** (§11.4). `Visualize.Feed.Monitor` is held by the owning process — #540's board process, a LiveView until then — which calls `tick/2` per frame and `handle_message/2` from `handle_info/2`. Pushes buffer and the next tick drains them in one merge, one trim and one reduction: the data-rate/frame-rate split of #300 and #361, with no other backpressure. A monitor process was not taken: it would be a second owner of the window and of "now", which #531 gives the board, and would copy every delivery across one more process boundary. #540 builds the board runtime on the value, as the board will hold D-136's cache. Errors and staleness are the states `:loading`, `:live`, `:stale` and `{:down, reason}`, with "capped" and "no data yet" read from the delivery.
- **Live plus history is a combining feed in visualize** (§11.5), not the connector's job. Choosing the stores is the connector's — only it knows that Exio's telemetry and metresis's capture are one metric — but routing a window across feeds, merging and deduplicating the seam is the same for every host, and two connectors writing it twice is the drift #531 exists to remove. `Visualize.Feed.Combine` asks its feeds freshest first: each serves what it can, its delivery's `window` says where it starts, and the rest of the window goes to the next, each part trimmed to `[from, served)` so a seam row comes from the fresher feed. Every part must describe the same schema version, because a delivery names one; making two catalogs agree is the connector's. Pushes pass through from the first feed unchanged, which the shared version makes correct.
- **Two providers prove the contract** (§11.6). `Visualize.Feed.Signals` (pull, window honoured, its tick the time field) and `Visualize.Feed.Telemetry` (push, one feed per metric: `t`, `value` and the tags, the unit through D-138's `Unit.from_telemetry/1`). The telemetry provider reads `Telemetry.Metrics` definitions by their fields, so `telemetry_metrics` is a test-only dependency, and `:telemetry` is optional under D-50's declaration. Its collector stamps each row on receipt with a strictly increasing clock, which makes the monitor's seam between a fetch and the pushes after it exact.

**Consequences.** A host stops polling and re-applying by hand: it holds a monitor, ticks it, and passes `deliveries/1` and `cache/1` to `apply/2` or a compiled chart. A small source implements two callbacks and works; a large one adds `:budget` to stop shipping raw points. A board never knows which provider fills a slot. Exio can show its own last hour live and metresis's history in one chart once a metresis feed (in `metresis_client`) describes the telemetry provider's schema. A metric becomes a slot, not a column, so a board with ten metrics binds ten feeds, which the monitor polls and drains independently. The telemetry provider stamps rows on receipt, so a row's time is the collector's, a few microseconds after the event; a host that needs the event's own time sends it as a measurement. Reduction is per tick, of the window's whole holding: a push feed's rows are held unreduced within the window, bounded by the window and the provider's retention. The conformance kit (#538) drives both providers through the behaviour alone.

## 141. D-141 — A board is a versioned 12-column grid of composite refs that never flattens; its sources are its cells' slots, namespaced per cell, and a connector's separate binding fills them

**Context.** #539, from #531 (§1, further considerations items 3–5, review H). A dashboard is many charts laid out together, sharing a time window and interaction but not geometry. A composite flattens into one picture, so it is the wrong tool (spec/14 §19.6). metresis holds its dashboards as panels on a 12-column grid, each a query plus a stored design, and Exio is to show the same boards with its own feeds. So the board has to be data that travels between hosts while its feeds do not. Five questions were open: what a cell refers to, the grid model, how board variables reach the components, how the sources a board needs are stated when one design appears in two cells, and where the knowledge of feeds lives. The runtime (#540) is separate. This decision is the value alone.

**Decision.**
- **A board is a value with a schema of its own** (spec/15 §1): `%Visualize.Board{version, meta, cells, window, zone, vars, sync, layout}`. Cells are a map by name, and a name is a cell's identity, as a frame's is in a design. A cell is `{x, y, w, h}` plus `ref`, a **composite id** in the store (spec/14 §19.2). The entry it names is a composite or a flat design, which `Composite.flatten/2` reads alike. A design has no id of its own (spec/14 §19.8), and a board holds no designs inline, so everything it shows is in the store, where the builder edits it and a bundle carries it. The board's schema is not added to `Visualize.Chart.Schema`'s node kinds. Those are the kinds of a design's fragments, and a board is not a fragment of a design.
- **Twelve columns, not generalised** (spec/15 §1.4). It is metresis's grid, so its migration is mechanical, and 12 divides every layout seen. A `columns` key is `:unknown_key` today. Adding it later is additive, under a newer version an older reader refuses by name.
- **Shared state** (spec/15 §1.5). The window is `nil`, `%{last: seconds}` or `%{from, to}` in `Etc/UTC`. Now comes from the caller, never from a feed, and `window/2` resolves it. `zone` is for display and is checked as a time scale's zone is. `sync` is the group every cell with `sync: true` joins. The board's group replaces a design's own, and a component that cannot join is a fault at the cell, never silently left out, so a gauge says `sync: false`.
- **Reflow stacks cells in reading order** (spec/15 §1.6). Below `breakpoint` (768 px), cells are taken by `y`, then `x`, then name, and stacked full width, each keeping its height. `layout`'s defaults are metresis's: 80 px rows and 8 px gaps. `boxes/2` is a CSS grid's arithmetic, and a cell's box is the size its component is applied at.
- **Variables cascade: cell over board over the component's default** (spec/15 §5). A board variable is a default and reaches only components that declare its name. One no component declares is not a fault, as an unused pool entry is not. A cell variable must name one its component declares. Nothing is renamed on the way in. Renaming is a use site's job (spec/14 §19.4).
- **Version and limits are D-137's** (spec/15 §2.3). The map form carries `version`, and the JSON form the stamp `"$version"`, as a fragment document does, so a reader gates any non-design document by one key. A newer board is `{:newer_schema, needs, this}` before a key is decoded or a cell is read, and an unstamped board reads as current. Boards begin at schema version 2, so a lower version is `{:unsupported_version, v}`. `depth` and `items` bound the board document, and every component is read through `Chart.from_map/2` with the board reader's limits, which also migrates a stored version 1 design. The JSON decoder leaves an unknown key a string, so a misspelling makes no atom.
- **Validation in the path-error shape, alone and given a store** (spec/15 §3). Alone, it checks keys, types, off-grid cells (`{:off_grid, 12}`), overlaps (`{:overlap, other}`, once per pair at the later name), the window and wrong-kind refs. Given a store, a fetch function or a map of id to fragment, it adds dangling refs (`{:dangling, id}`), cycles, component design faults under `[:cells, c, :ref | path]`, undeclared cell variables, and cells that cannot join the group. The store is a fetch, not the builder's `Store` behaviour, because that compiles only with Phoenix and a board must not.
- **Required sources are the aggregate of the cells' slots, keyed `{cell, slot}`** (spec/15 §4.1), each with `fields`, `types` and its whole contract from `Chart.contract/1` (D-136). One design in two cells is two sets of slots, bound independently.
- **The binding is a separate value, the connector's** (spec/15 §4.2–4.4). `%Visualize.Board.Binding{slots: %{{cell, slot} => feed}}`. `check/3` reports, by path `[:cells, c, :sources, s]`, every unbound slot (`{:unbound, :binding, s}`), every contract the bound feed's schema cannot meet (the reasons `apply/2` uses, so the words match), a feed with no schema to check (`{:undescribed, feed}`), and every binding to a slot no cell requires (`:not_required`). A slot with an empty contract is not looked up. `pools/2` turns a binding and a host's sources into one pool per cell. The binding has no JSON form here: it is the host's, and it travels with the host.
- **`realise/4` falls out and is kept** (spec/15 §6): every cell resolved, its variables cascaded and narrowed, its group written and its component applied at its box's size, purely, with the schema cache threaded across cells. `describe:` is called with `{cell, slot}`, which the connector maps to its feed. Faults are reported under their cells, and an unbound slot lands at the same path the binding check uses.

**Consequences.** A metresis panel maps to a cell field for field, and its stored design to a component. A host can check a board against its store, and its binding against its feeds' schemas, before any data flows, and sees the same reasons `apply/2` would give one design. The board holds nothing a provider owns, so one board document serves metresis and Exio unchanged. Boards add a spec document, 15. A cell whose component cannot sync must say so once the board names a group. That is a deliberate cost: a cell that dropped out of the group silently would be found only by hovering. The window is carried and resolved, but not applied to a component here. Windowing rows is the feeds' and the runtime's work (#540), as are cell states, processes and rendering. A bundle carrying boards with their components is #542's.

## 142. D-142 — A chart is accessible by construction: a summary and a data table generated from the design and its data, keyboard navigation inside one tab stop, reduced motion on both sides, and roles that read without hue

**Context.** #546, from #531 item 8 and its colour item 6. Before it, a rendered chart was `role="img"` with a `<title>` and `<desc>` only when the design's `meta` carried them (D-56), so most charts were an unnamed image; `aria-labelledby` named both texts, which makes a long description part of the name; a canvas, and a hybrid chart's stacked layers, carried nothing at all — three `role="img"` SVG layers, none of them named. Nothing reached a chart's points from the keyboard. The motion a chart has is the server's — a scale's `transition` and a mark's eased readings (§12.5) — and the components' CSS `transition`; none of it asked whether the reader wants motion. And a status shown by the theme's roles (D-135) rested on hue alone, which a greyscale print and about one man in twelve cannot tell apart.

**Decision.**
- **The text alternative is generated, not written** ([14-declarative-chart](14-declarative-chart.md) §21.1). A design already says what a chart is — its kind follows from its marks, frame and layouts, its axes from its scales and measures (D-138), its series from its `series` channels — and the applied data says the rest: the domains and the extremes. `Visualize.Chart.Summary` reads both, so every chart, the gallery's included, is described with no author effort, and the description follows the data. Values are written as the axis writes them, in its measure's display unit, to four significant digits rather than at the ticks' step, so a summary never contradicts the axis and never rounds an extreme to a tick. A design's own `meta.description` comes first, so an author who knows what the chart is *for* still says it.
- **Every root is named, and the name is short** (§21.2, amending D-56). The title defaults to the summary's name — the design's name, else its kind — and `aria-labelledby` names only the `<title>`, `aria-describedby` the `<desc>`: a paragraph as an accessible name is read in full on every visit, a description is read on request.
- **The data table lives beside the chart** (§21.3). A `role="img"` element presents its subtree as one image, so a table inside the `<svg>` — a `<foreignObject>` — would be hidden by the very role that names the chart. The table is an HTML string, visually hidden by the clip pattern so it is still read, one table per data mark, columns from the fields a scale maps: what the reader would read off the axes, not the layout's pixels.
- **Whatever stands for the chart carries the summary** (§21.4). A canvas is named and described (`role="img"`, `aria-label`, and its fallback content as the description); a hybrid chart's SVG layers are `aria-hidden`, since none of them is the picture, and the page names the wrapper of the layers with `Summary.attrs/2`.
- **Keyboard navigation is one tab stop per chart, arrows inside it** (§21.5, #558). A chart that carries its points' readout is focusable once; the arrow keys move between its points and its series and a polite live region reads the point. A board (#539) adds the order across cells with the same rule one level up — a cell is one stop, Tab moves between cells — so neither level needs the other's details.
- **Reduced motion is honoured on both sides** (§21.6, #559). The hooks read `prefers-reduced-motion` and tell the server, and a compiled chart told to reduce motion jumps every transition and eased reading to its target; the components' CSS transition stops under the same media query.
- **A role reads without hue** (§21.7, #560). Each of the five roles carries a glyph and a dash pattern the theme fixes, as it fixes their hue order, and a mark can draw them, so a status chart in greyscale still says which zone is which.

**Consequences.** Every root `Visualize.Chart.render/2` writes with `root: true` gains a `<title>` when the design had none, a longer `<desc>`, and `aria-describedby`: the charts golden changes on every line, and only in the root's first element and its `<desc>`. The two component fixtures with a title and a description change for the new attribute. The compiled chart's static, dynamic and backdrop documents gain `aria-hidden="true"`. A root render now also applies the summary's reading of each mark's rows, a second pass over the pipeline, measured against the gallery's cost budgets (#469); a caller that renders many roots per second and needs no summary passes `summary: false`. The later work items add the readout attributes, the reduced-motion option and the roles' glyphs and patterns, each with its own golden consequences recorded in its section.

## 143. D-143 — What a host drew around a chart is marks fed from sources: a horizontal rule, cut rules and bands, a mark's own colour scale, a map of colours, polarity on a ramp, and the design's brush

**Context.** #543, from #531 §4 and review I. metresis drew its event markers, its threshold lines and its brush *around* the visualize group, inside its own `<svg>`, placed with the realised frame's scales and its own margins (`MetresisWeb.Charts.Frame.events/1`, `thresholds/1` and `svg/1`; `MetresisWeb.DashboardLive.brushed_window/5` turning the brush's pixels back into instants through a time scale it rebuilt). A second host — Exio drawing the same design — would lose all three, or copy them. The audit (#565) found the geometry already in the library: an instant is a `:rule`, a span an `:x_band` (#1). What was missing was the rest. A rule could stand at `y` only in a polar frame, so a threshold had no mark. Marks are unclipped, so a span that began before the window was drawn across the axis. A style's colour binding always read the frame's `color` scale, so events coloured by severity in a chart whose `color` scale is its series' would be coloured as series — the spec even said so ("whatever the mark's `scales` say"), while a `series` channel's validation honoured the rename and its paint did not. A feed's schema says what a severity means (`status`, D-136) and the theme draws meanings (D-135), but no scale read a map from a state to a role, and nothing read `polarity`. And the brush had no way to say where the plot was or what its pixels meant, so every host computed both.

**Decision.**
- **A `:rule` at `y` in a cartesian frame is a horizontal rule across the plot width**, the counterpart of the polar arc at a radius, labelled at its left end. It is what a threshold is, fed from a threshold source (`y: :value`) or from a feed's `target` (`y: fact(:primary, :latency, :target)` over `take(1)`). A new mark type was not added: the rule already means "a line through the plot at a reading", and the validator already allowed `y` without `x`, drawing nothing in a cartesian frame.
- **In a cartesian frame a rule and an x band are cut to the plot area.** A rule outside it draws nothing; a band is clamped to `[0, width]` and draws nothing wholly outside. A whole-mark clip path was considered and not taken: it would change every mark on every backend, the binary stream has no clip record, and only these two marks span the plot by construction.
- **A mark's colour scale is `color` renamed by its `scales` node, for every colour the mark reads** — its series paint, its style's colour bindings, the inference over both, the validator's domain check and a compiled chart's regions. This reverses §4.3's "whatever the mark's `scales` say" for style bindings, which made a renamed colour scale half work. No existing design changes: no gallery chart, preset or fixture renames `color` and binds a style colour, and the one test that renames it reads a `series` channel, which the validator already treated this way.
- **An ordinal colour scale's range may be a map of colours** (`:colour_map`), a category to a colour, the shape a schema's `status` is in, so `range: fact(:events, :severity, :status)` colours by the feed's meaning. The realised scale keeps the domain's members the map names; any other value is outside the domain and draws `unknown`, which on a map range defaults to the theme's `:axis` slot as on `color` (D-122). In JSON it is a `$colours` pair list, since an object's keys are strings and a category need not be.
- **`polarity` on a colour scale over a ramp places the ramp by worth**: `:lower_is_better` reads it as it is, `:higher_is_better` reversed, `:neutral` folded about the middle (`|2t − 1|`). With a `:threshold` scale over the feed's `target` and `range: {:ramp, :status}`, a reading on the better side is `:good` and one beyond it `:bad`, whichever way the feed says is better. Polarity was put on the scale rather than on a mark because it says how values map to meaning, which is what a scale is; on any other range it is a fault, since a list or a map already says each colour.
- **The brush is a design key, `interaction.brush: :x`.** The brush frame (the sync frame's rule) writes `data-vis-brush`, its `x` mapping (`data-vis-brush-x`, the sync group's five numbers) and its plot area in the chart's coordinates (`data-vis-brush-extent`). `BrushHook` reads them from a descendant when its element gives no type or extent of its own, re-reads them on `updated()`, and adds `domain` to every `brush_select` it can map, not only in a sync group. It is held to a cartesian frame with a linear or time `x`, as a sync frame is.
- **What stays the host's**, from the audit: the hover text and a click are a tooltip (§5.7); the compass needle is `:needle` (#392); a state timeline's row names are an axis on a `:band` scale; the legend is the frame's (§4.5). A "capped" note is page text, and belongs to a delivery's `capped` (D-136) when the board shows it.

**Consequences.** A host draws a chart's overlays by adding marks and a scale to the design, and every host that applies the design draws the same overlays in its theme's colours on SVG, the canvas, the binary stream and the hybrid page, since rules and bands are ordinary elements. metresis can delete its overlay components, its brush geometry and its inverse once it adopts them, a follow-up in metresis after visualize's release. A rule or an x band that a design placed outside its plot no longer draws, which no gallery chart does. A mark that renamed `color` and coloured its style by a field now reads the renamed scale, as the spec said for its channels. `polarity`, `brush` and the `$colours` term are new keys and a new term: an older reader refuses a design that uses them only as an unknown key or term, and D-137's version policy is unchanged, since none of them changes a key that existed.

## 144. D-144 — A string naming a member of an enumeration alternative reads back as that member

**Context.** #554. spec/14 §11.2 promises `from_json(to_json(chart)) == chart` for every valid chart, and two gallery designs broke it. The first was a missing tagged term: `{:scale, :min}` and `{:scale, :max}` (§5.2, #420) had none, so `to_json/1` raised. The second showed once the first was fixed and every gallery design was held to the round trip. An axis's `format` is `{:one_of, [:string, {:enum, [:duration]}]}`. An enumerated atom is written as its string (§11.1), so `:duration` became `"duration"`, and the reader took the first alternative of a string's shape, `:string`. *Load age* therefore read back with a format specifier spelled `duration` in place of `Visualize.Format.duration/1`. An audit of every `{:one_of, …}` in the schema found no other key with both a string and an enumeration alternative. It also found no other tuple a validator accepts without a JSON form.

**Decision.**
- **A string that names a member of an enumeration alternative of a `{:one_of, …}` reads as that member, before any other alternative is tried.** Otherwise the reader takes the first alternative of the value's shape, as before. The rule is the codec's and holds for every key, so a later key that pairs a string with an enumeration needs nothing of its own.
- **The atom keeps its plain string.** Tagging the enumeration's member (`{"$atom": "duration"}`) was considered and not taken. It would make one key's enumeration the only one in a design not written as its string, and it would change the form of documents already written.

**Consequences.** The format string `"duration"` cannot be written: it reads as `:duration`. As a specifier it means nothing, and on a time axis it would print the literal word on every tick, so no design loses a meaning. A document written before this reads as it was meant: `"duration"` was only ever written for `:duration`. The property test's generator draws `{:scale, _}` channels and `:duration` formats from now on, and leaves out the string `"duration"`. The gallery holds every design to the JSON round trip and to the default limits through the JSON reader.

## 145. D-145 — The feed conformance kit is checks that answer findings plus a macro that writes the tests; it ships in `lib/` and references no ExUnit function, so it needs no guard

**Context.** #538, from #531 item 12. D-140 made a feed a behaviour, and spec/08 §11 says what a provider must honour: a schema by content hash, deliveries that say truly what they served, capabilities that are true, the schema before the data, errors as values. Each provider reading that prose for itself is exactly the drift #531 exists to remove, so the reading had to be a test every provider runs — `Signals` and `Telemetry` here, metresis's feed in metresis. Three things had to be decided: how a shipped test is shaped so that it can itself be tested; how it compiles in a consumer, for whom ExUnit is a test-time tool; and what "honest" means where a single delivery cannot show it.

**Decision.**
- **Two layers** (spec/08 §12.1). `Visualize.Feed.Conformance.check/3` runs one of six checks and answers findings, `{check, reason}` terms with `format/1` to word them; `use Visualize.Feed.Conformance, opts` expands, inside the caller's `ExUnit.Case` module, to one `describe` block of six tests that `flunk` with the formatted findings. Findings are values, so the kit's negatives are ordinary assertions against a deliberately broken fake feed rather than a test suite run inside a test suite, and a host can call `check/2` anywhere — a health page, a connector's own probe.
- **No guard, because nothing to guard** (spec/08 §12.1). The kit lives in `lib/` and its module calls no ExUnit function: `describe`, `test` and `flunk` appear only in the quoted code the macro returns, compiled in the caller where `use ExUnit.Case` imported them. A guard like the optional dependencies' (`Code.ensure_loaded?(ExUnit.Case)`, D-45, D-50) was considered and not taken. ExUnit is an application of Elixir's, not a dependency, and whether it is on the code path when a dependency compiles is Mix's code-path pruning to decide (the trap D-45 records for Phoenix): the kit would exist or not depending on how a consumer's build happened to load it. Listing `:ex_unit` in `extra_applications` would start it in every host's release. A module with no reference to ExUnit compiles the same everywhere, and the consumer check asserts it loads in a `MIX_ENV=prod` consumer.
- **Every callback runs in a monitored process** (spec/08 §12.2), with the caller in `$callers`, under `timeout:`. A raise is then the finding `{:raised, call, reason}` — the contract says errors are values (§11.1), so a raise is a fault to report, not a crash of the checker — and a hang is `{:timeout, call}`. Process isolation, not `rescue`, is how the kit observes a raise. `$callers` lets a provider backed by a sandboxed repository answer from the probe's process, as from a `Task`.
- **Honesty is checked against a second fetch** (spec/08 §12.3). One delivery cannot show that it is complete, so the kit also fetches the **open** window `[nil, to]` with no budget and compares: rows the feed holds within the served window must be in the delivery (the served window is a claim of completeness), `capped: false` means the feed held nothing between the asked `from` and where it served from, `reduced_by` must agree with the row counts with and without the budget. `capped: true` must show a shortfall: the served window starts after the asked `from`. These read §10.4's words as checkable facts without any knowledge of the provider. A provider that is asked before its own data begins and has nothing there — a signal before tick 0 — is uncapped, and one that may have dropped older data — the telemetry collector before its start — may say capped; both are honest, and the kit accepts both.
- **What is declared is probed, and what is not declared is not done** (spec/08 §12.3). A capability the options give nothing to probe is a finding (`{:no_probe, c}`), so a provider cannot pass `:window` by being asked only `nil`. A feed that does not name `:push` must refuse `subscribe/2`.
- **The options are expressions** (spec/08 §12.2). `feed:` and `down:` are evaluated in each generated test, in the test process, so a provider whose feed is a process starts it with `start_supervised!/1` and gets a fresh one per check; `push:` is a function of the feed that makes it push, since only the provider knows how. `windows:` is a list because a window is itself a list.

**Consequences.** A provider's suite gains six tests with one `use`; metresis runs the same reading of the contract as this library does, and a host that wants a stricter rule proposes it here, where both providers are checked by it. The kit's module is compiled into every consumer, test or not — a few kilobytes of pure functions nothing calls in production. `Visualize.Feed.Signals` and `Visualize.Feed.Telemetry` pass unchanged. The kit fetches each probe window twice and every capability's probes on top, so a provider whose fetch is expensive should give it small windows. Resolution is checked only as "not finer than claimed", in the time field's own units for numeric times; a provider whose resolution is a name the kit does not know (other than `:second` … `:week`) is not checked on it.

## 146. D-146 — A board runs as one process that owns the shared state and the feeds and one heap-limited process per cell that the board restarts; the host places the supervisor; every cell state is drawn

**Context.** #540, from #531 (further considerations §2, §3 and §5). A board (D-141) is a value, and the feed loop (D-140) is a value its owner ticks. Something has to keep a board live: drive the feeds, hand each cell its data, draw many charts at once, and show loading, empty, stale and failed data on purpose. Four things had to be decided. The first was how many processes, and who owns what. The second was how a pathological design is stopped without a scheduler of our own. The third was who supervises, since #540 left that open and the policy is that a host is in control. The fourth was where time (the window, late rows, gaps) is handled, and how each state is drawn.

**Decision.**
- **One board process, one process per cell** (spec/15 §8.2, §8.3). `Visualize.Board.Server` owns the window, the zone, the variables, the sync group and the board's single now, read once per turn from its `clock:`. It also owns the `Feed.Monitor`: it ticks it at the board's data rate, routes pushes to it and re-asks every feed (`Monitor.refetch/1`) when a window is set. It sends each cell its slots' deliveries, schemas and statuses with a `send` only when they changed. `Visualize.Board.Cell` applies, compiles and steps its component, and sends its views **straight to the subscribers**, so no frame crosses the board's mailbox. A cell coalesces its data messages and draws only the newest, so one that falls behind skips to the present. Fairness is the BEAM scheduler's. A custom scheduler, or one renderer for all cells, was not taken: either would make the slowest design everyone's frame rate.
- **Runaway protection is `max_heap_size` with `kill: true`, and the restart is the board's** (§8.3). Each cell sets the flag first (`max_heap:` words, 25,000,000 by default), so a design that grows its heap without bound is killed alone. Cells are `:temporary` under a `DynamicSupervisor` the board links as their parent. The board monitors them, draws `{:crashed, reason}` at once and restarts a dead cell after a backoff (100 ms doubling to 10 s, reset after 30 s alive). A supervisor's restart was not taken: a design that dies on every frame exhausts any restart intensity and takes its siblings and the board down with it.
- **The library ships `Visualize.Board.Supervisor`; the host adds it to its tree** (§8.1). The library starts no process of its own and has no application. `Server.start/2` takes any `DynamicSupervisor`, and `Server.child_spec/1` places one board under any supervisor, so a host keeps control of where boards live. Leaving the host to write the supervisor was the alternative. It was not taken because shipping one names the place, gives one line to copy and lets the library state the board's restart policy (`:transient`), at no cost to the host's control.
- **A board is started whole or not at all.** A board that does not validate given its store, or whose binding names a feed `feeds:` lacks, is refused with every fault before any process runs.
- **Time: UTC in, the zone out, rows by timestamp, gaps by `continuity`** (§8.5). The board's zone is written into every `:time` scale of a component that names none, beside the group, by the new pure `Board.components/3` that `realise/4` now builds on. The monitor keeps a holding in time order, sorting only when out of order, and keeps a **late** pushed row rather than dropping it at the seam, unless the holding already has that row. A cell breaks a series where two readings are further apart than the field's `continuity`, by inserting a row with that field `nil`, which the line's `defined` already breaks on. Making each mark read `continuity` was the alternative. It was not taken because every mark that draws a run would need it, while a `nil` reading is already how every one of them draws a gap.
- **Every cell state is drawn from the theme, with a role and its glyph** (§8.4). The states are crashed, down, loading, no data, stale (the board's new `stale_after` key, seconds) and capped, in that order of precedence, then live. Each has a role (`:bad`, `:bad`, `:info`, `:neutral`, `:warning`, `:info`) whose colour and cue glyph (D-142) it is drawn with. Where there is no chart, the state is the whole cell. Over a chart, stale, capped and down are a banner, so the last data stays readable with what is wrong with it.
- **A compiled chart's one-string group carries the frame's sync and brush attributes**, as the frame's own group does (spec/14 §12.4), so a cell's SVG joins its group's bus. With each frame the cell computes the crosshair and keyboard hooks' attributes from the frame realised over that step's sources.

**Consequences.** A host adds one supervisor to its tree, starts a board with its store, binding and feeds, subscribes, and receives `{:visualize_board, board, {:cell, name, view}}` with a frame or a drawn state for each cell. A heavy cell slows only itself, and a design that blows its heap or raises shows "Crashed, restarting" in its own cell while the rest of the board goes on. A board process draws at one width; a second viewer at another width sees the same frames scaled. Feeds are fetched in the board's process, so a feed whose `fetch` blocks still delays the board's turn. That is the feed contract's to bound, and not the runtime's. The board value gains the key `stale_after`, an additive key an older reader refuses as unknown (D-137). The monitor no longer drops a late row, so a push feed that resends history it already sent dedupes only rows that are exactly equal. A compiled chart's SVG gains its frame's sync and brush attributes, which changes no gallery chart, since none of them names a group or a brush. The LiveView component, the board hook (#575) and the examples page (#576) follow on this runtime.

## 147. D-147 — A board travels in a bundle with its components, their closure and the themes it names; the whole bundle is checked before anything is put, and the store's own import re-keys it

**Context.** #542, from #531 item 4. A board's cells name their components by store id (D-141), and an id is one store's (D-100). A board authored in metresis names ids that Exio's store does not hold, or holds as something else, so the board cannot be copied across as a value. A bundle already crosses stores for one composite (spec/14 §19.7, D-103): it carries the closure of an id and the store's `import/2` relocates it through one `old → new` table. Four questions were open: what a board bundle carries beyond the components' closure, what happens to a binding, how themes named rather than referenced by id travel, and what an import checks before it writes.

**Decision.**
- **A board bundle is the bundle of spec/14 §19.7 with the board's map form under a `board` key** ([15-boards](15-boards.md) §11.1). Its entries are the closure of every cell's component, and the closure of every stored theme the board or its components name. One bundle shape serves a composite and a board, so a store's `import/2` is unchanged: it relocates the entries and ignores the board. The board is in its map form rather than as a struct, so the importer reads it through `Board.from_map/2`'s version gate and limits like any board from elsewhere.
- **Themes travel by the name a design gives.** A design names a theme (spec/14 §2.5), and the store finds it by name (D-139). The flattened component's `theme` is read, and a variable is read through the cell's cascade (spec/15 §5). This is the one way a board names a theme, since it has no `theme` key. Built-in names are not carried. A name is not an identity (spec/14 §19.2), so import cannot re-key it. A carried theme whose name the receiving store already gives a different body is refused as `{:theme_taken, name}`, because one of the two boards would silently draw in the other's theme. The same body under the same name is not a fault. Renaming a theme on import was considered and not taken: the design names it, so renaming would mean rewriting a design's `theme`, and a variable's value in the board, on the way in.
- **Bindings never travel.** A binding names one host's feeds (spec/15 §4.2). A bundle has no field for one, and the importing host binds the board's `required_sources/3` to its own feeds.
- **Everything is checked before anything is put.** The order is the provenance `schema` (D-137), then the board read under the limits, then the entries' count and each entry's `depth` and `items`, then the board validated with the bundle's entries as its store and its components read under the limits, then the theme names against the receiving store. A cell whose component the bundle does not carry is `{:not_carried, id}` at that cell's `ref`. The check uses the bundle, not the receiving store, because the receiving store's ids mean something else. A refused bundle therefore leaves the store as it was, whether or not the store's `import/2` is transactional.
- **The store re-keys; the board follows the table.** The entries go through the store's own `import/2`, which issues every new id, so a new id never collides with one already held. The board's cell refs are rewritten through the table it returns. The board is returned to the host and is not put in the store, because a board is not a library entry. Where a host keeps its boards is its own.

**Consequences.** metresis can export a board with `export_board/3` and Exio import it with `import_board/3`, then bind its own feeds. A bundle gains a `board` key, `nil` for one `export/3` makes, so bundles made before this read unchanged. `Store.theme_name/1` becomes public, because the importer and `theme/2` must agree on what name a stored theme answers to. Three reasons are added: `{:not_carried, id}`, `{:theme_taken, name}` and `{:store, reason}`. A bundle still has no JSON form of its own. Its parts have one (a board's `to_json/1`, a fragment's `to_json/1`), and a host that ships a bundle as text writes them.

## 148. D-148 — A test waits on a message or a count, never on the clock; where the code reads time, the test drives it

**Context.** #520, #567, after #483 and D-114. D-114 kept the suite from *measuring* time, but tests still *waited* on it, and every flake since was a wait that a starved runner outlasted. #483's first force tick arrived 0.36–2.86 s after the subscribe, against a 500 ms `assert_receive`. #512's switch test counted one tick or two: the chart page's tick read `System.monotonic_time`, and the test only rewound the run's `last_tick`, so a tick's elapsed time was the rewind plus however long the test had taken. #567's drawer test measured a strip on the same kind of tick: 31,359 and 31,910 bytes against a 30,000-byte budget, which is some 0.7 s of samples and a hundred columns where the test meant 16 ms and two. #495's raster teardown and #469's comparison sat near their bounds. A byte count, a tick count and a mode are deterministic only if the elapsed time behind them is.

**Decision.**
- **Where code under test reads time to decide something a test asserts, it reads a clock it is handed.** `Visualize.Feed.Telemetry` takes `clock:`, the force simulation takes its subscribers at start (#483), and the chart page reads every instant of a run through its `run_clock` assign, a function of a time unit that is `&System.monotonic_time/1` from the mount (spec/14 §12.6). A test stops that clock and advances it by exactly the milliseconds a tick means. A measurement the page only shows, such as a frame's render time, stays on the real clock: it is shown, never asserted.
- **A test waits on a message or a count, with a generous bound.** An `assert_receive` waits at least 5 s, so its bound says when to give up on a defect and never how fast the machine is. A `refute_receive` asserts absence only after a synchronising call, and then it is `refute_received`, which reads the mailbox without waiting. A sleep, a poll on the clock and a wait below the floor stay only on an allow-list that gives the reason for each.
- **The rule is checked from source** (spec/12 §6). `test/visualize/no_wall_clock_test.exs`, D-114's check, also reads every test file and test support module for receive waits and sleeps. It fails on an `assert_receive` below the floor, on one with no timeout (ExUnit's default is 100 ms), on one whose timeout is neither an integer literal nor a module attribute bound to one, on any `refute_receive` that waits, on a `receive … after` that waits less than the floor, and on any `Process.sleep` or `:timer.sleep`, unless the site is on its allow-list. An allow-list entry that matches no site fails too.

**Consequences.** A loaded box makes the suite slower, not red: the drawer's strip is 3,418 bytes on every run, and a tick count is the count the test asked for. Assertions that #512 had loosened to survive the clock, such as "the ticks plus what this tick counted" or "an origin of at least 8", are exact again. A wait whose condition never comes now takes the full bound to fail, which is the price of never failing a sound test. Whether a waiting `refute_receive` follows a synchronising call cannot be read reliably from source, so the check does not try: a waiting refute is rewritten as a synchronising call followed by `refute_received`, or listed with its reason. The check reads the code, not the strings in it: code a test evaluates on another node from a string is not checked, and the one such test counts ticks rather than waiting on them.

## 149. D-149 — Every inverse sine and cosine in Geo is d3's clamped one, and no point at the edge of a projection's domain raises

**Context.** #566. The rotation property of spec/07 §1.4 failed on some seeds for `:collignon` with `asin(-1.0000000000000004)`. It is not a rotation fault: `:collignon`'s inverse of its own south pole computes `α = 1.4142135623730951`, `α² = 2.0000000000000004`, and takes the arcsine of `1 − α²`. The property finds it only when a rotation carries its random point within about 10⁻⁸° of the south pole. Erlang's `:math.asin`, `:math.acos`, `:math.sqrt` and `:math.log` raise `ArithmeticError` outside their domains where JavaScript returns `NaN`, so d3-geo clamps its inverse trigonometry in one place (src/math.js) and d3-geo-projection its square root, and every projection calls those. Here, three modules had their own clamped `asin` (`Projection`'s rotation, `Circle`, `Clip.Circle`), two cylindrical inverses clamped inline, and the rest of the families called `:math.asin` and `:math.acos` raw. A survey of all 38 types at both poles and the antimeridian, ±1 ulp, with each projected point moved ±1 ulp, raised in nine: `:collignon`, `:albers` and `:conic_equal_area`, `:mollweide`, `:eckert2`, `:eckert6` and `:wagner4` (an arcsine past ±1), `:eckert3` (a square root of `−2.2e-16`), `:loximuthal` (the logarithm of zero at a pole) and `:mollweide`'s forward Newton step (a zero derivative at `−89.99999999999999°`). `PolygonContains` skipped a crossing whose normalised `z` drifted past ±1, where d3 counts it.

**Decision.**
- **One helper module, d3's definitions.** `Visualize.Geo.Math` holds `asin/1` (past `1` is `π/2`, past `−1` is `−π/2`) and `acos/1` (past `1` is `0`, past `−1` is `π`), as d3-geo's src/math.js, and `sqrt/1` (`0` at or below `0`), as d3-geo-projection's. Every inverse sine and cosine in `lib/visualize/geo/` goes through it, the local helpers are gone, and no family calls `:math.asin` or `:math.acos` directly. Clamping only where a survey found a failure was not taken: the sites that held were safe by an argument about rounding at each one, and that argument is easy to lose in a later edit.
- **The square root is clamped where its argument is zero at the edge and non-negative on the sphere** — Eckert III (`1 − 4φ²/π²`, both ways) and Hammer's inverse (`1 − x²/16 − y²/4`). The others stay raw: their arguments are bounded away from zero on the sphere.
- **A step outside the domain is clamped too, as in d3.** The clamp cannot tell a value one ulp past ±1 from one a whole unit past it, so `invert/3` of a point outside `:mollweide`'s ellipse or `:azimuthal_equal_area`'s disc returns a point, as d3's does, where it raised. The explicit `nil`s spec/07 §1.2 lists (`:orthographic` outside the unit disc, `:gnomonic` and `:stereographic` behind the horizon) stay: they are decisions about the domain, not rounding.
- **The two non-trigonometric failures follow d3's own guards.** `:loximuthal`'s inverse returns `lon = 0` within 10⁻⁶ radians of a pole, the same band its forward projection already maps to `x = 0`. `:mollweide`'s Newton step stops where its derivative is zero, as it already did at the pole itself.

**Consequences.** No point of the sphere, and no point of the plane within rounding of a projection's edge, raises `ArithmeticError` in `project/3`, `invert/3`, a rotation, a circle or the clip. Every value inside the domain is unchanged, so the projection and charts goldens and the d3 oracle cases (within 1e-9) are as they were. `test/visualize/geo/projection_edge_test.exs` is the recurrence guard. It drives every type at the poles, the antimeridian and ±1 ulp around them, with each projected point moved ±1 ulp, and it fails if any family calls `:math.asin` or `:math.acos` outside `Visualize.Geo.Math`. The rotation property also runs a fixed case at the south pole that used to fail. A future projection family that takes an inverse sine of a computed quantity is held to the same rule.

## 150. D-150 — A board is exported from a snapshot: one value of the board and its data that draws each cell as the cell does; the document embeds each cell's own SVG; print is a theme and a band layout, as SVG pages

**Context.** #547, from #531 item 11. A dashboard is shared and printed, not only watched. Three things were asked: a picture of a whole board (SVG and PNG), a frozen board that reopens without its feeds, and print. Four questions had to be settled. What does an export draw from, when a live board's frames are spread over one process per cell (D-146)? How does a document of many cells stay true to each cell? How does a frozen board relate to the bundle, which already carries a board to another host (D-147)? And what does print produce, when the rasteriser the library uses (D-121) makes PNG and not PDF?

**Decision.**
- **Every export draws a snapshot** (spec/15 §12.2). `%Visualize.Board.Snapshot{}` holds the board, its now and resolved window, the width, the theme, and for each cell its prepared component, its variables, the slots the board last handed it, and a crash's reason. A running board answers one with `Visualize.Board.Server.snapshot/1`, a call to the board process, which already holds every piece of it, so no cell is called and a slow cell holds nothing up. A board realised purely gives one with `Visualize.Board.Snapshot.realise/4`, whose faults are `realise/4`'s. Collecting the cells' last views from the subscribers was considered and not taken: views are pictures at one width in one theme, and print needs the same board at another width in another theme. A snapshot carries data, so it redraws at any width and in any theme.
- **A cell's drawing is one set of pure steps, shared by the cell process and the snapshot** (spec/15 §12.2). `Visualize.Board.Draw` places the rows, applies, compiles, steps and wraps the root, and decides the state. The cell process draws every frame through it, and `Snapshot.views/2` draws each cell once through it. A snapshot's cell is therefore the cell's own first frame of the same data, byte for byte, which a test holds against a running cell. A cell that eased between frames differs only by where its easing stood. The snapshot draws the data at rest.
- **The document embeds each cell's own SVG unchanged** (spec/15 §12.1). Each chart, banner and panel is the cell's own root, placed inside a viewport whose view box is that root's size, at the cell's box from `Visualize.Board.boxes/2`, below the header that `Visualize.Board.window_text/2` writes for the component too. Re-generating every chart into one shared tree was not taken. It would be a second drawing of each cell that could drift from the first. The chart's content-derived ids (a gradient's, a filter's, the root's) make embedding safe: two cells that share an id share its body.
- **PNG resolves the theme's references to their fallbacks** (spec/15 §12.1). A cell's SVG is in `:css` mode, and resvg reads no custom property. A theme writes each slot's literal as the fallback of its reference (spec/08 §7.3), so `Export.literal/1` replacing `var(--name, fallback)` by its fallback *is* the `:literal` drawing. Drawing every cell a second time in `:literal` mode would mean a literal path through the compiled step that the runtime never takes.
- **A bundle carries definitions; a snapshot carries data** (spec/15 §12.2). A bundle moves a board and the store entries it refers to into another store, to be re-keyed and edited. A snapshot carries each component already resolved, with its rows, refers to no id, needs no store and is never imported. It is versioned and read like a board (D-137): a `"$version"` stamp refused by name when newer, the `depth` and `items` limits before any key is decoded, then every key with each embedded document read by its own reader. Rows are written column by column, so a field name keeps its spelling as an atom or a string.
- **Print is a theme, a band layout and SVG pages** (spec/15 §12.4). `Visualize.Theme.print/0`, the built-in `:print`, is white paper, dark ink and the colour-safe status ramp, since paper is often read in greyscale. `Export.paginate/2` arranges the board at the page's width, which reflows it as a narrow container does, and moves it down the pages in **bands**: cells whose rows overlap, the least that can move without cutting a cell. A band that does not fit starts the next page, and one taller than a page is scaled to fit, so no cell is split. `Export.pages/2` draws one SVG per page in the paper's unit, and `Export.html/2` puts them in one HTML document with an `@page` rule. **SVG pages, not a PDF.** resvg makes PNG and not PDF, no PDF writer is available without a native or large dependency, and every host that shows a board has a browser, whose print makes the multi-page PDF from the HTML.

**Consequences.** A host exports a running board in two calls (`Server.snapshot/1`, then `Export.svg/2`, `png/2` or `pages/2`), and a snapshot file reopens anywhere a matching library runs, with no store or feed. `Visualize.Board.Cell` now draws through `Visualize.Board.Draw`, unchanged in behaviour. `Visualize.Theme.builtins/0` gains `:print`, so a design may name `theme: :print`, the builder offers it beside the other built-in themes, and a bundle does not carry it (§11.1). A snapshot holds rows, so its size is its data's: the `items` limit bounds each slot's rows on reading as it bounds any list, and a host that shares large snapshots raises it. A row value must be a term spec/14 §11.2 can write. A snapshot taken mid-easing shows the data at rest rather than the frame on screen. The examples' `/board` page posts to `/board/export/:format` for SVG, PNG, the snapshot and the printed pages.


## 151. D-151 — Interaction across a board's cells is the board's shared state: a brush is the board's window, debounced in the board; a selection is `{field, value}` through `links`, drawn as a per-datum `opacity`; a drill target is validated data that the page follows

**Context.** #545, from #531 items 8–10. A board's cells are separate charts in separate processes (D-146), and until now they shared hover and a brush band only on the client's sync bus. A brush already set the board's window (#575), but a click on a chart was a brush too small to keep and its clear restored the window, so a click could not mean anything else. Three things were open: how a selection is identified across cells over different feeds, how a cell draws it, and how a drill-down target is named and who follows it.

**Decision.**
- **The window is the board's, and rapid brushes are debounced in the board process** ([15-boards](15-boards.md) §13.1). `Server.brush/2` validates at once and applies after `debounce:` (250 ms), the latest winning, so a burst of brushes costs each feed one fetch. Debouncing on the client was considered: the hook pushes only on release, so the burst is of releases, and only the board sees all of them, from every viewer. Undo is `reset_window/1`, the window the board was started with, behind a *Reset window* button shown while `moved`. A cleared brush no longer restores the window, because a click is a cleared brush.
- **A selection is `{field, value}`, read through a board-level `links` map** (§13.2). A series key alone would not cross feeds, whose series are named by their own fields. A field and a value do, and the schema's meaning makes them comparable (D-136). Where two feeds name one thing differently, `links: %{name: :service}` says so once, for the board. A link is one step. The selection is held canonically, under the linked name, and each cell is sent every field that links to it.
- **A pick is resolved to its row in the cell process, from the keyboard's points.** The client names a point as `{series, index}`, the keyboard hook's own coordinates (D-142), and the cell answers the typed row, so a drill variable is a `DateTime` or an atom and never an attribute string. A click picks by the crosshair's snapping. The identity is the mark's series field, else the first `:category` field of its slot's schema. Writing datum attributes onto every element was not taken: only marks with a tooltip write them, and they are strings.
- **The highlight is the style grammar's `opacity`, bound per datum** (spec/14 §3.2). A cell that shows the selection adds a `:"$vis_dim"` column to its rows (`nil` for the selected, `0.25` otherwise) and appends `%{opacity: {:field, :"$vis_dim"}}` to each data mark's style stack. That is the grammar's own channel binding, so it needs no hook, draws on every target, and leaves a cell that does not show the selection byte for byte as it was. A client-side class toggle, as the legend hides a series, was considered: it needs the elements to carry the identity, which a category bar without a tooltip does not.
- **A drill target is stored data, validated against the target board's declared variables, and followed by the page** (§13.3). `drill: %{board: ref, vars: %{var => {:datum, field} | value}}` names `:self` or a host's name for a board, since boards are not store entries. `validate/3` takes `boards:`, and the runtime refuses a board whose drill cannot be met. The component sends the page `{:visualize_board_drill, id, target}` rather than calling `push_navigate`. The library holds no route, and the host may follow a drill by a URL, a modal, or new variables on a board only one person sees.

**Consequences.** A board value gains two keys, `links` and a cell's `drill`, additive keys that an older reader refuses as unknown (D-137). The JSON form gains a board-only tag, `{"$datum": field}`. The runtime gains `brush/2`, `reset_window/1`, `select/2` and `pick/4`, two options (`boards:`, `debounce:`) and two fields of its info (`selection`, `moved`). `KeyboardHook` gains a pick event and a click, and `BoardHook` gains Escape for the selection. A selection is shared by every viewer of a board process, as its window is. A transform that derives rows without the dim column leaves its mark undimmed. The examples' board gains three cells that show all three interactions and a detail board, which the page starts per visitor.

## 152. D-152 — A board is a library kind the builder edits in a board mode; the server places every cell, the preview realises each cell alone over sample data, and the binding is the host's, emitted and never stored

**Context.** #544, from #531 §1. A board (D-141) had a value, a runtime (D-146) and a bundle (D-147), but no tool: a host wrote one in Elixir. The builder is the tool for designs and, since D-139, for themes, and #568 split it so that a mode is one module. Five questions were open: where a board is kept while a person builds it, who decides where a dragged cell lands, how the builder learns the feeds a host can bind, where the binding a person makes goes, and what the preview draws. D-147 had said that a board is not a library entry, because no store kept boards then.

**Decision.**
- **A board is a kind of fragment, `:board`, kept in the builder's store.** The entry is the singular wrapper `%{board: map}` holding the board's map form, `version` included, so a board is versioned by its own `version`, as a theme document is (D-139), and read back by `Visualize.Board.from_map/2` under D-137's gate and limits. Per-kind validity is that reader, so *Save to library* cannot store a board that does not open again. `Fragment.to_json/1` writes the body in the board's own JSON form and `from_json/2` reads it back, so a store that keeps JSON keeps boards too. Because the cells' refs are ids inside the fragment, `export/3` of a board entry carries its components and an import re-keys them with no new code. This narrows D-147's "a board is not a library entry": `import_board/3` still returns the board rather than putting it, and a host that keeps boards in its store puts it there. Keeping boards outside the store, in an assign the host owns, was considered and not taken: the builder would then need a second library, a second save path and a second notion of identity for one more kind of document.
- **The board mode is a mode, and the chart holds the board, not a stack.** `Builder.BoardMode` is one entry of `Builder.Modes`, entered by opening a board entry or *New → board*. A board is not a design node, so the theme mode's way of writing a site of the stack does not fit; the board chart carries the `%Visualize.Board{}` itself, and every edit validates it alone before it is written, so the mode never holds a board that would not read.
- **The hook moves, and the server places.** `BuilderHook` in scope `board` turns a pointer's travel into squares and shows the rectangle as it goes, but it pushes the rectangle and nothing else. `Boards.place/3` writes it and validates the board, so an overlap, a cell off the grid or a rectangle that is not integers is refused with the board's own reasons, whatever a client sends. A new cell takes the first free place in reading order and is named `cell_<n>`, because a cell's name is its identity and the builder makes no atom of a person's text. Variable names are matched against the names the components declare, for the same reason.
- **The preview realises each cell alone, over sample data.** Each cell is `Board.realise/4` of a board of that one cell, so the component, the cascade, the group and the zone are prepared exactly as the runtime prepares them, and one cell that cannot apply does not blank the others. The data is the builder's sample pool: by the slot's own name until a binding is made, and through the binding (`Binding.pools/2`) after. A live preview through `Board.Server` and the board component was not taken: it needs feeds the builder does not hold and a process per open board that the builder would have to own.
- **The host gives a connector catalog, and the binding is the host's.** The builder takes `catalog`, a map from each feed the host offers to its schema, which is what `describe` answers (spec/08 §10.3), and offers those feeds for each cell's slots. It shows every unbound slot and every unmet contract with `Binding.check/3`, so a person sees before any data flows what the runtime would report. With no catalog, the bindable feeds are the sample pool. The binding never enters the board: each change is sent to the host as `{on_bind, id, board_id, binding}`, and a host may seed one per board with `board_bindings`. This is D-141's and D-147's rule that a binding names one host's feeds, so it travels with the host and not with the board. A catalog feed previews over the host's sample rows of that name, else rows made from its schema, so the builder never fetches from a host's feed.
- **Edit component opens the component and returns.** The cell's composite opens as a chart of the workspace, as from the library, so saving it writes the entry every cell that uses it reads. *Back to board* closes that chart only when it holds no unsaved change, so returning never drops work.

**Consequences.** A board is made, placed, bound, previewed and saved without writing Elixir. `:board` joins `Fragment.kinds/0`, so `{:board, n}` ids exist, the library's kind view always shows a *Board* heading, and *New* offers `board`. A board entry dropped into a stack is refused, as a wrong kind is. The preview is as good as the sample data: a catalog feed with no sample rows previews over rows made from its schema, which are shaped like the feed's but are not its values. The builder emits a binding and does not check it again when the host stores it. A host that runs the board passes its own binding to `Board.Server.start/2`, which refuses unknown feeds as before.

## 153. D-153 — A bound field's schema type is checked against its scale's need before anything is drawn: in application, in the board's binding check and in the builder's previews, which draw a fault panel instead of raising

**Context.** #582, found in #580. Binding a time feed to a chart whose x scale is linear raised from the frame inside the builder's board preview, and took the builder page down. The single-chart preview behaved the same way, since spec/14 §18.4 said a fault the frame raises raises there too. The rule is right for a host's data: catching it would hide a bug. But a builder whose purpose is choosing bindings meets wrong choices all the time. D-136 already gave a feed's fields a `type`, and every scale declares a kind, so a wrong binding can be seen from the two descriptions before any value reaches a scale.

**Decision.**
- **Each field a mark reads through a scale has a need, read from the scale's kind.** A time scale needs `:time`. Linear, log, power, sqrt, symlog, radial, quantize, quantile, threshold, sequential and diverging scales need `:number`. Band and ordinal scales need `:category`, which a `:text` field also satisfies, since both are discrete. A read is a channel that names a scale, a style's `{:field, f}` colour or an offset's field, with the mark's `scales` renames and a scale's adoption followed. It counts only where the mark's rows are its slot's own, or pass through steps that keep every field as it is (`:filter`, `:sort`, `:take`, `:window`, `:lttb`, `:m4`). A field whose type does not satisfy its need is `{:type_mismatch, slot, field, expected, given}` at the read's path.
- **A schema's type is meaning, and a rows' type is representation.** A schema's `:time` names the time field however the feed writes it. `Feed.Signals` describes its integer tick as `:time`, and a linear scale draws it, so a numeric scale takes a schema's `:time`. Rows typed by `Data.Table.column_types/1` say how values are written: `:time` there is instants, which no numeric scale takes, and a band or ordinal scale takes any value. Treating a schema's `:time` on a linear scale as a fault, as #582 first proposed, was not taken. It would refuse every tick feed on a linear axis, and a time scale cannot take ticks, so such a feed could not be drawn on a board at all. The case #582 found is caught where it happens: the builder's samples write a `:time` field as instants, and the previews check the rows they draw.
- **Application checks it where a schema is bound.** The schema step (D-136) resolves the schema of a slot with reads as well as of a slot with a contract. For a slot without a contract, a schema that does not resolve is not a fault, because nothing requires it, and that slot's types go unchecked. So a host that sends rows, or deliveries with no describe, sees no new fault. Rows of the wrong type with no schema still raise from the frame, as before. Only a schema makes the check possible, and no path rescues.
- **The board's binding check reports it.** `Board.required_sources/3` gives each slot its `reads`, and `Binding.check/3` checks them against each described feed. `Binding.check_types/3`, and `Chart.check_types/2` for one design, take a schema or a plain map of types. That is how the builder checks a sample that has no schema, by the types its rows show (`Data.Table.column_types/1`).
- **The builder's previews check first and draw a fault panel.** The single-chart preview runs `Chart.check_types/2` before `apply/2`, with each slot typed by its rows and, for a catalog feed, by its schema too. A board cell with a type fault is not realised, whether the fault comes from the binding's check against the catalog or from `Binding.check_types/3` against the preview's rows. Both draw the board runtime's cell-state panel, `Board.State.render/3` of a new state, `{:invalid, errors}`, with role `:bad` and words *Wrong binding* and the first fault, and they list every fault beneath it. The runtime draws the same state for a cell whose application reports a type mismatch, which it used to call *Feed down*, though the feed was up.
- **Rescuing the preview was not taken.** A rescue around `apply/2` would hide every bug the frame raises for, in the host's data and in the library alike. Checking what the preview can know first, and raising for everything else, keeps those bugs visible.

**Consequences.** spec/14 §18.4's "the preview raises" holds now only for faults no type predicts, such as a theme name nothing resolves. A wrong binding is a panel in the builder, a fault under its slot, and a deliberate state in the runtime. The need table is coarser than what a scale accepts. A `:number` field on a band scale draws, but it is reported, because a schema that calls a field a number says it measures something, and a band or an ordinal colour reads categories. A host that means numbers as categories describes the field as `:category`. A slot without a contract now has its schema described when its delivery names one and its fields are read through a scale, so a describe function may be asked once more than before. A design read through steps that make fields, such as `:bin`, `:stack` or a layout, is not type-checked, and a wrong binding there still raises. At runtime a feed whose schema calls a field `:time` but whose rows are instants, bound to a linear scale, passes the schema check and still raises from the frame, as before. Only rows show that, and the runtime does not type its deliveries' rows.

## 154. D-154 — A `:time` field holds instants; a feed's windows are over its window field, a `:time` one or one it describes `window: true`; a schema's `:time` on a numeric scale is a type fault

**Context.** #585, found in #584 (D-153). `Visualize.Feed.Signals` (#537, D-140) described its integer tick column as `:time`, so that the window, the monitor and `Feed.Combine`, which found the field a window is over as the schema's one `:time` field, would work over ticks. That gave `:time` two meanings. It meant an instant, which is what a time scale needs and what every other feed writes. It also meant a tick count, which only a numeric scale can draw. D-153 had to let a schema's `:time` satisfy a numeric scale so that the tick feed could still be drawn. The cost was that a real feed whose `:time` field holds `DateTime`s, bound to a linear axis, passed validation and still raised from the frame. The data-to-axes milestone had already settled the question for designs (#369, spec/14 §2.3): a signal's time is its tick, an integer, and is typed `:number`, never `:time`. #537 diverged from that only because the feed contract had no other way to say which field a window is over.

**Decision.**
- **A `:time` field holds instants**: `DateTime`, `NaiveDateTime` or `Date` values, or ISO 8601 strings, which is what a time scale takes (spec/14 §4.3). It never holds numbers. A count of ticks, a sequence number or an offset is `:number`. `Feed.Signals` describes its tick as `%{type: :number, window: true, label: "Tick"}`.
- **A feed names the field its windows are over: its window field** (spec/08 §10.1, §11.2). It is a tenth description key, `window`, a boolean that a `:time` or `:number` field may carry. The window field is the one field described `window: true`, or else the schema's one `:time` field when it has exactly one and does not describe it `window: false`. `Feed.window_field/1` answers it and replaces `Feed.time_field/1`. `Schema.new/1` refuses `window` on a `:category` or `:text` field (`{:invalid_for, type}`), a value that is not a boolean (`{:type, :boolean}`), and two fields that both say `true` (`{:duplicate_window, other}`).
  - The key lives in the schema because `finish/3` trims a feed that does **not** honour windows. So which field to trim by is a fact about the data, not a capability, and every reader of it (`finish/3`, the monitor, `Board.Rows`, the cell states, the conformance kit) already holds the schema.
  - It is a per-field key rather than a schema-level one, which keeps the schema a map of descriptions. The content hash (spec/08 §10.2) covers it with no change to the encoding, and no existing schema's version moves.
  - The default keeps the common case silent. A feed stamped with instants that has one `:time` field (telemetry, metresis) declares nothing.
- **The window works over either kind** (spec/08 §11.2). A window applies to a window field of its kind (`Feed.Window.applies?/2`): instant bounds to `:time`, number bounds to `:number`, and a window with no bound to either.
  - `finish/3` trims, and reduces against, a numeric window field as it does a time one. It never reduces the window field itself as a value.
  - The monitor asks each feed for the window that applies to it. That is the request, or `nil` (the feed's own span) when the request is of the other kind. A feed asked `nil` is neither trimmed by the window nor refetched when it moves.
  - Late and out-of-order rows are placed along a numeric window field as along a time one.
  - `Feed.Combine` routes over either kind. Its describe error for a schema with no window field is now `:combine_window_field`.
- **On a board, a feed over ticks declares its own window domain** (spec/15 §1.5, §8.5). A board's window is in UTC. It applies to the feeds whose window field is `:time`. A feed whose window field is a number keeps its own span and its own present, and a brush does not move it. Its `continuity` gaps are measured in its own units.
  - The alternative was mapping ticks to time through the board's clock, and it was not taken. Only the connector knows what a tick is worth, and the board does not guess a mapping. A connector that wants ticks on the board's clock maps them to instants and describes the result as `:time`, as the examples' wall-clock signal (`Examples.BoardDemo.Wave`) already does. That is why the `/board` demo is unchanged.
- **The type check is strict.** This updates D-153's second bullet and its last consequence. A schema's `:time` no longer satisfies a numeric need, so `Types.satisfies?/3` loses its one clause for it. A `:time` field read through a linear, log, power, sqrt, symlog, radial or any other numeric scale is `{:type_mismatch, slot, field, :number, :time}` in application, in the board's binding check and in the builder's previews.
  - The case D-153 left open, a feed whose `:time` field holds instants and is bound to a linear axis, is now reported before anything is drawn.
  - D-153's reason for leaving it open, that it "would refuse every tick feed on a linear axis", no longer holds, because a tick feed's tick is a `:number`.
  - The rest of D-153 stands.
- **The conformance kit checks the declaration** (spec/08 §12.3). The `:describe` check reports a feed that names `:window` but has no window field (`{:no_window_field, times}`).
  - The `:deliveries` check holds the window field's values to its type. That is an instant for `:time` and a number for `:number` (`{:window_value, request, value}`, which replaces `:not_a_time`).
  - Every other `:time` field must hold instants (`{:not_an_instant, request, field, value}`). A tick written under `:time` therefore fails the kit, not a frame.

**Consequences.**
- **For a feed author:**
  - Every `:time` field must hold instants.
  - A feed whose window, ordering or `continuity` is over a numeric field types that field `:number` and describes it `window: true`.
  - A feed with several `:time` fields marks the one its windows are over.
  - `Feed.time_field/1` is gone; call `Feed.window_field/1`.
  - `Combine`'s error is `:combine_window_field`.
  - The kit's `:not_a_time` finding is `:window_value`.
  - A feed stamped with `DateTime`s that has one `:time` field changes nothing.
- **Versions move.** `Feed.Signals`' schema has a new version, since its tick's description changed.
- **Designs that drew a tick feed:**
  - A design that bound a tick feed's `:time` field to a linear axis now binds a `:number` field and is unchanged.
  - A design that bound it to a time scale never drew, and still does not, now reported as a type fault.
- **The builder's sample rows** give a `:number` window field its row's place, so a tick feed's preview counts as a tick does.
- **Gaps are measured in the window field's units.** spec/15 §8.5's "a numeric clock is read in seconds" is now "in its own units", since `continuity` is measured along the window field.
