TerraPulse — Palette & Style Audit
Generated audit of the current user-facing site (
web/, Astro 6 + Tailwind 4). Source of truth for design tokens isweb/src/styles/global.css(@themeblock). This document also flags every place the site bypasses the token system (inlinestyle=, hardcoded hex, Tailwind arbitrary[#...]values) so we can drive the codebase toward "free of inline stuff."
1. How styling is wired
| Concern | Mechanism |
|---|---|
| Framework | Astro 6 (output: 'server', Node standalone adapter) |
| CSS engine | Tailwind 4 via @tailwindcss/vite |
| Token definition | @theme { --color-*, --font-* } in web/src/styles/global.css |
| Token consumption | Tailwind utilities auto-generated from theme vars (e.g. text-tp-muted, bg-forest) |
| Markdown rendering | .prose rules hand-written in global.css |
In Tailwind 4 every --color-foo in @theme becomes utilities text-foo, bg-foo,
border-foo, fill-foo, etc. So the palette below is the utility vocabulary.
2. Color tokens
2a. Legacy tp-* aliases — the workhorse set
These are the most-used tokens across the site. They were remapped onto the
"Verdant" palette. (Usage = count of utility occurrences in .astro/.mdx.)
| Token | Hex | Role | Usage |
|---|---|---|---|
tp-green-dark |
#091E1A |
Deep Forest — nav, footer, headings, primary dark | 71 |
tp-green |
#1AAB7A |
Viridian — primary accent, CTAs | 38 |
tp-green-light |
#148A62 |
Evergreen — hover/pressed depth | 3 |
tp-teal |
#22C3D4 |
Cyan Flare — secondary accent | 37 |
tp-blue |
#3B8FD9 |
Arctic Blue — links, info | 4 |
tp-bg |
#EDF5F2 |
Seafoam Mist — light page surface | 22 |
tp-card |
#F6FAF8 |
Frost — elevated card surface | 38 |
tp-text |
#091E1A |
Deep Forest body text | 4 |
tp-muted |
#3D5C53 |
Verdigris — secondary text | 104 |
tp-border |
#DDE9E4 |
Glacier — light borders | 103 |
Pruned (2026-05-29):
tp-danger,tp-success,tp-warninghad zero references (danger is covered bycoral).
2b. Core identity
| Token | Hex | Name |
|---|---|---|
forest |
#091E1A |
Deep Forest — used: text-forest, bg-forest |
seafoam-mist |
#EDF5F2 |
Seafoam Mist — used: text-seafoam-mist |
Pruned:
viridian(=tp-green) andevergreen(=tp-green-light) — unused duplicates of the legacy aliases.
2c. Neutral scale (cool-green undertone)
Light surfaces / muted text. Pruned the unused dark end (abyss, kelp,
lichen); the survivors below are the steps actually referenced.
| Token | Hex |
|---|---|
verdigris |
#3D5C53 |
pewter |
#7A9B90 |
celadon |
#B8D4CA |
glacier |
#DDE9E4 |
frost |
#F6FAF8 |
2d. Semantic / status
| Token | Hex | Meaning |
|---|---|---|
coral |
#D4453A |
danger / error — used: bg-coral, text-coral |
status-live |
#22c55e |
live-feed dot: connected (≈ green-500) |
status-down |
#ef4444 |
live-feed dot: disconnected (≈ red-500) |
The ticker's connection dot (#pulse-dot) toggles between bg-status-live and
bg-status-down (in PulseTicker.astro) — no inline hex.
Pruned:
signal(=tp-success),marigold(=tp-warning),arctic(=tp-blue) — unused.
2e. Data-visualization ramp
Categorical ramp kept as infrastructure for future charts. Currently none are
consumed (the article heroes that briefly used bg-viz-2 now derive their
color from features.ts instead — see the §5 per-article note). All six remain
available as bg-viz-N / text-viz-N utilities when charts need them, and
tree-shake out of the compiled CSS until referenced, so they cost nothing.
| Token | Hex | Name |
|---|---|---|
viz-1 |
#1AAB7A |
Viridian |
viz-2 |
#1B6B9A |
Ocean — article hero bands |
viz-3 |
#22C3D4 |
Cyan Flare |
viz-4 |
#5E8C4A |
Moss |
viz-5 |
#7A7EB5 |
Lavender Steel |
viz-6 |
#8CC4B4 |
Seafoam |
2f. Event / hazard kind colors
One token per live-ticker event kind — single source of truth for
PulseTicker.astro's KIND_STYLES. Change a value here and the ticker follows.
Most mirror a Tailwind palette shade (exact oklch, so no drift); volcano /
lightning / alert are intentional custom hues. In the component: chip bg uses
/40–/60, ring uses /70–/90; the light text tint is a per-kind
readability foreground (a Tailwind light shade), not a brand color.
| Token | Value | Mirrors | Family |
|---|---|---|---|
event-quake |
oklch(70.5% 0.213 47.604) |
orange-500 | Subterranean |
event-volcano |
#FF4F00 |
— (custom: NASA "International Orange") | Subterranean |
event-drought |
oklch(55.5% 0.163 48.998) |
amber-700 | Subterranean |
event-storm |
oklch(62.3% 0.214 259.815) |
blue-500 | Atmospheric |
event-tornado |
oklch(48.8% 0.243 264.376) |
blue-700 | Atmospheric |
event-hail |
oklch(70.7% 0.165 254.624) |
blue-400 | Atmospheric |
event-wind |
oklch(62.3% 0.214 259.815) |
blue-500 | Atmospheric |
event-cyclone |
oklch(60.9% 0.126 221.723) |
cyan-600 | Atmospheric |
event-flood |
oklch(58.8% 0.158 241.966) |
sky-600 | Atmospheric |
event-lightning |
#FFFF00 |
— (custom: pure primary yellow) | Atmospheric |
event-flare |
oklch(76.9% 0.188 70.08) |
amber-500 | Solar |
event-cme |
oklch(79.5% 0.184 86.047) |
yellow-500 | Solar |
event-solar-wind |
oklch(83.7% 0.128 66.29) |
orange-300 | Solar |
event-space-weather |
oklch(55.5% 0.163 48.998) |
amber-700 | Solar |
event-meteor |
oklch(66.7% 0.295 322.15) |
fuchsia-500 | Meteor/impact |
event-fireball |
oklch(74% 0.238 322.16) |
fuchsia-400 | Meteor/impact |
event-forbush |
oklch(51.1% 0.262 276.966) |
indigo-600 | Cosmic |
event-wildfire |
oklch(72.3% 0.219 149.579) |
green-500 | Biosphere |
event-air-quality |
oklch(79.2% 0.209 151.711) |
green-400 | Biosphere |
event-system |
oklch(55.4% 0.046 257.417) |
slate-500 | Fallback |
event-alert |
#DC143C |
— (custom: crimson) | Semantic exception |
Usage example (in KIND_STYLES): bg-event-quake/40 ring-event-quake/70.
2g. Knowledge-graph palette (Data Garden)
Colors for the D3 force graphs on /garden and the lab paper navigator
(/lab/[slug]/navigator) — both consume this one palette. Defined as plain
:root custom properties in global.css (not @theme) — they're read by D3
via getComputedStyle(), and Tailwind tree-shakes any @theme var no utility
references, which would leave the read empty. Edit a value here and a reload
recolors both graphs. Verified color-identical to the prior hardcoded values.
| Token | Hex | Mirrors | Role |
|---|---|---|---|
graph-datasource |
#2563eb |
blue-600 | data-source nodes |
graph-metric |
#d97706 |
amber-600 | metric nodes |
graph-workspace |
#7c3aed |
violet-600 | paper/workspace nodes + cites edges |
graph-finding |
#059669 |
emerald-600 | finding nodes |
verdict-positive |
#059669 |
emerald-600 | positive correlation (= finding) |
verdict-negative |
#dc2626 |
red-600 | anti-correlation |
verdict-null |
#9ca3af |
gray-400 | null result |
graph-accent |
#1B5E4B |
— (brand forest) | buttons, search focus, hover stroke |
Consumed via var(--…) in inline styles / CSS, and via a _tok() reader in the
D3 script. Incidental chrome left as literals (out of palette scope): page bg
#f0f4f3, panel heading #1a1a1a, hover stroke #fff, lab link #4ade80,
PDF link #a855f7.
3. Typography
| Stack | Var | Value |
|---|---|---|
| Sans (body/UI) | --font-sans |
'Montserrat', 'Inter', system-ui, sans-serif |
| Serif (headlines/brand) | --font-serif |
'Libre Baskerville', Georgia, serif |
| Mono (code/data) | --font-mono |
'JetBrains Mono', 'Fira Code', monospace |
Helper classes: .brand-serif / .font-serif apply the serif stack with
letter-spacing: -0.02em for display headings.
Fonts inventory
| Family | Source | Loaded where | Notes |
|---|---|---|---|
| Montserrat (400–800) | Google Fonts | <link> in Base.astro:95 |
Primary UI font |
| JetBrains Mono (400) | Google Fonts | <link> in Base.astro:95 |
Mono/data |
| Libre Baskerville (400/700) | Google Fonts | <link> in Base.astro |
Unified with the other two (was a CSS @import) |
| DSEG7-Classic | Local woff2 |
@font-face in PulseTicker.astro:438 (global) |
Seven-segment ticker timestamps (.pulse-time); also the /dev/ticker-7seg-preview A/B variants |
4. Other global style rules (global.css)
- Focus ring —
:focus-visible→ 2px Viridian outline + 4pxrgba(26,171,122,.2)glow. - Overflow guard —
html, body { overflow-x: hidden; max-width: 100vw }. .prose— full Markdown typography set (h1–h3 serif, links intp-blue, blockquote bordertp-green, code ontp-bg, styled tables, mobile size-downs)..dropdown-menu.force-show— click-toggle override for data dropdowns.
5. Audit — where we bypass the token system
The goal is "free of inline stuff." Current state (after the ticker pass):
| Bypass type | Occurrences | Files | vs. baseline |
|---|---|---|---|
Inline style= attributes |
78 | ~14 | (dynamic — see note) |
Hardcoded hex (#rrggbb) |
72 | ~18 | ↓ from 146 |
Tailwind arbitrary [#...] |
10 | a few | ↓ from 35 |
Inline
style=rose by design (65 → 78): the 13 article heroes now derive their background fromfeatures.tsviafeatureBg(slug)(§2e note) — a dynamic, single-source style, not a hardcoded literal. The remaining inline styles are likewise dynamic: the homepage masthead colors (fromfeatures.ts) and the ticker/incident glyphtransform:scale()(computed per row). None are literals to hunt down. Hardcoded hex / arbitrary[#…]are the real targets, and both are down sharply.
Done — ticker (§2f): the 3 custom ticker hues (
#FF4F00,#FFFF00,#DC143C) and the 15 Tailwind-palette event shades are now--color-event-*tokens.PulseTicker.astroKIND_STYLEScarries nobg-[#…]/ring-[#…]arbitrary values. Verified color-identical in the compiled CSS.Done — garden (§2g): node-type + verdict + accent colors are now
--color-graph-*/--color-verdict-*:roottokens, consumed by the D3 script and inline styles.garden.astrodropped from 34 hardcoded hex to 6 (incidental chrome). Verified in headless Chromium: 812 nodes render with the exact prior colors, zero empty fills.
Worst offenders (hardcoded hex, by count)
| File | Hex count | Also has inline style=? |
|---|---|---|
data/features.ts |
26 | — (intentional per-article theming, see note) |
pages/data/earthquakes.mdx |
10 | yes |
pages/dev/ticker-palette.astro |
7 | (dev) |
pages/dev/ticker-7seg-preview.astro |
7 | (dev) |
pages/garden.astro |
6 | yes (incidental chrome only — palette done, §2g) |
pages/chips.astro |
6 | yes |
pages/lab/[slug]/navigator.astro |
4 | yes (incidental chrome only — palette done, §2g) |
pages/incidents.astro |
0 | colors on brand tokens; only dynamic glyph-scale inline left |
pages/chips.astro |
0 | KIND_STYLES now uses bg-event-* tokens (mirrors ticker); dynamic glyph-scale inline left |
components/PulseTicker.astro |
0 real | fully tokenized (event kinds §2f + status dots) |
article pages (wspr-*, sanriku, fireball, …) |
~30 combined | a few |
features.tsaccentColor/bgColorare per-article hero colors — content data, deliberately bespoke per feature, not UI chrome. Leave as data.
Files with inline style=
FeatureEyebrow.astro, HomepageMasthead.astro, PulseTicker.astro,
articles/cascadia-swarm-apr2026.astro, articles/index.astro,
articles/reno-earthquake-swarm.astro, chips.astro, data/earthquakes.mdx,
garden.astro, incidents.astro (dynamic glyph-scale only), lab.astro,
lab/[slug]/navigator.astro.
Hardcoded colors that already have a token (easy swaps)
| Hardcoded | Token | Status |
|---|---|---|
#22C3D4 (masthead "Pulse") |
tp-teal |
✅ swapped → text-tp-teal |
bg-[#1B6B9A] (3 article heroes) |
viz-2 (Ocean) |
✅ swapped → bg-viz-2 |
#22C3D4, #1B6B9A, #8CC4B4 in features.ts |
viz/tp tokens | ⏭ left — per-article content theming |
#9ca3af in navigator.astro |
verdict-null |
⏭ left — it's the verdict-null semantic, not a neutral gray; belongs with a navigator-graph-palette pass (§6.1) |
#FFFFFF in earthquakes.mdx |
frost? |
⏭ left — Plotly plot_bgcolor is intentionally pure white; frost (#F6FAF8) ≠ white |
Note: the generic "grays → pewter/verdigris" and "white → frost" rows from the first audit didn't survive context review — the only real occurrences are semantic (verdict-null) or genuinely white (chart bg), so swapping would change meaning or be a regression.
Per-article hero colors — ✅ settled (derive from features.ts)
The big category/event clusters are tokenized (ticker §2f, graphs §2g, chips,
status dots). Per-article hero colors are now single-sourced: each article's
hero <section> derives its background from its features.ts entry via
featureBg('<slug>') (helper in data/features.ts), so the hero always matches
the homepage feature card you clicked. This removed 13 hero bg-[#…]/bg-viz
literals and fixed 3 drifts (cascadia → #0E4C6B, wspr-aircraft-detection →
#148A62, wspr-tornado-v2-historical → #2A1428). The accentColor/bgColor
values in features.ts remain deliberate per-article content theming —
that's the one source, by design.
6. Cleanup backlog (suggested order)
Promote event/category colors to tokens✅ Done — ticker event kinds (§2f), garden knowledge-graph palette (§2g), and ticker status dots (§2d).Swap the easy duplicates✅ Done — masthead teal →tp-teal, 3 article ocean heroes →bg-viz-2(§5). Remaining hex are content/semantic/white (left deliberately).navigator.astro's graph palette is now tokenized too (§2g).De-inline✅ Done —incidents.astro(colors → brand tokens, display toggles →hiddenclass) andchips.astro(KIND_STYLES→bg-event-*tokens, now a true mirror of the live ticker). Only dynamic glyph-scale inline left in each.Unify font loading✅ Done — Libre Baskerville moved from CSS@importto theBase.astro<link>block; the Vite@importwarning is gone.Remove or relocate DSEG14✅ Done — deleted the orphanwoff2, its@font-face+.font-14segclass, and the dev-preview "Variant C" that was its only user.Prune unused tokens✅ Done (2026-05-29) — removed 11 zero-reference tokens:tp-danger/success/warning,viridian,evergreen,abyss,kelp,lichen,signal,marigold,arctic. Verified each had no utility orvar()usage before removal; pages render unchanged. Theviz-*categorical ramp was kept intentionally as future-chart infrastructure (§2e).