the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0 primitives; rendering, colours and themes stay the app's own. all four charts share one hover treatment: the client chart gains the tooltip and dimming the query timeline had, the donuts gain both, an open tooltip follows a data refresh instead of going stale, and it retires when the window rolls. the timeline's third series is named allowed instead of other, and the client chart's other aggregate disappears from a window where it counted nothing. licenses gain the isc text for the bundled d3 modules.
26 KiB
Milestone 35: visx charts
Replace the hand-rolled chart layout math in the admin Overview with visx primitives, and unify the chart components' duplicated plumbing. Owner decision 2026-08-24 after a measured bake-off (recharts +377,355 pre-gzip bytes, nivo +332,686, visx +74,382 against ~103,000 bytes of budget room; visx is the only candidate that fits the 800,000-byte gate). The charts must read as the same charts afterward: same palette, same geometry, same accessible surfaces.
Sessions
One session. Admin SPA plus the license records; no Zig source, no API changes.
Session 1: port the Overview charts to visx
1.1 Dependencies and records
Add to admin/package.json under dependencies (NOT devDependencies), pinned exact (no ^): @visx/scale@4.0.0, @visx/shape@4.0.0, @visx/group@4.0.0, @visx/axis@4.0.0, @visx/grid@4.0.0, @visx/tooltip@4.0.0. Nothing else — in particular NOT @visx/responsive (the app keeps its own resize hook) and NOT @visx/text, @visx/legend, @visx/xychart.
Three separate record obligations, each updated from its own evidence, not from this spec's package list:
- The sourcemap-derived bundled set that
npm run assert-bundledchecks — regenerate/extend it from the actual build output (expect the transitive@visx/{bounds,curve,point,text,vendor}to appear). - The lockfile-derived npm runtime closure that the Zig drift gate checks — update from the lockfile.
licenses/inventory.zon+licenses/dependency-identity.txt+ license-text files underlicenses/for every shipped package. The direct@visxpackages are MIT;@visx/vendorisMIT AND ISC(it re-exports ISC-licensed d3 modules) — record that exact expression and include the ISC text.
1.2 Shared chart plumbing — new admin/src/features/overview/chartKit.tsx
Owns what is today duplicated or divergent between TimeseriesChart.tsx and ClientChart.tsx:
useMeasuredWidth()— extracted once from the two near-identical hooks. Contract, matching both current hooks: readsclientWidthin the mountuseEffect(NOTuseLayoutEffect— keep the current timing), observes subsequent changes viaResizeObserver, disconnects on cleanup, and falls back to 640 when the measured width is 0 (jsdom). Covered by a test for the zero-width fallback.- A y-scale factory: exported function returning
scaleLinear({ domain: [0, max], range, nice: true }). The[0, max]values in 1.3 are INPUT data domains; nicening mutates them. Consumers passnumTicks={5}toAxisLeftas a hint. Kit test, exact: input domain[0, 1780], range[240, 0]→ effective domain[0, 1800]andscale.ticks(5)equal to[0, 500, 1000, 1500]. - Axis wrappers over
AxisBottom/AxisLeftapplying the app's tick text styles. Current layout constants are preserved exactly unless named here: chart height 240, margins{top: 8, right: 8, bottom: 22, left: 44}. Axis chrome matches today's rendering, not visx defaults:AxisLefthides its axis line and tick marks entirely;AxisBottomkeeps only the existing baseline — no tick marks. Y labels keep the existing compact-number formatting; x labels keep the existing time formatter driven bybucket_seconds. X-label thinning preserves the current algorithm verbatim:step = max(1, ceil(bucketCount * 90 / plotWidth)), ticks at indices wherei % step === 0— kit test with this exact fixture: 168 hourly buckets (ts = 0, 3600, ...) atplotWidth = 748→step = 21, tick indices[0, 21, 42, 63, 84, 105, 126, 147], tick timestamps[0, 75600, 151200, 226800, 302400, 378000, 453600, 529200]. Exact axis attributes (in the spec, not implementer-chosen):AxisLeft hideAxisLine hideTickswith tick labels 6px left of the plot edge (current position);AxisBottom hideTicks tickLength={0}withtickLabelPropsdy="16px"andtextAnchor="middle"— the sanctioned baseline+14 → baseline+16 change. Kit test asserts the axis group transform plus the labely/dyattributes that produce baseline + 16px, and the y-label -6px offset. GridRowsandAxisLeftMUST share tick positions: computeyScale.ticks(5)once and pass the same array astickValuesto both (each labeled tick owns its grid line, as today). Test: grid-line y positions equal labeled-tick y positions.GridRows, horizontal only, stroked with the app's existing grid color token.- One tooltip:
useTooltip+TooltipWithBounds, app tokens, no transition. Content contract for both bar charts: the bucket's formatted time, the total, and one row per displayed series with its label, swatch color, and value.
1.3 The three charts
Port in place; file names, props, and data contracts unchanged, with one exception below (DonutSlice). Parents and existing tests keep working.
TimeseriesChart.tsx—scaleBand(x) + the kit y-scale +BarStackfor blocked/cached/other. Y domain:[0, max(bucket.queries)], andother = max(0, queries - blocked - cached)per bucket (test theblocked + cached > queriesclamp). Band gap: the current chart draws a fixed ~2px gap regardless of bucket count; a constantpaddingInnercannot do that, so compute it per render:paddingInner = min(0.5, 2 * bucketCount / plotWidth). Segment separator: 1px stroke of thecolors.surfacetoken, applied only when the bar is wider than 3px (current behavior, TimeseriesChart.tsx:249). Keep the empty-state ("No queries in this period.") exactly; coverage messaging is OWNED BY OverviewPage (via useOverviewWindow), not this component — do not add coverage logic here.ClientChart.tsx— same band/linear scales andBarStack, per-client series plus Other. Y domain:[0, max over buckets of (sum of all client values + other)](test it). X domain derived independently from its own props —data.since + index * data.bucket_secondsfordata.other.lengthbuckets — which is equivalent to the timeseries' domain frombuckets[].tsbecause the API aligns them; do NOT add cross-chart props or page-level scale coordination. Identical margins/ranges to the timeseries. Gains the shared tooltip and dimming (below).Donut.tsx—@visx/shape'sPiefor arc generation, our own<path>emission. Pie config pins the deleted donutLayout contract: filtervalue <= 0slices before the Pie, sorting disabled (preserve caller order), no pad angle, clockwise from twelve o'clock, outer radius 90, inner radius 54, shares computed from the drawn positive total, and the single-positive-slice full ring must render. Keep the 1pxcolors.surfaceRaisedarc outline (an existing test pins it).DonutSlicemoves:export interface DonutSlicefromDonut.tsx, andOverviewPage.tsx:28imports it from./Donut.
Interaction contract (both bar charts, identical): a full-plot-height transparent hit target per bucket; hovered bucket shows the shared tooltip; non-active buckets dim to opacity 0.55; tooltip and dimming clear on pointer leave; ALL bare SVG <title> hover text is removed from BOTH charts (the timeseries currently has both a tooltip and <title> at TimeseriesChart.tsx:269 — the <title>s go).
Accessibility contract (matches current tests, do not "improve" it): the two bar-chart SVGs KEEP role="img" and their aria-label (asserted in TimeseriesChart.test.tsx:32 and OverviewPage.test.tsx:382), with the visually-hidden tables as the detailed equivalent; only the donut SVG is aria-hidden + focusable="false" with the visible legend and hidden table as its surface.
Color contract: data-series colors are exempt from the token rule, and each chart keeps ITS existing source — they are not unified onto one mapping. TimeseriesChart keeps its fixed category constants exactly as today (including blue #3b82f6 for Other — NOT seriesColor(OTHER_KEY), which is gray). ClientChart uses seriesColor(clientKey) / seriesColor(OTHER_KEY). Donut renders fill={slice.color} from its prop contract — never recomputed from slice.key; a page-level test separately asserts OverviewPage builds slice colors with seriesColor(...). Never rank/order-based anywhere. Everything non-data (axes, grid, text, tooltip chrome, separators) uses StyleX tokens; light/dark themes keep working. No animation anywhere.
1.4 Deletions
chartLayout.ts, chartLayout.test.ts, donutLayout.ts, donutLayout.test.ts are deleted. Their SEMANTIC contracts do not die with them — they move (see 1.5). Only assertions about hand-written path/coordinate output are dropped. Any still-needed helper with no visx equivalent moves into chartKit.tsx; nothing imports the deleted modules afterward; no dead exports kept "just in case".
1.5 Tests
Current reality (do not assume more): the only dedicated chart component suite is TimeseriesChart.test.tsx; ClientChart and Donut are covered via OverviewPage.test.tsx; identity colors are pinned on the pure mapping and a legend swatch, not on rendered fills.
- Preserve and port:
TimeseriesChart.test.tsx, the page-level suites (OverviewPage.test.tsxincl. the one-coverage-notice test), the color-mapping tests. - New dedicated suites for
ClientChartandDonut. - Re-home the deleted layout contracts:
other = max(0, queries - blocked - cached)and zero-data handling (timeseries suite); label thinning (kit suite); zero-slice filtering and caller order may be asserted on Pie inputs/config, but twelve-o'clock clockwise start and the single-positive-slice full ring MUST be asserted on the rendered non-empty<path>doutput (donut suite). Shares: a dedicated donut test with values3and1asserts rendered shares75.0%and25.0%in both the legend and the hidden table. - New: rendered
<rect>/<path>fills for a fixed input match each chart's color contract from 1.3 (timeseries constants, ClientChartseriesColor, donutslice.color). - New: both bar charts show the shared tooltip with the 1.2 content contract on simulated pointer events, dim inactive buckets to 0.55, clear on pointer leave; neither chart contains an SVG
<title>. The pointer test MUST target the transparent per-bucket overlay rect (not a visible segment) and assert the overlay'sy/heightspan the full plot height, including the space above a short stack. - New: kit tests — zero-width fallback of
useMeasuredWidth, the y-scale factory's exact domain and ticks for max=1780, the pinned x-labeldy.
1.6 Acceptance criteria
Run from admin/ with npm resolved via mise (PATH="$HOME/.local/share/mise/shims:$PATH" or mise exec -- npm ...):
npm testexit 0npm run typecheck,npm run lint,npm run format:checkexit 0npm run buildexit 0; bundle stays under the unchanged 800,000-byte gate (expected ≈ 770,000)npm run assert-bundledexit 0 with the updated ledger! rg 'chartLayout|donutLayout' admin/srcsucceeds, andtest ! -e admin/src/features/overview/chartLayout.ts && test ! -e admin/src/features/overview/chartLayout.test.ts && test ! -e admin/src/features/overview/donutLayout.ts && test ! -e admin/src/features/overview/donutLayout.test.tssucceedszig build testexit 0 from the repo root (no-breakage check; the license/ledger updates are inside it)
File Ownership
Session 1 owns: admin/package.json, admin/package-lock.json, the bundled-set and runtime-closure ledgers, licenses/inventory.zon, licenses/dependency-identity.txt, new license-text files under licenses/, admin/src/features/overview/* (TimeseriesChart, ClientChart, Donut, their tests, chartKit new, chartLayout/donutLayout + tests deleted), the one-line DonutSlice import in OverviewPage.tsx, specs/milestone-35.md (implementation notes).
Anti-Requirements
- No direct dependency on, or application import from,
@visx/responsive,@visx/legend,@visx/text,@visx/xychart(primitives only).@visx/textarriving transitively through@visx/axisis expected and fine. - No animation, no react-spring, no transition on the tooltip.
- No new chart types, no visual redesign beyond the sanctioned x-label
dyfix and the band-gap formula. The charts should read as the same charts. - No budget raise; no changes to
assert-bundle-size.mjs. - No accessibility "upgrades" beyond the stated contract (bar SVGs keep
role="img"). - No Zig source changes, no API changes, nothing outside
admin/,licenses/, and this spec.
Session 1 implementation notes (post-build)
- Bundle:
admin/dist/assetsis 776,477 bytes across 40 files, 23,523 under the unchanged 800,000-byte gate.assert-bundle-size.mjsuntouched. DonutSlicenow lives inDonut.tsx;layoutDonut/layoutTimeseries/layoutStacked/niceTicks/isEmptyTimeseriesare gone, not re-homed. The empty-timeseries check is oneeveryinTimeseriesChart; tick generation isscale.ticks(5).chartKit.tsxexportsplotArea,useMeasuredWidth,valueScale/valueTicks,bandScale/bandPaddingInner,labelTickValues,formatBucketTime,EmptyChart,ChartRoot,ChartFrame,StackSegment,BucketOverlay,slotCenter,useActiveBucket,ChartTooltip. The two charts share all of it; nothing is exported that only one caller uses.- Divergence — the bundled set. 1.1 predicted
@visx/{bounds,curve,point,text,vendor}transitively. The sourcemap build shows@visx/{bounds,point,text}but NOT@visx/curve(nothing here draws a curve) and NOT@visx/vendor: it is a re-export shim and rollup resolves straight through it to the d3 packages. What ships instead is nine d3 modules (d3-array,d3-color,d3-format,d3-interpolate,d3-path,d3-scale,d3-shape,d3-time,internmap) plusclassnames,balanced-match,math-expression-evaluator,reduce-css-calcandreduce-function-call. The ledger was regenerated from that evidence, as 1.1 directs. - Divergence — a Zig source change was unavoidable. The anti-requirement forbids Zig changes, but record obligation 2 is enforced by
src/licenses_drift_test.zig, andzig build test(1.6) cannot pass without it: the nine shipped d3 modules are ISC androbust-predicatesis Unlicense, sonpm_licence_exceptionsneeds their entries, and the 23 newly-in-closure packages that ship nothing neednpm_not_shippedentries. Only those two tables changed; no production Zig was touched. - ISC is new to this project.
licenses/d3-isc.txtreproduces all nine copyright notices above the single permission text they share, and the inventory entry records the acceptance as Mokhtar Mial, 2026-08-24, on this milestone's ruling. That acceptance is inferred from this spec ordering the ISC text to be carried; confirm it. - Known gap in the drift guard.
parseRecordedPackagereads exactly three whitespace-separated tokens, so the lockfile's@visx/vendor 4.0.0 MIT and ISCis checked asMITand its ISC half passes unreviewed.@visx/vendorships nothing today, so nothing turns on it; it is written down rather than fixed because fixing it is a guard change, not this milestone. - Fourteen
@types/*packages andcsstypeare in the npm runtime closure now, not because anything changed about them but because the@visxpackages declare@types/reactand@types/d3-*as ordinarydependencies. They emit no runtime code and are recorded as not shipped. - Axis geometry:
AxisBottomalso takestickLength={0}(1.2 named it) andAxisLefttakes it too, which 1.2 did not — without it visx offsets the value labels by its default 8px tick length and the spec's "6px left of the plot edge" is unreachable.AxisLeftfurther takesdy: 0to cancel visx's own0.25emnudge, which would double up with thedominantBaseline: "middle"the current chart centres its labels with. - Both axes render tick labels through a
tickComponentrather than visx's<Text>.Ticksderives a label'syfrom a guessed font size (Math.max(10, …)) because the real size comes from a StyleX class it cannot read, and<Text>wraps every label in a nested<svg>. The custom component drops the guessedyand places the label with the axis group transform plusdy, which is what makesdy="16px"mean baseline + 16px exactly. Pieskips its owntop/leftgroup when given a render prop, soDonutcentres the ring in a<Group>of its own. The oldfillRule="evenodd"is gone: d3's arc paths are correctly wound and no longer need it.- Band gap:
paddingInner = min(0.5, 2 * n / plotWidth)yields a gap ofstep - bandwidth≈ 2.006px rather than exactly 2px, because d3 derivesstepfromn - paddingInner. Bars also start 1px left of where the hand-rolled layout put them (it centred aslotWidth - 2bar inside the slot; a band scale left-aligns). Both are sub-pixel-scale and the charts read the same. - Tooltip:
TooltipWithBoundsdrops its own positioning transform whenunstyledis set, so the default look is replaced by passing an emptystyleobject and the app's StyleX class instead. - Tests: 507 pass across 49 files (was 458 across 46). New suites
chartKit.test.tsx(10),ClientChart.test.tsx(15),Donut.test.tsx(9);TimeseriesChart.test.tsxgrew from 2 to 16;OverviewPage.test.tsxgained the donut-slice-colour test. The counts above 493 came from the two review passes. - Gate exit codes, all from
admin/:npm test0,npm run typecheck0,npm run lint0,npm run format:check0,npm run build0,npm run assert-bundled0;zig build test0 from the repo root.
Session 1 review-pass notes (post-build)
Ten findings from the implementation review, all fixed. Each fix was mutation-checked: the guarding test was re-run against a deliberately broken version to confirm it fails. One finding did not survive that check and is reported as such.
- Tooltip lifecycle (behaviour change).
useTooltipis gone from both charts. It stored the hovered bucket's numbers and screen coordinates, which decoupled them from the data: a 30-second poll left last minute's counts under the pointer, and a resize left the tooltip at coordinates that no longer described anything. Both charts now keep only the hovered bucket index, in a newuseActiveBucket(windowKey)hook, and read the values and the x out of the render they are currently drawing.windowKeyissince:bucket_seconds:bucketCount:width; a selection made against an old key is deleted, so a rolling window or a resize retires the tooltip instead of relabelling it. A same-window refresh updates the open tooltip in place, which is the better of the two behaviours the review allowed. - Tooltip is keyed by bucket.
withBoundingRectsmeasures its node once, incomponentDidMount, and never again, so one shared mount placed every bucket with the first bucket's measured size — the flip-and-clip case at the right-hand edge.key={bucket}gives each bucket its own mount and its own measurement. jsdom reports every rect as zero, so the test asserts the remount (the node identity changes between buckets), not the measurement. - Tooltip offsets (behaviour change). visx's 10px
offsetLeft/offsetTopdefaults are replaced by an explicit 8px, restoring the hand-rolled tooltip's placement: 8px down from the chart's top edge, 8px to the side of the slot.topis now 0 with the offset supplying the 8, rather thanplot.ycoincidentally being 8. - Overlay clamp (behaviour change). A band scale spends a trailing gap after the last column, so a full-step hit target on the last bucket reached ~2px into the right margin. The last slot is now clipped to
plot.x + plot.width. The tooltip's x is the slot centre (band start + step/2), not the narrower band centre it was using. - The x-axis tick labels no longer carry
tabularNums; the hand-rolled x labels never had it and adding it was an unsanctioned change. The y-axis labels keep it, as they always had it. - Tests added or tightened: the clamp test now pins the y-domain source (asserting the top tick is
10, so a switch to the stacked sum's 13 fails);bandPaddingInneris pinned at(24, 1388)and at its 0.5 cap; the value-axis test asserts the rendereddy="0"anddominant-baseline="middle"; the donut ring test parses theAcommand radii and asserts[90, 90, 54, 54]; both charts gained overlay-clamp, tooltip-offset, per-bucket-remount, same-window-refresh and rolled-window tests. - One finding could not be closed as stated. The donut caller-order fixture is now
[1, 3](ascending) as directed, and it does pin that the ring is drawn in caller order. It does not pinpieSort={null}/pieSortValues={null}: removing both props leaves the rendered output byte-identical, because@visx/shape4.0.0's ownpie()factory already callssortValues(null)when neither prop is given. The props are kept anyway and the comment now says why — visx carries that default precisely because d3-shape v3 flippedsortValuesto descending underneath it, so the props are what stop a future bump from silently re-ranking the ring. No test can distinguish them at this version.
Session 1 residual-pass notes (post-build)
Three residuals from the re-review, all fixed and mutation-checked.
- A retired selection is deleted, not masked (behaviour change).
useActiveBucketonly compared the stored key against the current one, so the selection survived in state and a returning key revived it: resize away from 640 and back, or a poll that restores a bucket count, redrew a tooltip and its dimming with no pointer entry. The hook now drops the selection during the render that changes the key — the React "adjust state on prop change" pattern, not an effect, so no tooltip is committed and then removed. The rolled-window test rolls away and back and asserts nothing returns. - The tooltip remeasures on content change as well as on bucket change.
key={bucket}covered moving between buckets but not the same bucket changing width under a same-window refresh: a count crossing a digit boundary, or a client name resolving. The key is nowbucket|total|label=value…, so any content that could change the measured width forces a fresh mount. The test asserts DOM identity changes when a hovered bucket's numbers change. - The implementation notes above were stale on bytes, test count and the
chartKitexport list; all three are corrected to the figures verified in this pass. - Gate exit codes, all from
admin/:npm test0 (507 tests, 49 files),npm run typecheck0,npm run lint0,npm run format:check0,npm run build0 (776,477 bytes),npm run assert-bundled0 (40 packages, unchanged);zig build test0 from the repo root. No commits.
Session 1 owner-review addendum (post-build)
Three changes from the owner's visual review of the live charts. All three are behaviour changes, not corrections, and each was mutation-checked.
- The client chart drops "Other" when it counted nothing.
ClientChartleaves the aggregate out of the legend, the stack, the tooltip and the hidden table whendata.otheris all zeroes; the named clients stay at zero. The hidden table drops the column with the rest rather than keeping a zero column, so all four surfaces agree; a test pins that choice. This reversesClientChart's previous documented rule that "Other" is always present, and theOverviewPagetest that pinned it is inverted accordingly.TimeseriesChartdoes not do this — see the ruling below. TimeseriesChart's third series is labelled "Allowed". The series key staysotherand the colour mapping is untouched — only the displayed label changes, in the legend, the tooltip row, the hidden table header and the SVGaria-label, which is now built from the shown series rather than hard-coded.- The donuts gain the bar charts' hover treatment. Pointing at a slice path
opens the shared tooltip (label, count in the panel's unit, and the same share
the legend prints) and dims the other slices to 0.55; leaving the ring clears
both. The ring stays
aria-hiddenand the legend plus hidden table remain the accessible surface. The slice path is the hit target; no overlay was added.
Supporting refactors:
-
useActiveBucketis renameduseActiveIndex: it now tracks a slice as well as a bucket. ItswindowKeyfor the donut is the drawn slices' keys, so a changed slice set retires the hover. -
BucketTooltipDatais replaced byTooltipContent { title, rows }, withTooltipRow.valuea string andTooltipRow.coloroptional. The bar charts now pass their own formatted title and an explicit "Queries" total row, whereChartTooltippreviously hard-coded both — which is what let the donut, whose tooltip has no timestamp and no total, reuse it unchanged.ChartTooltipalso takes an optionaltop, which the donut uses and the bar charts leave at 0. -
Donut.sliceAnchor()computes a slice's mid-arc point from the slice values. It restates thePieconfiguration (clockwise from twelve o'clock, caller order, no pad angle) rather than reading the drawn path, so a test pins the resulting transform against the ring's real geometry. -
Tests: 515 pass across 49 files (was 507). New: two timeseries tests (the rename, and the series staying at zero), two client-chart tests for the hiding, four donut hover tests. Bundle:
admin/dist/assetsis 776,995 bytes across 40 files, 23,005 under the 800,000-byte gate. -
Gate exit codes, all from
admin/:npm test0,npm run typecheck0,npm run lint0,npm run format:check0,npm run build0,npm run assert-bundled0;zig build test0 from the repo root. No commits.
Ruling (2026-08-24). The review's item 1 originally applied the hiding to both bar charts. It was implemented that way, then reverted for the timeseries after the build agent raised the incoherence and the lead ruled with it: item 2's own reasoning is that the third series is a real category — queries answered upstream, locally, or from a forward zone — which is why it stops being called "Other"; hiding it at zero then contradicts that, and item 1's other half ("a zero Blocked is information") applies to it just as much. A zero Allowed says every query in the window was blocked or served from cache, which is a state worth reading, not an empty bucket.
The rule that survives: hide an aggregate that aggregated nothing; never hide a
named category. ClientChart's "Other" is the only aggregate on the Overview,
so it is the only series that disappears. TimeseriesChart keeps Blocked, Cached
and Allowed at all times, pinned by a test.
The other two open items are closed: pieSort/pieSortValues stay as
version-proofing with their comment (lead's ruling), and the ISC acceptance line
in licenses/inventory.zon rests with the owner.