The question picks the chart: seven questions a reader brings to a number, each with an inline mark and a card mark, and a three-engine ruling — CSS parts in rows, Recharts where there are axes, hand-drawn SVG only for a card's signature figure until it recurs.
Cross-cutting laws·docs/charts.md·314 lines
Kit ui/share-bar · ui/sparkline · feedback/meter · feedback/ring · ui/chartLiving example · Charts
How the app draws a number. Every chart — from a hairline share bar in a table row to a scatter with two axes — answers one of seven questions a reader brings to a number, and the question picks the mark. This is a cross-cutting law like card-design-language.md and feedback.md: it applies inside every page class and every card. The kit is the parts the system owns — ui/share-bar, ui/rank-bar, feedback/meter, feedback/ring, ui/sparkline, ui/chart — and the living example is /labs/components/charts, whose Overview is the table below with every mark running.
01
The question picks the chart. A chart is not a shape someone liked; it is the answer to a question, and there are seven questions. Filing charts by the question rather than by the mark does for charts what filing diagrams by data shape did for diagrams: a bar chart and a histogram look alike and answer different questions, and a share bar and a ranked bar are both bars and are not the same chart. Before this law the repo held 22 Lines, 21 Bars, 27 share bars, 11 meters and twenty-one hand-drawn SVG figures with no rule for which was which. The count is why the law exists.
02
| Question | The reader asks | Inline mark — a row, a footer | Card mark — room for axes | The light |
|---|---|---|---|---|
| Share | what part of the whole is this | ShareBar — one segment is a ratio, a stack is a composition | donut: Pie with an inner radius, clockwise from 12 | at rest none — a share has no single reading (unless one segment IS the answer, a quota's spent segment); the ACTIVE slice lifts and lights |
| Reading | where does this sit in a known range | Meter — lit ticks on a track, a pointer for "you are here", a notch for "you should be" | Ring — one arc over a muted track, the figure in the hole | the fill: it is the reading |
| Trend | how has this changed over time | Sparkline — one stroke, no axes, the number printed beside it | Line / Area with time on x | the line, and its tip |
| Comparison | which of these is bigger | RankBar — one per row, each against a common maximum | Bar with categories on x | at rest none — a STATE (the one over threshold) earns it, the leader never does; the ACTIVE bar lights |
| Relation | how do two quantities move together | — no honest inline form | Scatter | the subject point |
| Distribution | how are the values spread | — | histogram: Bar over bins | at rest none; the ACTIVE bin lights |
| Schedule | when does it start, and how long does it last | one CSS interval against an explicitly shared time window, with start/end text | horizontal range Bar with time on x and one task/span per row; waiting and execution are adjacent intervals | only the selected or currently executing interval; waiting and the cursor are context |
Two questions have no inline form on purpose. A relation and a distribution cannot be read without two axes, and a row has room for none; squeezed into a footer they become a decoration that looks like a chart.
Share vs Comparison is the ruling most often needed, because both are bars. A share bar's segments sum to a whole and are read against each other; a comparison bar is one bar per row, each read against the longest, and the rows do not sum to anything. Services ranked by request count is a comparison; the same services as slices of all requests is a share. The mark follows the question, so the same data can honestly appear as both — on two rows of one card, if both questions are being asked.
Reading vs Share is the second. A meter at 62% and a share bar at 62% look the same and are not: the meter's range is a scale with meaning at both ends (poor → perfect, empty → full) and the reading is JUDGED; the share bar's whole is a sum and the segment is a fraction of it. ARIA agrees — meter against a labelled img.
03
Three engines draw charts here, and the boundary is the question's ROOM, never taste:
ui/share-bar, ui/rank-bar, feedback/meter. A row or a footer never mounts a Recharts canvas: a responsive container, an SVG and a tooltip layer under a 6px bar is the wrong weight by two orders. The parts are repo-owned with a fixed anatomy (track, fill, notch, pointer); a call site composes them and never redraws them.ui/chart's ChartContainer. The moment a reader has to read a VALUE off the chart — an axis, a tooltip, a legend — it is a Recharts chart, themed by ChartConfig. One slot, one engine: never a second charting library. The operational rules live in AGENTS.md (pies clockwise from 12, colours from the config). ChartContainer also owns axis-label colour: its selector targets .recharts-cartesian-axis-tick-value, the actual tick text, and applies muted-foreground. Keep that shared selector aligned with the installed Recharts DOM rather than patching tick colours at each call site.ShareBar (nine hand-rolled bars before it) and Meter (nine hand-rolled tick rows) were extracted exactly this way, and the law's first act was two more: ui/sparkline from six copies across cards/, industries/ and card-list/, and feedback/ring from three, each with every caller migrated in the same change and the local drawings deleted. The count that fires the rule is three. A part's contract is the INTERSECTION of the copies it replaces, never the union — a caller that cannot fit it is a caller to fix, not a prop to add.04
A chart's glow follows card-design-language.md's three rules, and the table's last column is those rules applied. The light goes to the READING, never to the whole chart: a meter's fill and a ring's arc are the reading; a trend's line is; a comparison has no reading, only a state, so its light is earned by a bar crossing a threshold and never by being the longest; a share has none; a relation's subject point is. The ground rule holds unchanged — adjacent fills in unrelated hues (a many-slice donut, a stacked bar) never take the light, because the blur fills the gaps that made them readable — and a dense tick grid is the density at which Meter turns its own glow off.
05
The light has three ways to be earned, and they are one sentence: the light goes to whatever is being read right now. At rest that is the chart's reading (a meter's fill, the trend line). When a value crosses a line, it is that state (the bar over its threshold). And on an interactive chart it is the element under the pointer — the reading the reader has just chosen. The Run family's active node has glowed this way from the start; this names the rule for charts.
It splits charts in two:
| Reading at rest | At rest | On hover / selection | |
|---|---|---|---|
| Single-reading — line, sparkline, meter, ring, a scatter with a subject | a fixed one | lit | unchanged: a hovered context element never takes the light from the reading |
| Many-candidate — bar, pie/donut, ranked bars, histogram | none, until the pointer says | dark (a threshold state aside) | the pointed-at bar, slice or row lights |
So "none" in the table's last column never means "this chart has no light"; it means "at rest nothing is the reading yet". Two consequences:
activeShape habit) before it glows. Ground first, then light.The light ARRIVES; it does not snap. Hover on and off fades over ~200ms, and a lifting slice moves over the same span — a snap reads as a glitch, not as a response. What makes this affordable is that the blur is never the thing that transitions: each item renders a second copy of its shape wearing the glow, and the copy's OPACITY fades, so the blur is rasterised once and cached and the fade is compositor work only; the lift is a transform about the pie's centre. Never transition the filter itself — a blur tweening between two radii re-rasters every frame, which is the one expensive glow the card law warns about. One measured fact decides the mechanism: Recharts RE-MOUNTS an item the moment it becomes, or stops being, the tooltip's active item, and a remounted element is born at its final value — a CSS transition has nothing to run on. So the fade is a keyframe animation (it plays on insertion), and the fade OUT needs the chart to remember the last active index so the item that just went quiet remounts wearing the out-animation. Keyframes live in a CSS Module, never globals.css.
06
Nothing new here; three existing laws decide it, in this order of precedence:
feedback/tone.ts) — a meter that crosses into warning, a comparison bar over its threshold, a delta that is bad.Never two ladders in one chart: a series in --chart-2 beside a category in violet-500 is two colour systems arguing.
07
/labs/components/charts is a FAMILY. Overview is the table above with each mark live; a question earns a member page when its marks and rulings need more than a row: Share was filed first because ShareBar is the richest part in the system, and Reading and Trend followed as the living examples of Ring and Sparkline; Trend includes the axis-bearing forms. Comparison, Distribution, Relation and Schedule had members for two days (2026-09-19 to 09-21) and were WITHDRAWN: built before the motion and entrance rulings, they disagreed with the family at every turn, and patching four pages towards a law is dearer than extending again from it — the next member for any of those questions starts from this document, not from the old page. A new chart anywhere classifies against the table before it is drawn. One that fits none of the seven is either a diagram (see /labs/diagrams, typed by data shape) or a new question — and a new question is legislated here first (a row: its marks, its light) and built second.
08
null is not zero and never connects across a missing observation. A derived trend gets a second pane with syncId, one readout and a keyboard/touch scrub control. No dual y-axis.The Comparison, Distribution, Relation and Schedule recipes were withdrawn on 2026-09-21 with their member pages (see Growth). Their rulings are not law until a member is rebuilt against this section and Entrance; the questions themselves stand, with their marks running on Overview.
The family shares a sliding selection thumb, leaf-level number tweens and interruptible data transitions; the inline fill uses a transform rather than animating layout width. The tween, selection thumb and row layout use the house strong ease-out. Recharts' native animation is off outside the entrance window (below), deliberately: version 3.8 rekeys shape animations when props change, restarting playback on every tick. Named source measures tween instead through useAnimatedRows; labels and missing observations do not interpolate. Glow copies stay present: a newly mounted shape renders the current opacity, not the animation's final state. No autoplay loop, no animated chart-container lift, no delay before a control becomes usable.
Every mark ENTERS — on mount, and again when its dataset is REPLACED (a different series, not new values for the same rows; identity-preserving changes tween in place under the paragraph above and never re-enter). One clock: ENTER_MS = 750ms, the house ease-out, and prefers-reduced-motion mounts the mark at its value. 750 is useCountUp's number, so a card's figure and the bar beside it stop together. The shape follows the mark's axis and there are exactly three: a quantity GROWS from its baseline (bar, ShareBar, the Meter's ticks lighting left to right, a counting figure); a time series DRAWS along time (Line, Area, Sparkline); a share or a reading arc SWEEPS from twelve o'clock (donut, Ring). Three shapes are not three styles — they are "the value arriving from nothing" in three coordinate systems.
Recharts marks take their entrance through useChartMotion() in charts/_shared/motion.tsx and nowhere else: native animation is on for the entrance window only and then switches itself off, which is how the rekey ruling above and the entrance coexist — a page never writes isAnimationActive by hand, and the curve is the house bezier passed as a cubic-bezier(…) string — Recharts' keyword ease-out is [0.42, 0, 0.58, 1], an ease-in-out (its names are swapped), and its parser accepts the string even though its type names only the five keywords. The CSS parts enter by a keyframe that plays once on insertion (Meter: a clip wipe over the row; Ring: stroke-dasharray from 0), so a host that tweens value afterwards is never tweening a tween; ShareBar and Sparkline enter through motion on the same clock.
Why not static mounts: a chart that appears at its value reads as a table cell, and every reference the system is judged against enters. What this section ends is five parts each entering on the clock it was extracted with — 0, 280, 500, 700 and 1500ms on one page — which reads as five styles even when every one of them was reasonable alone.
09
The sweep of 2026-09-18 classified every chart-bearing card in /labs/cards by its registry patterns tags. This is the table a new card checks its tag against; a tag that is not here is either not a chart (a diagram, a motion pattern) or a new question.
| Question | Registry tags |
|---|---|
| Share | share-bar, allocation-bar, budget-bar, donut, donut-compare, segmented-bars, dense-grid (a unit grid is a share drawn one unit at a time) |
| Reading | tick-meter, gauge-sweep, budget-meter, budget-thermometer, semantic-meters, diverging-gauge, pace-notch, imbalance-meter |
| Trend | sparkline, time-series, gradient-area, line-chart, threshold-chart, waveform, live-waveform, equity-curve, drawdown (a derived trend — the fall from the running peak — read on the same clock as its source) |
| Comparison | rank-bars, rank-list, versus-columns, dot-strip, utility-scores, radar, diverging-bars, funnel, depth-ladder |
| Relation | scatter, diverging-matrix, attention-curve, response-curve |
| Distribution | histogram, heatmap, weight-grid, distribution-strip |
| Schedule | gantt, waterfall, timeline, timeline-playback, timeline-scrub, lifecycle-spine |
Rulings the sweep produced.
Meter split by tick colour. A meter's ARIA role announces one value against a scale; this was two parts of a whole. It is a two-segment ShareBar now, the bid-against-ask form, and the boundary between the segments is the odds.ui/rank-bar; the ShareBar workaround and the Overview's hand-drawn comparison were removed together.syncId and one cursor, and the reading under the pointer is printed once, in the header, in place of a floating tooltip. That is the honest form for a derived trend: a second pane, never a second axis squeezed onto the first. The light stays with the source series; the derived one is a loss and wears the error tone, flat.10