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 §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 §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) 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 §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 §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 §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 §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 §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) 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 §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 §2.1) and every component takes title and description (10-liveview-integration §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 §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 §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 §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 §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.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 §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 §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 §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 §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 §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 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 §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 §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 §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 §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 §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 §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.

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

Context. Every d the library prints is Visualize.IR.Path.to_string/1, the one d serialisation of 02-architecture §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 §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 §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 §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 §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 §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 §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 Ls, 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 §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 Cs and nothing else, and a curved :line mark (14-declarative-chart §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 §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 §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 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 §2.1). A mark's :tooltip node (fields, template, event; 14-declarative-chart §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 §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 §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 §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 §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 §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 used 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.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 §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 used 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 §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 §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 §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 §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 §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 §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 §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 §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 §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 §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 §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 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 §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 §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 §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 §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 §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 §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 §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.merged the two by name. A %Visualize.Chart.Var{} is a map, and 14-declarative-chart §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 §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 §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 §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.

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