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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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”.
- 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.
- 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.
- 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.
- 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"
- 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.