6.6 KiB
Thermograph — design system
The project-specific visual spec. For general aesthetic guidance the global
frontend-design skill still applies; this file is the source of truth for
Thermograph's own tokens and conventions. When they disagree, this file wins.
The authoritative values live in static/style.css (a single hand-written
stylesheet — no framework, no build step). This doc describes how to use them;
it deliberately avoids copying hex values that could drift. When in doubt, read
the :root block at the top of style.css.
Tokens (never hardcode)
Every color is a CSS custom property in static/style.css :root (top of the
file), with a @media (prefers-color-scheme: light) override right below it.
Always use var(--token); never paste a raw hex into a rule or an inline
style. New surfaces get their color from the existing tokens so light mode and
future retints keep working for free.
Structural tokens: --bg, --surface, --surface-2, --border, --text,
--muted, and the warm orange brand --accent (#f0803c). The light-mode block
remaps the first six; the grade scales below are shared across both schemes.
Grade palettes
These encode meaning, not decoration — keep their order and midpoints intact:
- Temperature — a 9-step diverging scale, cold → green → hot, chosen to be
colorblind-safe:
--rec-cold--very-cold--cold--cool--normal(green midpoint)--warm--hot--very-hot--rec-hot. The two--rec-*ends are the "Near Record" danger tiers — deliberately dark and saturated. - Precipitation —
--dryplus--wet-1…--wet-9(light green → teal → deep navy), with--wet-5the scale midpoint. - Seasons —
--season-winter/-spring/-summer/-fallfor month/season chrome.
The inline-SVG charts (static/chart.js, and the SVG strings built in
app.js) pull from these same tokens, so a chart and its legend never diverge.
Typography
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif.
Inter is not self-hosted or imported — it renders only where the OS has it,
otherwise the stack falls back to system-ui. Do not add a webfont link without
a deliberate decision (it's a network dependency on every page).
- Headings are tight:
h1is 22px withletter-spacing: -0.02em. - Section/eyebrow labels are small, uppercase, muted, and letter-spaced
(~12px,
letter-spacing: .04–.05em,color: var(--muted)). - Body
line-height: 1.45.
Components
Match the existing recipes rather than inventing new ones:
- Panels —
.panel:--surfacebackground,1px solid var(--border),border-radius: 14px, generous padding. The primary content container. - Grade cards —
.normal-card:--surface-2, ~11px radius, the big value tinted by its grade color viacolor-mix(in oklab, …); hover lifts the border to--accent. - Buttons — accent background, dark text,
border-radius: 10px,font-weight: 600. Toggle chips (e.g..today-chip) go outlined → filled when active. - Segmented toggles — unit (°C/°F) and metric switches; the active segment is tinted by that metric's grade color (tmax → warm/hot reds, tmin → cold blues, precip → wet blues).
- Inputs —
--surfacebackground,1px solid var(--border), andfont-size: 16pxminimum — smaller text makes iOS zoom on focus. This is a hard rule, not a preference. - Charts — bespoke inline SVG (percentile fan + median + value trace +
pointer-driven
.chart-tip). No canvas, no charting library. - Map — Leaflet in the shared modal picker (
mappicker.js).
Layout & responsive
Mobile-first: the base stylesheet is the phone layout; wider screens layer on via
min-width queries. Design and test in this order.
- Phone — single column. Primary phone breakpoint is 640/641px; a few tweaks at 560px. Keep ~44px touch targets and use pointer (not mouse-only) events. The mobile header folds into a hamburger menu.
- Large monitors — the content column widens in real steps, it does not
stay a centered 1200px strip:
maingrows to 1440px at 1680px, 1640px at 2400px, 1880px at 3400px (style.css~89–99). Per-page grids (normals, calendar months, day cards) flow into the extra room; charts scale with it. - Both color schemes — dark is the default; light comes from
prefers-color-scheme. Every change must look right in both. - Honor
prefers-reduced-motion: reduce— gate non-essential animation behind it.
Validate every visual change at 390 / 800 / 1920 / 2560 / 3840px in both light
and dark. These viewport widths straddle the breakpoints above (phone, tablet,
1440p, 2K, 4K). Use make shots (below) to capture the full matrix.
Conventions
- Metric order is always
Precip · High · Low— chart legend, normal cards, day rows, exported tables, everywhere. - Grades are relative, never absolute. Use the percentile tier names
("Above Normal", "High", "Near Record"), never absolute-temperature words
("hot", "warm", "cold") — the same reading is Above Normal in a cool climate and
Below Normal in a hot one. This is a percentile against each place's own
climate history, not a thermometer reading (see
thermograph-backend'sdata/grading.py'spct_ordinal(), whichstatic/shared.js'spctOrd()mirrors — seeCLAUDE.md). - The two chart temperature lines are labeled "Daily high / Daily low" so they aren't confused with the "High / Low" percentile tiers.
Viewing & iterating — make shots
There is no design without seeing it rendered. To view the running app across the
full breakpoint matrix, serve this repo's app (see README.md/CLAUDE.md for the
uvicorn invocation) and run the screenshot sweep:
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # if not already set up
.venv/bin/pip install -q -r tools/requirements.txt
.venv/bin/python -m playwright install chromium
.venv/bin/python tools/shoot.py
tools/shoot.py drives headless Chromium over the served app and writes PNGs to
.screenshots/ (gitignored), named {page}@{width}-{scheme}.png (e.g.
index@390-dark.png, city@3840-light.png). Read those PNGs back to see the
result, adjust static/style.css, and re-shoot.
Narrow the matrix while iterating on one thing:
.venv/bin/python tools/shoot.py index --width 390 --scheme dark
Point it at a different server with SHOTS_BASE (default
http://127.0.0.1:8137/thermograph — override to match wherever this repo's
app.py is actually serving, e.g. http://127.0.0.1:8080 for a local run
without THERMOGRAPH_BASE set).