# Cairn OBS Design System (Phase 5)
Direction: **Signal** — cockpit/ICU-monitor instrumentation logic. Color
is a rationed resource: the UI is near-neutral grayscale everywhere, so
the four severity tiers land as genuinely the most saturated colors
anywhere on screen, not one saturated color competing with a dozen
decorative ones. See the Phase 5 design-direction review (three options
were presented; Signal was the one picked) for the two rejected
directions and the full rationale.
This doc is the source of truth for the token system and component
library `/web/src/lib/components/ui` implements — read it before adding
a new color, spacing value, or component, rather than reaching for a
literal hex or one-off markup the way Phase 0-3's pages did.
## Status
**Status: Phase 5 shipped.** All ten tasks are built: the token system,
the `ui/` component library, the persistent sidebar nav + command
palette, a real ECharts-based charting layer (5 chart types, drill-down,
zoom), the dashboard panel rebuild (GridStack drag-and-drop + live-preview
panel editor), the query/search redesign (CodeMirror syntax highlighting
+ autocomplete, sortable/resizable/expandable results), the alerting UI
redesign (severity-state pill + delivery timeline), and an accessibility
pass driven by real axe-core runs against the live app (not static
analysis) — see the Accessibility section below for what that actually
caught. See `/docs/phase-5-runbook.md` for the full verification log.
Verified in a real browser against a live docker-compose stack with real
seeded data (not just fixtures): dark/light/system theme and
comfortable/compact density switching persisting across navigation and
reload; command palette open/filter/keyboard-navigate/go-to; every nav
route including dashboard/alert detail pages with real panels and real
alert history; all 6 panel viz types rendering real query results; a
firing alert rule's full state-history timeline; keyboard-only operation
of the query editor, results table (sort/expand), and dashboard grid.
## Fonts
Self-hosted (not a Google Fonts CDN link — no runtime dependency on a
third party for the app to render correctly): `web/static/fonts/`,
licensed under the SIL Open Font License (see that directory's
`LICENSE.txt`).
| Role | Typeface | Why |
|---|---|---|
| UI (nav, labels, headings, body) | Overpass | Originally drawn for U.S. highway signage — engineered to be read correctly, fast, under bad conditions. A more honest reason to pick a typeface for an incident-response tool than "it looks modern." |
| Data (log tables, code, query bar, numbers) | Overpass Mono | Same family as the UI face — one typeface end to end removes even the small cognitive cost of a font pairing, matching the direction's overall restraint. |
Both are variable fonts (one file covers the full weight range), loaded
via `@font-face` in `web/src/lib/styles/fonts.css`.
## Color tokens
Defined in `web/src/lib/styles/tokens.css`. Dark is the literal default
— `:root` defines the dark palette directly, light is the override
(both `@media (prefers-color-scheme: light)` for an unset preference and
`[data-theme="light"]` for an explicit one) — not a retrofit where light
is `:root` and dark is bolted on. Component CSS must only ever read a
token, never a literal hex; that's what makes the theme/density toggles
a token swap instead of a per-component rewrite.
| Token | Dark | Light | Use |
|---|---|---|---|
| `--color-bg` | `#0a0a0b` | `#f7f7f8` | Page background |
| `--color-surface` | `#17181a` | `#ffffff` | Cards, tables, inputs |
| `--color-surface-raised` | `#1e2023` | `#ffffff` | Hover states, popovers |
| `--color-border` / `--color-border-strong` | `#2a2c2f` / `#3a3d41` | `#dfe0e2` / `#c7c9cc` | Dividers, input borders |
| `--color-text` / `--color-text-muted` / `--color-text-faint` | `#f0f0f1` / `#85888d` / `#8a8d92` | `#101113` / `#6b6e73` / `#75787d` | Body text hierarchy |
| `--color-accent` / `--color-accent-strong` | `#3fb6ff` | `#0b84d6` | Interactive elements only — links, primary buttons, focus rings, active nav. Never reused for severity (see below); a semantic color competing with the brand accent defeats the point of "color means something." |
### Severity tiers
The schema carries seven OTel severities
(`TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR`/`FATAL`/`UNSPECIFIED`, see
`/storage/README.md`). Seven colors would be seven things to memorize at
a glance; `web/src/lib/severity.ts`'s `severityTier()` collapses them to
four accent tiers plus one "quiet" state:
| OTel severity | Tier | Dark | Light |
|---|---|---|---|
| `TRACE`, `DEBUG`, `UNSPECIFIED` | quiet | `#85888d` | `#6b6e73` |
| `INFO` | info | `#4c8dff` | `#1a63d6` |
| `WARN` | warn | `#f5c242` | `#8c6800` |
| `ERROR` | error | `#ff6a39` | `#c94b1e` |
| `FATAL` | critical | `#ff2d78` | `#c21362` |
Chosen as a blue → amber → orange → magenta progression — hue *and*
lightness both shift at every step, so no two adjacent tiers rely on
red-vs-green to be told apart, and the sequence should survive grayscale
and protanopia/deuteranopia simulation. That reasoning hasn't been
verified with an actual simulator yet — do that before treating it as
confirmed accessible, not just plausible.
Each tier also has a translucent `-bg` token (e.g. `--color-sev-warn-bg`)
for chip/pill backgrounds. These are tuned independently of the solid
foreground colors above, not derived from them by a fixed formula: a
translucent color tints *toward* its own hue as alpha increases, which
for these saturated, low-luminance hues (critical's magenta especially)
*lowers* contrast against the foreground text the higher the alpha goes
— the opposite of the intuitive "more opaque background is safer"
assumption. `--color-sev-warn` itself was darkened in light mode
(`#9c7300` → `#8c6800`) for the same underlying reason: axe-core caught
the *plain* light-mode warn text failing AA (4.32:1) against white
before any background was even involved. See the Accessibility section.
Use ``
(`ui/SeverityBadge.svelte`) to render one of these — it owns the OTel
string → tier mapping so call sites can't invent a sixth color by hand.
For non-log-severity status (success/danger/neutral/accent — e.g. a
"saved" confirmation), use `` instead; the two are
deliberately separate components so a form-validation color can never
accidentally collide with a log-severity one.
## Type scale
14px base (dense-first — this is a tool for reading log tables, not
marketing copy), 1.2 modular ratio:
`--text-xs` 11px · `--text-sm` 13px · `--text-base` 14px · `--text-md`
16px · `--text-lg` 20px · `--text-xl` 28px · `--text-2xl` 40px
`--font-weight-normal` 400, `--font-weight-medium` 600,
`--font-weight-bold` 700. `font-variant-numeric: tabular-nums` is set
globally on `body` so columns of numbers/timestamps align.
## Spacing, radius, shadow
8px-based spacing scale: `--space-1` through `--space-8` (4px, 8px,
12px, 16px, 24px, 32px, 48px, 64px). Radius: `--radius-sm` 4px,
`--radius-md` 6px, `--radius-lg` 10px, `--radius-full` (pills). Shadow
is used sparingly, matching Signal's restraint — only real overlays
(modals, the command palette, tooltips) get one (`--shadow-sm/md/lg`);
inline UI never does.
## Density
Two presets, one token swap — `html.density-compact` overrides
`--row-height`, `--row-padding-y/x`, `--panel-padding`, `--control-height`,
and drops `--text-base` to `--text-sm`. Comfortable (the default) suits
dashboards and forms; compact suits log tables and query results. Toggle
via `$lib/density.svelte.ts`'s `setDensity()`/`toggleDensity()` — a
global, persisted (`localStorage['cairnobs.density']`) preference, not a
per-page setting, so switching it on one page carries to the next.
`web/src/app.html` has a synchronous inline script that applies the
stored value before first paint, so there's no flash of the wrong
density on reload; keep that script's storage key/values in sync with
`density.svelte.ts` if either changes.
## Theme
`$lib/theme.svelte.ts`, same persisted/synchronous-apply shape as
density. Three states — `'dark' | 'light' | 'system'` — but unlike most
apps, the *unset* default is `'dark'`, not `'system'`. That's the actual
point of "real dark mode as the default, not an afterthought": a
first-time visitor on a light-OS machine still lands in dark. `'system'`
is available as a deliberate opt-in for anyone who wants their OS
setting to win instead.
## Components (`web/src/lib/components/ui/`)
Import from the barrel: `import { Button, Input, ... } from
'$lib/components/ui';`
| Component | Notes |
|---|---|
| `Button` | `variant`: `primary`/`secondary`/`ghost`/`danger`. `size`: `sm`/`md`. Renders `` when `href` is passed, `