Full rebrand across cosmetic branding, code identifiers, and infrastructure/data-plane naming, using the supplied Cairn OBS logo package. Cosmetic: favicon/logo swap (also closes a stale license-audit finding -- the old favicon was SvelteKit's unreplaced scaffold logo), new centered welcome landing page, larger/legible sidebar logo, page titles, CLAUDE.md/README/docs prose. Code identifiers: Go module path github.com/sentry/sentry -> github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc regenerated); Rust crates sentry-agent/sentry-parser/sentry-search -> cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type, env vars); every session/auth cookie name; agent config paths and Windows service identity. Deliberately preserved: the gRPC wire protocol's protobuf packages (sentry.logs.v1, sentry.agent.v1) and their Go import directory (proto/sentry/...) -- renaming the wire-level package would break every currently-deployed agent binary (confirmed two real hosts, including mail.inbuxa.com, are actively streaming through this exact contract) until rebuilt and redeployed in lockstep with an ingest cutover. Only the Go module path wrapping the generated code changes. Infrastructure: every docker-compose container name (root and three component-level compose files); the Helm chart (directory, Chart.yaml, named-template helpers, all templates, values.yaml image repos); Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd package. Caught and fixed real path-coupling bugs along the way: the Helm chart's search/ingest volume mounts and the dev-only-credential detection constant vs. docker-compose.yml's literal values had to move together or a security warning would have silently stopped firing. Data plane: Postgres database sentry_metadata -> cairnobs_metadata and role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups. Source-level defaults, docker-compose.yml, and every migrate.sh/ provision script default updated together; already-applied migration files left untouched per this repo's immutable-migration convention. Verified at every layer: all 13 Go modules build/vet/test clean, both Rust workspaces (agent, search) build/clippy/test clean, npm run check/ build clean, docker compose config validates on all four compose files. Live-verified against a real docker stack multiple times through this work, including a final fresh-volume run confirming the actual renamed Postgres database/role, ClickHouse database, and Kafka topic all work end to end with a real login and query, zero console errors.
20 KiB
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.mdfor 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 <SeverityBadge severity={row.severity} />
(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 <Badge tone="..."> 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 <a> when href is passed, <button> otherwise. |
Input |
Thin styled wrapper over <input>; bind:value, invalid sets aria-invalid + a danger border. |
Select |
Styled wrapper over native <select> (real <option> children) — not a custom listbox, so it keeps native keyboard/screen-reader behavior for free. |
Badge |
Generic status pill. tone: neutral/success/danger/accent. |
SeverityBadge |
severity prop takes a raw OTel string; maps to a tier internally. Use this instead of Badge for anything log-severity-shaped. |
Table |
A styling wrapper around real <table> markup (native semantics matter for screen readers) — pass real <thead>/<tbody> as children. Density-aware via the row tokens above. Not a data grid; sort/resize (Phase 5 task 6) layers on top later. |
Card |
title/actions (a snippet) header, padded toggle. |
Modal |
Built on native <dialog> — real focus trap, Escape-to-close, and top-layer stacking, not hand-rolled. bind:open, title, footer snippet. |
Tooltip |
Pure-CSS hover/focus tooltip, role="tooltip" + aria-describedby. |
Tabs |
Renders the tab list only (roving tabindex, arrow-key nav); the caller renders each panel's content on bind:active and owns id="panel-{id}"/aria-labelledby="tab-{id}". |
CommandPalette.svelte and NavSidebar.svelte
(web/src/lib/components/, not under ui/) are app-shell components,
not general-purpose library pieces — mounted once in +layout.svelte.
Navigation & tenant switching
Persistent sidebar (NavSidebar.svelte): Search / Dashboards / Alerts /
Data Sources / Settings, active-route highlighting, a command-palette
hint, and the theme/density quick toggles. The tenant indicator calls
getCurrentSession() ($lib/api.ts), which wraps POST /internal/authorize — an endpoint that already existed (api/authz. HTTPAuthorizer and alerting already call it) and was already reachable
from the browser (enterprise-auth's whole mux is behind
WithCredentialedCORS, not just the tenant-picker routes), so this
needed zero backend changes.
Known limitation, not yet solved at the API level: there is no
"list my other tenant memberships while already logged in" endpoint —
GET /auth/memberships only works during the short-lived pending-login
window (see enterprise/internal/loginhandler's doc comment), not for
an established session. So "Switch tenant" in the sidebar re-triggers
login (/auth/oidc/login) rather than offering an inline dropdown —
logging in again is the only tenant-switching mechanism this product
actually has today, and if there's more than one membership it
naturally lands back on /select-tenant. A real inline switcher would
need a new GET /auth/my-memberships-shaped endpoint (or equivalent);
that's real, disclosed future work, not a bug in what's built.
Command palette
⌘K/Ctrl+K anywhere in the app. Indexes the five static nav
destinations plus live-fetched dashboards and alert rules
(listDashboards()/listRules(), via Promise.allSettled so one
failing endpoint doesn't blank the other's results or the static items).
Arrow keys + Enter to navigate, Escape to close (native <dialog>,
same reasoning as Modal). Substring filter, not fuzzy — no new
dependency pulled in for it, matching this repo's "boring,
well-understood dependencies" convention.
Charting (web/src/lib/charts/)
Built on ECharts, via modular imports (echarts/core plus only the
chart/component modules actually used — LineChart, BarChart,
HeatmapChart, TooltipComponent, GridComponent, LegendComponent,
DataZoomComponent, VisualMapComponent, MarkLineComponent,
CanvasRenderer), not the full bundle. Picked over Observable Plot (SVG
rendering hits a real performance ceiling at volume, and no built-in
zoom/pan or legend-toggle) and raw D3 (too much hand-rolled engineering
for chart types this standard). Verified, not assumed: the lazy-loaded
chart chunk is 211,975 bytes gzipped, and a synthetic 30,006-row/6-series
stress fixture (/dev/charts, an unlisted dev-only route) renders its
first two frames in ~50ms on a production build — npm run dev's
~3.1s figure for the same fixture is pure dev-mode/unminified-JS
overhead, not a real perf number, and was confirmed as such before being
discarded.
Five chart components, all consuming the same {columns, rows}
QueryResult shape Phase 2's query endpoint has always returned:
TimeSeriesChart (multi-series line, legend toggle), BarChart
(including stacked), SingleStat (big number + sparkline + trend),
Heatmap, TopN (ranked horizontal bars). EChart.svelte is the shared
base wrapper (init/resize/dispose lifecycle).
No query-language change was needed for multi-series or drill-down — both are pure frontend reshaping of the existing tabular output:
pivot.ts'spivot(columns, rows, config)turns a "long" result (one row per series+x pair, e.g.stats count by service, timestamp) into one series per distinct value of the grouping column. Its value-column auto-detection has one sharp edge worth knowing if you touch it: when a result has only one non-x column (a barestats count,single_stat's most common shape),Array.prototype.findIndexreturning-1for "not found" must be checked explicitly —-1 ?? fallbacknever falls back, because-1isn'tnull/undefined. This was a real, shipped bug (everysingle_statpanel silently rendered0) caught by seeding a live dashboard with real query results rather than only fixture data, not by any static check.drilldown.tsstrips a panel's query down to its pre-statsfilter portion, appends a clicked series/x-value as a new filter term, and computes a tight time window around a clicked timestamp — then navigates to the Search page via URL params (?q=&earliest=&latest=).
theme.ts's readChartTokens() reads real computed CSS custom-property
values (getComputedStyle) so charts render in the actual active
theme's colors rather than a hardcoded palette — guarded with an
SSR_FALLBACK object for adapter-static's prerender pass, where
document doesn't exist.
The one backend change in this phase: heatmap as a VizType
(api/dashboards/types.go's validVizType(), web/src/lib/api.ts's
VizType union, and the dashboard_panels table's viz_type CHECK
constraint — three places, not two; the DB constraint mirrors the Go
validator and was originally missed, which meant a heatmap panel passed
API validation and then failed on insert. See
metadata/migrations/0035_add_heatmap_viz_type.sql). Justified under
the brief's "no query-language or data-model changes except what's
strictly needed to feed richer visualizations" exception — a heatmap is
a viz type, not a new query capability.
Dashboard panels
Drag-and-drop grid is GridStack, already a Phase 3 dependency — no
new library needed, satisfying the brief's "a maintained library is
fine, don't hand-roll grid physics." PanelEditor.svelte (a Modal)
replaces the old inline add-panel form: a debounced live preview reuses
PanelViz directly, so what you see while editing is pixel-identical to
what renders on save, not a separate preview renderer that can drift
from the real thing. Empty/loading/error states use EmptyState and
Skeleton rather than a blank panel or a raw error string.
Query & search (web/src/lib/query-editor/)
CodeMirror 6, not a hand-rolled textarea-plus-overlay highlighter —
picked specifically because autocomplete needs real cursor-aware popup
positioning, which a plain textarea can't give you. language.ts is a
StreamLanguage tokenizer for the pipe grammar; its token() function
must return real @lezer/highlight tag names looked up by string
('controlKeyword', 'operatorKeyword', 'name.function' for
tag+modifier pairs) — a custom Tag.define() object's .toString()
looks plausible but silently fails to highlight anything, a real bug hit
and fixed while building this. completions.ts provides context-aware
suggestions: stage keywords after |, stats functions after stats,
field names elsewhere.
ResultsTable.svelte: sortable columns (click header — a real <button>
inside the <th>, not a clickable <th> itself, so it's keyboard-operable
for free), resizable columns (pointer-drag handles, mouse-only by
design — the role="separator" handle is intentionally not in the tab
order, matching how most apps treat column resize as a mouse affordance
rather than a keyboard one), and expandable rows. The row-expand
interaction was originally a bare <tr onclick=...> with no keyboard
equivalent at all — a real gap the accessibility pass caught (not via
axe, which doesn't flag missing keyboard handlers on custom widgets;
caught by manually tabbing through the page), fixed by adding
tabindex="0", role="button", aria-expanded, and an Enter/Space
onkeydown handler alongside the existing click handler.
"Add as panel to dashboard" (AddToDashboardModal.svelte) lets a query
built on the Search page become a saved panel without hand-copying the
query string into the dashboard editor.
Alerting UI
AlertStatePill.svelte reuses the log-severity color tiers rather than
inventing a second color vocabulary: ok → quiet, pending → warn,
firing → critical. DeliveryTimeline.svelte reframes the existing
delivery_log data (no new backend fields) as a vertical timeline —
"why didn't I get paged" is a chronological question, and a flat table
answered it less directly than a timeline does.
Accessibility
Automated checks ran with axe-core, injected into the live app in a real browser and driven against a real seeded docker-compose stack (dashboards with all 6 panel viz types populated with real query results, a firing alert rule with real delivery history) — not just static markup or empty/loading states, since several of the real findings below only appear once real content renders. Real violations found and fixed:
color-contrast:AlertStatePill's critical tier at 4.04:1 (below the 4.5:1 AA threshold) when rendered on an opaque--color-surfacecard, not the plain page background most severity chips happen to sit on — see the Severity tiers section above for why translucent backgrounds made this worse, not better, and why quiet/info (dark) and quiet/warn/error (light) were fixed alongside it as the same latent bug, not yet triggered elsewhere only because no page had rendered those specific tiers on an opaque surface yet.landmark-main-is-top-level/landmark-no-duplicate-main/landmark-unique: the layout shell's content wrapper was a second<main>nested around each page's own top-level<main>.landmark-one-main/region:/data-sourceswas the one page using a plain<div>instead of<main>as its top-level element.heading-order:CardandEmptyStateboth jumped from<h1>to<h3>, skipping<h2>.aria-input-field-name: the CodeMirror query editor's.cm-contenthad no accessible name — fixed viaEditorView.contentAttributes.of({ 'aria-label': ... }).empty-table-header: the alerts list table's trailing actions column had a bare<th></th>— fixed with visually-hidden text (.sr-only, now a shared utility inapp.css) rather than a visible "Actions" label that would've added a fifth, less-necessary heading.
Keyboard-only operation was verified by tabbing through, not just
inferred from markup: sidebar nav → command-palette hint → theme/density
controls → into main content, with a visible focus ring
(--focus-ring, a two-layer box-shadow so it's visible on both light
and dark surfaces) at every stop; the query editor is reachable and
labeled; sort buttons are real <button>s; the results table's row
expansion works via Enter/Space after the fix above. Responsive:
the sidebar collapses to an off-canvas drawer under 860px
(NavSidebar.svelte's mobileOpen/onCloseMobile, a transform-based
slide-in with a backdrop button), verified down to tablet-landscape
width, not just described in CSS.