Resolved: light

Data viz & metrics

The Save the Dogs data-viz family — the way numbers, trends and progress read in the obsidian skin. Two tiers: pure server-safe SVG/CSS micros (StatTile, Sparkline, Gauge, Meter, ProgressRing, TrendDelta, StatusMeter) that are just props → markup, and the four Recharts wrappers (Line / Area / Bar / Donut) that are interactive client components. Every color rides a token — accent, the num-pos/-neg/-flat trend colors, the good/warn/bad tones, and the data-series-* ramp — so flip the theme in the top bar and the whole page reskins. All data on this page is sample fixtures, never live.

server vs client
All micros (StatTile…StatusMeter) are server-safe — no hooks, no browser APIs. Only the four charts are client (Recharts measures the DOM + animates), so they live in the colocated ./ChartsDemo island.
tone tokens
Gauge / Meter / ProgressRing / StatusMeter take tone "accent" | "good" | "warn" | "bad" | "neutral" — meaning rides a token, never a raw hex.
trend tokens
TrendDelta and StatTile’s delta use --num-pos / --num-neg / --num-flat — green up, red down, muted flat (with invert for metrics where down is good).
series ramp
Charts color each series via seriesColor(i) → var(--color-data-series-1..6); pass the same to your own legend swatches so they match.
reduced motion
Every chart accepts reducedMotion — pass true to snap to the final frame (no draw-in). Honour the user’s prefers-reduced-motion at the call site.

StatTile — anatomy

The headline metric card. A label, a big value, an optional delta (with deltaTone for the trend color), a caption line, and an optional spark slot that takes any node — typically a <Sparkline>. accent draws the brand left-edge; display swaps the value to the Fraunces display face; loading shows a shimmer.

Active rescuers
1,284+12.4%
vs. last 30 days
Open intakes
37-8.1%
vs. last 30 days
Avg. response
2.4h0.0%
no change this week
label + value + delta + caption + spark — the full tile
label
ReactNode (required). The small, muted metric name above the value.
value
ReactNode. The headline figure — pass a pre-formatted string (use tabular-nums upstream for column alignment).
delta / deltaTone
delta is the change text; deltaTone "up" | "down" | "flat" colors it (--num-pos / -neg / -flat). Tone is independent of sign — pick the tone that means good/bad for THIS metric.
caption
ReactNode. The faint context line under the delta (e.g. “vs. last 30 days”).
spark
ReactNode slot for a micro-chart — usually a <Sparkline>. Sized by the node you pass; keep it short (height ~28).

StatTile — accent, display, loading & minimal

The boolean modifiers. accent draws the brand left-edge for a hero metric; display renders the value in the Fraunces display face for a big number; loading shows a shimmer placeholder while the figure resolves. A tile is valid with just label + value — drop the delta/caption/spark when you don't have them.

Total reach
48,920+18.2%
accent — the hero metric
Dogs placed
3,107
display — Fraunces face
Pending reviews
14
plain — label + value only
accent (hero) · display (big number) · plain
Active rescuers
Open intakes
Avg. response
loading — shimmer placeholder
accent
boolean. Draws the brand accent left-edge — reserve it for the single hero metric in a group.
display
boolean. Renders the value in the Fraunces display font for an oversized headline number.
loading
boolean. Shows a shimmer placeholder in place of the value/delta — keep the label so layout is stable.
minimal
Everything except label is optional; a label + value tile is the floor.

StatGrid — the metric row

A responsive container that lays out a row of StatTiles with the right gaps and equal heights. Drop StatTiles straight in — StatGrid handles the wrapping, so you don't hand-build a grid. It is the canonical header for a dashboard or report.

Volunteers
765+5.1%
Teams
42+2
2 new this month
Events
18-3
fewer than last month
Coverage
86%0.0%
steady
StatGrid wrapping four StatTiles
StatGrid
A plain Div wrapper — pass StatTiles as children; it owns the responsive columns + gaps. No props beyond className / standard div attrs.
composition
Mix accent + plain tiles — one accent tile per grid reads as the lead. Keep deltas consistent (all show vs. the same window).

