The open UI & design reference behind Capitol Trace.
How we make public records legible — components, patterns, and design language.
capitoltrace.com turns 27+ government data sources into a fast, readable civic-intelligence platform — 95+ pages of member dossiers, money maps, and national-security feeds. This repository is the public design reference for that interface: the design language and component patterns we use to turn raw public records into something a citizen can actually read.
Curated, not a full export. The application code stays in the private Capitol Trace monorepo. This repo documents the design system and surfaces self-contained, reusable UI pieces over time — no proprietary data logic, scoring, or prompts.
- Stack: React 18 + Vite + Tailwind CSS
- Theming: CSS-variable overrides with dark / light / system modes
- Type & color: a restrained, high-contrast system tuned for dense data tables and long reading
- Principle: receipts, not vibes — every view traces back to a public record
- Design language — the color, type, spacing, and theming rules behind the platform (documented here)
- Component patterns — how we structure dossiers, data tables, and dashboards
- Curated components (rolling out) — self-contained React + Tailwind pieces, added over time
If you're looking for the full product, it's live at capitoltrace.com — free, no account required.
The shared design layer, consumed by both capitoltrace and voterready.
Plain JavaScript + JSX, ESM, React 18 peer dependency. No TypeScript.
npm install # once
npm run build # dist/index.js + dist/tokens.css
npm run storybook # http://localhost:6006
npm test # axe, contrast, and focus-ring assertionsInstalling it into an app: see MIGRATION.md.
| Import | What it is |
|---|---|
@capitoltrace/ui |
The twelve primitives — Card, Chip, SourceLine, StatBoard, Button, FilterBar/FilterGroup, Field/Input/Select, DataTable, Tabs, Panel, EmptyState, Skeleton — named exports, no defaults |
@capitoltrace/ui/tokens.css |
Every token as a CSS custom property. Import once at the app root |
@capitoltrace/ui/tailwind-preset |
The same tokens as Tailwind utilities (bg-ct-surface, rounded-ct-card, …) |
src/tokens.css is the single source of truth. The Tailwind preset holds no
literal colours — every entry is a var(--ct-*) reference.
Light tokens live on :root. Dark overrides live on html.dark, matching
the app's Tailwind darkMode: 'class' setup — dark mode is a dark class on
<html>. There is no data-theme attribute anywhere in this package.
Because the selector is html.dark, dark mode cannot be scoped to a subtree.
Storybook drives the document element rather than a wrapper, which is why light
and dark are separate stories instead of side-by-side panels.
Note: the dark palette here is a new fourth theme introduced by this design system. It intentionally matches none of the app's existing three (
newsroomlight/default,bloombergdark,light).
Six core tokens are remapped outright:
| Token | Light | Dark |
|---|---|---|
--ct-bg |
#f7f7f5 |
#0d1b2a |
--ct-surface |
#ffffff |
#11243a |
--ct-surface-alt |
#faf9f7 |
#16293f |
--ct-border |
#e7e5e1 |
#22334a |
--ct-text |
#0d1b2a |
#f1f5f9 |
--ct-text-secondary |
#475569 |
#94a3b8 |
--ct-accent |
#b97309 |
#f59e0b |
The desk palette and the semantic pair shift to their 400-weights in dark
for legibility. The token name does not change — --ct-desk-money resolves to
whichever weight the theme calls for, so components never branch on theme:
| Token | Light | Dark (→ 400) | Contrast on dark --ct-surface |
|---|---|---|---|
--ct-desk-money |
#0f6e56 |
#3fb894 |
6.35:1 |
--ct-desk-news |
#a32d2d |
#e06a62 |
4.79:1 |
--ct-desk-civic |
#185fa5 |
#4a97e0 |
5.08:1 |
--ct-desk-education |
#534ab7 |
#9a92e8 |
5.72:1 |
--ct-desk-convergence |
#f59e0b |
#fbbf24 |
9.41:1 |
--ct-positive |
#0f6e56 |
#3fb894 |
6.35:1 |
--ct-negative |
#a32d2d |
#e06a62 |
4.79:1 |
Each 400-weight clears 4.5:1 against --ct-surface #11243a. The 400 values are
also addressable directly (--ct-desk-money-400, --ct-positive-400, …), as are
--ct-dem-400 and --ct-gop-400, which do not remap.
Everything else — --ct-ink-2, --ct-ink-3, --ct-rule-on-ink, --ct-amber,
the amber washes, --ct-dem/--ct-gop/--ct-tossup — is theme-invariant and
carries its :root value into dark.
Three rules the components enforce and the test suite guards mechanically:
#f59e0bfails AA as text on light surfaces (2.15:1). It is for fills, spines, dots, and text on dark backgrounds only.--ct-accent#b97309is 3.80:1 on white and 3.55:1 on--ct-bg. It clears the 3:1 bar for large text (≥24px, or ≥18.66px bold), UI boundaries, and focus rings — and does not clear 4.5:1 for body-size text. The only sanctioned accent-on-light in the primitives:StatBoard's 24px figure (large text) andTabs' 2px active underline (non-text boundary).- The focus ring uses
--ct-accent, never--ct-amber.--ct-accentremaps per theme (3.55:1 light, 8.10:1 dark, both against--ct-bg), so it passes the 3:1 non-text bar in both. Hardcoded--ct-amberwould be 2.15:1 in light.
Desk colours are for kickers, 3px card spines, and data marks only. Never a button fill, never a page tint.
The focus ring — 2px --ct-accent at 2px offset — is never removable. No
component sets outline: none, and the test suite fails if one starts to.
One, documented inline in src/tokens.css:
--ct-text-mutedis aliased to--ct-text-secondaryunderhtml.dark. The supplied dark palette does not remap--ct-text-muted, which leaves it at its light#64748bin dark: 3.10:1 on--ct-surface-altand 3.30:1 on--ct-surface. That is the colourSourceLine(10.5px) andStatBoardlabels (10px) are specified to use, so dark mode would fail AA on two of the first four primitives. Aliasing it to the canonical dark--ct-text-secondary#94a3b8gives 5.76:1 and 6.12:1. No new colour enters the system. Delete the alias andnpm testfails with those exact ratios and points back at the line.
Two pairs in the supplied palette pass, but only just — they have no room for a future nudge, and the test suite will catch it the moment one moves:
--ct-negative#e06a62on dark--ct-surface-alt(theERRORlabel) — 4.50:1.--ct-text-muted#64748bon light--ct-surface-alt(theSourceLinestrip) — 4.52:1.
Also worth knowing: Chip uses --ct-text as its foreground for every tinted
tone, not the tone colour. Tinting the label instead would put dem at
3.63:1, gop at 4.00:1, and tossup at 1.64:1 against their own 10% fills at
11px. The tint and the 30% edge carry the meaning; the ink stays readable, and
the geometry is identical across tones either way.
Chip tone="accent" carries the package's only hardcoded hex — the foreground is
pinned to the literal #0d1b2a rather than --ct-text, because --ct-text
remaps to #f1f5f9 in dark and lands at 1.96:1 on amber. It is commented in
src/components/Chip.jsx, and a test asserts no other hex appears outside
tokens.css.
Inter for language, JetBrains Mono for anything machine-generated — figures,
labels, routes, timestamps, kickers. Exposed as --ct-font-sans and
--ct-font-mono; the package does not load the fonts, the apps do.
| Step | Size / line-height / tracking / weight | Utility |
|---|---|---|
| display | 52 / 1.04 / -.025em / 800 | text-ct-display |
| h1 | 32 / 1.15 / -.02em / 800 | text-ct-h1 |
| h2 | 22 / 1.25 / -.02em / 800 | text-ct-h2 |
| h3 | 17 / 1.35 / 700 | text-ct-h3 |
| body | 15 / 1.6 / 400 | text-ct-body |
| secondary | 13.5 / 1.55 / 400 | text-ct-secondary |
| kicker | mono 11 / 700 / .08em uppercase | text-ct-kicker |
| figure | mono 26 / 700 tabular | text-ct-figure |
Every figure gets font-variant-numeric: tabular-nums. The .ct-figure
class (in tokens.css) bundles mono + 700 + tabular so a caller can't render a
figure and forget it; size stays separate.
- Space — 4px base: 4, 8, 12, 16, 20, 32, 56 (
p-ct-16,gap-ct-8, …). Container1240px(max-w-ct-container), gutters 16 / 24. - Radius — three values plus pill, role-named so they can't be misapplied:
rounded-ct-control(4, buttons/inputs/chips/badges) ·rounded-ct-block(8, nested blocks/list rows) ·rounded-ct-card(12, cards/panels/tables) ·rounded-ct-pill(999, accent bars and status dots only). - Elevation — rest is a 1px hairline with no shadow. Hover is
translateY(-2px)plusshadow-ct-hover.shadow-ct-overlayis for modals and dropdowns only.
| Component | What it is |
|---|---|
Card |
Surface, 1px border, 12px radius, 20px padding. desk renders the 3px spine; interactive adds the lift and renders a real focusable element; as overrides the element |
Chip |
4px radius, 11px mono uppercase. One geometry — tone sets a 10% tint and 30% border. accent is the exception: solid amber |
SourceLine |
The receipts strip. Status dot, agency names, filing links, Synced 28 Jul 09:15 UTC — always UTC, always that format |
StatBoard |
A bordered grid of figures with 1px internal dividers. First stat (or any tone="accent") leads in accent; the rest stay --ct-text |
Button |
Four fills at three heights (30/38/44). primary is ink; accent is amber — the one invitation per screen; secondary bordered; ghost bare. Disabled is a state, not a variant; as="a" for link-buttons |
FilterBar / FilterGroup |
Segmented filter groups on a --ct-surface-alt strip with a right-aligned sort slot. Selected segment is an ink fill — never amber |
Field / Input / Select |
Mono uppercase label above a 38px control; errors are mono text beneath, never a tooltip. Field wires id, aria-invalid, and aria-describedby onto its one control child |
DataTable |
The workhorse: mono 10.5px uppercase headers (sorted column goes ink), right-aligned tabular mono figures, zebra on even rows, 44/34px rows via density, controlled sort/onSort, and pagination + sourceLine footer slots. Presentation only |
Tabs |
The detail-archetype strip with optional mono counts. Roving tabindex, automatic activation, arrow/Home/End keys; the active underline is --ct-accent |
Panel |
Dashboard building block: header row (title + actions), free-form body, sourceLine footer slot. Grid spans (4/6/12) are the consumer's grid classes |
EmptyState |
Icon-less: title, one-line body, at most one action. Transparent — inherits the surface it sits on |
Skeleton |
text/figure/block bones sized by the type scale. Opacity-only pulse, off under prefers-reduced-motion. Skeletons, never spinners — the layout must not reflow when data lands |
Each one: named export, React.forwardRef, className and ...rest forwarded
to the root, no default export, no data fetching, and no margin on the
outermost element — spacing is the caller's job.
DataTable, FilterBar, Field, Tabs, Panel, EmptyState, and Skeleton
landed in 0.2.0 (phase 3). The spec's "Desk card" and error/cached-data states
are compositions, not components — see the DeskCard story (Card + SourceLine)
and the DataTable CachedData/ErrorState stories (SourceLine status +
EmptyState).
npm test runs the suites over every story and every token pair:
test/a11y.test.jsx— axe over all 127 stories, no violations.test/contrast.test.js— every foreground/background pair the primitives can produce, in both themes, classified by the size it is actually rendered at: 4.5:1 for body text, 3:1 for large text and non-text boundaries. A flat 4.5:1 bar would wrongly failStatBoard's 24px accent figures and the focus ring. Purely decorative pairs (container hairlines, redundant spines and dots) are measured and reported with a written exemption rather than asserted.test/focus.test.jsx— the focus ring resolves to--ct-accentin both themes, every focusable element in every story carries it, and no component suppresses the outline.test/dataTable.test.jsxandtest/tabs.test.jsx— behavior contracts: sort wiring, density heights, zebra, footer slots; roving tabindex and arrow-key activation.
Contrast is read from the real src/tokens.css, not a copy — change a token and
the test inputs change with it.
Part of the Capitol Trace civic data network.