Sparkline

A bare, axis-less inline trend — just the shape of a number[] over time. Line by default, or filled with area. Sized by width / height props (it's an SVG). Use it inside a StatTile spark slot, beside a value in a table, or anywhere a number wants a glance of its history.

line (default) — at three widths
area — area fill under the line
1,284
in context — a value with its trend inline
data
number[] (required). The series, oldest → newest. The component scales it to the box — no axes, no labels.
width / height
Pixel size of the SVG (defaults are small). It draws to fill — pick a width that matches the slot.
area
boolean. Adds a soft fill under the line for a denser read; omit for a thin line.
a11y
Decorative by default — pair it with the actual figure (the value text carries the meaning), or add an aria-label describing the trend.

Gauge

A semicircular dial for a single value within a range — the radial read of “how full / how far along”. Tone colors the arc; label sits under it, valueLabel is the big readout in the center. min/max default to 0..100; size controls the diameter.

64%Capacity
tone="accent"
64%Capacity
tone="good"
71%Capacity
tone="warn"
92%Capacity
tone="bad"
64%Capacity
tone="neutral"
every tone (value within 0–100)
6.8Health score
340Signups
custom range (min / max) + size
value
number (required). Clamped into the min..max range; the arc fills to its fraction.
min / max
Range bounds. Default 0 / 100. Set them when the metric isn’t a percentage.
tone
"accent" (default) | "good" | "warn" | "bad" | "neutral" — colors the filled arc via the matching token.
label / valueLabel
label is the caption under the dial; valueLabel is the centered readout. Pass both for a self-describing gauge.
size
Diameter in px. Scales the whole dial; keep within a tile.

Meter

A horizontal bar for a value in a range — the linear sibling of Gauge, for when a row reads better than a dial. Tone colors the fill; label / valueLabel flank it; threshold draws a marker (e.g. a target or limit) you can read the fill against.

tone="accent"58%
tone="good"58%
tone="warn"78%
tone="bad"94%
tone="neutral"58%
every tone (value within 0–100)
Donations (target 500)420
Donations (over target)540
threshold marker + custom range
value
number (required). Clamped into min..max; the bar fills to its fraction.
min / max
Range bounds, default 0 / 100. Use real units when the metric isn’t a percentage.
tone
"accent" (default) | "good" | "warn" | "bad" | "neutral" — the fill color token.
threshold
Optional marker line at a target/limit value (same scale as value), so the fill can be read against it.
label / valueLabel
label sits before the bar, valueLabel after — both ReactNode.

ProgressRing

A circular 0–100 progress indicator with an open center — drop any node (a percentage, an icon, a fraction) into children and it sits inside the ring. Tone colors the arc; size / thickness shape it. For completion / quota reads where a full circle says “done”.

84%
tone="accent"
84%
tone="good"
62%
tone="warn"
28%
tone="bad"
84%
tone="neutral"
every tone — value inside the ring
75%
9/18
size + thickness + a custom center node
value
number 0–100 (required). The fraction of the ring that fills.
tone
"accent" (default) | "good" | "warn" | "bad" | "neutral" — the arc color token.
size / thickness
Diameter and stroke width in px. Thicker reads heavier; keep thickness proportional to size.
children
Centered node — a %, fraction, icon or short label. Keep it short so it fits the open center.
aria-label
Give a label describing the progress (the visual % isn’t always announced); pass it explicitly.

TrendDelta

An inline change figure: a signed number with a directional arrow and the right trend color (green up, red down, muted flat — the --num-pos / -neg / -flat tokens). format lets you render it as a %, currency, etc.; invert flips the coloring for metrics where down is the good outcome; neutralAt sets the value that counts as flat.

+12.4%increase-8.1%decrease0%no change
default coloring — up / down / flat
-15%decrease+9%increase
invert — down is GOOD (e.g. response time, churn)
$1,280increase2 ptsno change-3 ptsdecrease
format + neutralAt — custom formatting and flat band
value
number (required). Its sign drives the arrow + color (relative to neutralAt).
format
(n: number) => ReactNode. Renders the figure — add the +/–, %, $, units. Defaults to the raw number.
invert
boolean. Flips the color logic — negative reads as good (green), positive as bad (red). For cost/latency/churn metrics.
neutralAt
The value treated as flat (default 0). Values at it render muted with no arrow.

StatusMeter

A segmented “filled of total” meter — discrete steps rather than a continuous bar. Reads as “7 of 10 done”: filled segments are toned, the rest are empty. Tone colors the filled segments; label captions it. For checklist / quota / coverage progress measured in whole units.

tone="accent" — 8 of 10
tone="good" — 8 of 10
tone="warn" — 6 of 10
tone="bad" — 2 of 10
tone="neutral" — 8 of 10
filled / total across every tone
Onboarding (3/5 steps)
Profile complete (5/5)
Not started (0/4)
different totals — small step counts
filled
number (required). How many segments are filled.
total
number (required). The total number of segments; filled / total is the read.
tone
"accent" (default) | "good" | "warn" | "bad" | "neutral" — the filled-segment color token.
label
ReactNode caption — state the fraction in words (“3 of 5 steps”) since the segments alone aren’t announced.
vs. Meter
Use StatusMeter for DISCRETE whole units (steps, items); use Meter for a CONTINUOUS value in a range.

Charts — Line / Area / Bar / Donut

Thin Recharts v3 wrappers that inherit the skin: series come from seriesColor(i) → var(--color-data-series-1..6), grid/axis from the data tokens, with the display font + tabular-nums on labels and a branded draw-in. No hardcoded hex, so charts reskin with brand/theme. These are CLIENT components (Recharts measures the DOM + animates) — they live in the colocated ./ChartsDemo island. They size to their container via a ResponsiveContainer; give them a parent with a real width and a height prop.

LineChart — multi-series (signups vs. onboarded, 6 mo)
AreaChart — single series with gradient fill (weekly active)
BarChart — grouped (lead vs. member per team)
BarChart — stacked (stacked prop)
VolunteersOrganizersCommitteePartners
DonutChart — composition, with a token-matched legend
reducedMotion — same charts, draw-in disabled (snap to final frame)
data
ChartDatum[] — array of Record<string, string | number>. One object per x-category.
series
string[] — the keys to plot; each becomes a token-colored series (Line / Area / Bar). Order maps to seriesColor(0), seriesColor(1), …
x
The category key for the x-axis (default "name"). Line / Area / Bar only.
nameKey / valueKey
DonutChart only — the label key (default "name") and the value key (default "value"); each slice is colored seriesColor(i).
stacked
BarChart only — boolean. Stacks the series into one bar instead of grouping side by side.
height
Pixel height (default 240). Width is fluid via ResponsiveContainer — the parent must have a real width.
reducedMotion
boolean. true snaps to the final frame (no draw-in). Wire it to the user’s prefers-reduced-motion at the call site.
seriesColor(i)
Helper returning var(--color-data-series-(i%6)+1). Use it for your own legend swatches so they match the chart exactly.

Tone reference

The five tones the Gauge / Meter / ProgressRing / StatusMeter share. Meaning is always carried by the token (and the label), never by color alone — accent is the neutral on-brand default; good / warn / bad are the judgement tones; neutral de-emphasizes.

tone="accent"70%
70
tone="good"70%
70
tone="warn"70%
70
tone="bad"70%
70
tone="neutral"70%
70
the five tones and what each means
tone="accent"
the brand accent (--color-accent) — the neutral, on-brand default.
tone="good"
--color-good — healthy / on-track (e.g. coverage met).
tone="warn"
--color-warn — needs attention / approaching a limit.
tone="bad"
--color-bad — over a threshold / failing.
tone="neutral"
--color-muted — de-emphasized / informational, no judgement.