Decisions

ADR-066: Microvisualisations — sparklines + timeline strips

The Platform UI is converging on two recurring visualisation shapes that are neither full charts nor single scalars. They appear in KPI tiles, alert rows, deploy lists, project pages, and — after ADR-067 — at the end of…

#066

ADR-066: Microvisualisations — sparklines + timeline strips

Status: Accepted Date: 2026-04-23 Extends: ADR-062 (design-system-and-component-strategy), ADR-065 (persona-aware-ia-and-niceties), ADR-067 (playbooks-unified-primitive)

Context

The Platform UI is converging on two recurring visualisation shapes that are neither full charts nor single scalars. They appear in KPI tiles, alert rows, deploy lists, project pages, and — after ADR-067 — at the end of every playbook run's report.

Shape one: a value over time, rendered thin enough to sit next to the number it describes. The reader's next question when they see "open PRs: 12" is up or down? A 24px-tall line beside the number answers that question without a page load, without a chart, without an axis.

Shape two: a sequence of discrete events over time, rendered as a horizontal strip with coloured pins. The reader's next question when they see "deploys this week: 7" is when, and were any incident-shaped? A 24px-tall strip with seven coloured dots between two anchors answers that without opening a Grafana dashboard.

Today each surface invents its own. ai-frontend has an inline <svg> sparkline in the KpiCard delta chip, a different inline <svg> strip on the deploy timeline row, and a third improvised set of dots on the /ops alert list. ai-research-dev's experiment pages draw their own. None of them agree on:

  • pixel height
  • colour vocabulary (traffic-light vs single-accent)
  • axis treatment (gridlines? dots? latest-marker?)
  • motion budget (some loop, some spin on every prop change)
  • data contract (some take raw samples, some take pre-bucketed arrays, some fetch their own data client-side)

Three adjacent decisions force the issue now, not later:

  1. ADR-062 shipped the Web Components library contract. Every new visual primitive lands there or it doesn't land at all. A sparkline re-invented per-page is a sparkline that cannot join gyrum-ui.
  2. ADR-065 introduced persona-aware homes (/me, /ops, /team), each of which renders ~8-12 KPI tiles and at least one event-timeline. Four persona pages × "draw it again per page" is four-way drift.
  3. ADR-067 made every screen action a playbook run, and every run produces a report. The report's job is to summarise what the scalar metrics did and what discrete events occurred during the run. Sparklines and timeline strips are not chart ornaments — they are the report-rendering syntax for exactly those two shapes. The third rendering, on dashboards, shows the same two shapes aggregated across many runs. Same two primitives; two consumption modes.

We need one sparkline primitive and one timeline-strip primitive, with visual rules tight enough that a sparkline drawn in a playbook report looks identical to the same sparkline drawn on /ops, and strict enough that neither primitive can drift into being a real chart.

Why not use the existing charting library?

ai-frontend already has <gy-fleet-donut> and <gy-bar-chart> (ADR-062 Layer 2). Those are full charts — they have axes, legends, titles, tooltips, and a typical footprint of 240-480px tall. Pushing a full chart into a 24px-tall slot produces an illegible chart. The two primitives below are deliberately not charts — they carry one signal each and reject any affordance that implies they carry more.

Why two primitives, not one?

A sparkline answers "trend direction for a continuous scalar". A timeline strip answers "what happened, and when, in this window". These are different questions with different data shapes — scalar samples vs discrete events — and different failure modes when misused. One combined primitive with a mode="scalar|events" flag was considered and rejected in Alternatives: the branch would double the component's surface area and invite the "let me render a heatmap with it" mission creep.

Decision

Two primitives ship in gyrum-ui (ADR-062's Layer 2), with matching Svelte adapters in @gyrum-labs/svelte (Layer 3):

  • <gy-sparkline> — a scalar over time.
  • <gy-timeline-strip> — discrete events over time.

No third primitive in this ADR. Additions require a successor ADR.

1. Visual rules (non-negotiable)

These are the contract. A PR that ships either primitive without these rules is a PR that fails ADR-062's visual-regression gate before it fails review.

1.1 Sparkline

Rule Value Rationale
Default height 24px Fits inside a KPI tile's delta row; legible at laptop DPI; matches the t-3 (16px) + t-1 (4px) baseline spacing.
Allowed heights 16px, 24px, 32px 16 for inline-text use (next to a scalar on one line); 32 for playbook-report hero use. Nothing else.
Width fluid, container-driven The parent tile decides; the primitive fills it. No hardcoded widths.
Axes none No x-axis, no y-axis. If the reader needs axes they are looking at the wrong primitive.
Gridlines none Ditto.
Per-sample dots none A dot per sample reads as a dashed line; samples are implicit.
Line single, stroke-width="1.5", stroke-linecap="round" Matches visual-language.md §1.2's icon stroke convention, thinned by 0.5px because the sparkline is ornamental next to a scalar.
Latest-marker optional, off by default When on, a single r="1.5" dot at the last sample, same token colour. On when the primitive stands alone; off when it sits beside the scalar it describes (the scalar is the latest value).
Colour one token, currentColor inheriting Inherits from the surrounding mode tint. Never a palette.
Area-fill optional, off by default When on, same token at opacity: 0.12. Used when the sparkline is the tile's primary visual, not when it is ornamental.
Baseline implicit min/max The primitive auto-scales to the sample window. No zero-baseline; a sparkline that looks flat because its data sits between 10,000 and 10,050 on a zero baseline is a sparkline that lies about the trend.

1.2 Timeline strip

Rule Value Rationale
Default height 24px Same contract as sparkline; a sparkline and a timeline strip sit on the same visual shelf.
Allowed heights 24px only 16 is illegible for pinned events; 32 invites rendering labels inline (which is a chart, not a strip). One height.
Width fluid, container-driven As above.
Axis one horizontal line, 1px, currentColor at opacity: 0.3 The only rule line in either primitive. It anchors the eye.
Anchors exactly two, start + end Left anchor shows window start (e.g. "7d ago"); right anchor shows window end ("now"). No midpoint ticks; no hour labels. Anchors are t-0 style, opacity: 0.6, sans-serif.
Event pins 6-8px diameter, circular Smaller than 6px is a dust speck; larger than 8px reads as a chart marker.
Pin colour severity token, from a fixed set status-up (green), status-warning (amber), status-down (red), status-info (slate-accent). Four tokens. No traffic-light explosions.
Pin overlap cluster when events share a pixel-column Two events at the same x become one pin with a 2 badge; three or more become a pin with "N". No stacked dots.
Hover full event detail popover Title, timestamp, severity, one-line summary, link to source. Popover is out of scope for the primitive (a slot) but required at the consumer.
Density limit ~1 event per hour maximum, at window width Beyond that, pins merge into a bar and the reader learns nothing. See §2.2.

1.3 Motion (both primitives)

  • Mount animation only. A sparkline "draws in" left-to-right over 0.24s cubic-bezier(0.4, 0, 0.2, 1) on first paint. A timeline strip fades its pins in staggered over the same budget. Matches visual-language.md §2.3's attention curve.
  • No looping. No pulse, no marching-ants, no scrubber. A looping microvisualisation is a microvisualisation that demands attention after it has given its signal.
  • Data updates re-mount without animation. Changing the data array swaps the paint in place. This avoids the "every poll repaints a draw-in animation" failure mode that a reader on /ops would notice within a minute.
  • Reduced motion wraps everything. @media (prefers-reduced-motion: no-preference) gates both the mount animation and the hover transition. Under reduced-motion, both primitives paint their final state immediately.

1.4 Accessibility

  • Both primitives are role="img" with an aria-label that renders the signal in prose. A sparkline's label is "Open PRs, 7 days: trending up from 8 to 12, peak 14". A timeline strip's label is "7 deploys in last 7 days, 1 warning, 0 incidents".
  • The label is generated by the adapter from the data array; the consumer may override with an explicit aria-label prop.
  • Hover detail for timeline strip is keyboard-reachable — focus traverses pins left-to-right with arrow keys; Enter opens the popover slot.

2. When to use / when not to

2.1 Sparkline

Use when:

  • The reader's next question after seeing a scalar is up or down, roughly? — "open PRs", "p50 latency last hour", "merged commits this week".
  • The trend is readable at 24px. If the data has so much high-frequency noise that 24px swallows the signal (millisecond latencies with outliers, per-request series), the sparkline is lying.
  • A single series, one colour.

Do not use when:

  • The reader needs to compare two or more series. That is a real chart, on its own page. A sparkline overlay of two series is a sparkline that has quietly turned into a chart without the axes that would let the reader read it.
  • The data is categorical, not continuous. Counts of PR statuses per day are categorical; use a bar chart.
  • The trend is not readable at 24px. Prefer a timeline strip if the signal is "did anything happen?"; prefer a real chart if the signal is "show me the distribution".

2.2 Timeline strip

Use when:

  • The rendered shape is "discrete events over a bounded window" — deploys, alerts fired, incidents, PR merges, playbook runs, experiment runs.
  • Event density is below ~1 per hour at the chosen window. A 7-day window with 7 deploys is legible; a 1-hour window with 200 request-level events is not a timeline, it is an illegible bar.
  • Severity is a small fixed set (the four status tokens in §1.2).

Do not use when:

  • Events carry richer metadata that the reader needs inline. A timeline strip with labels next to pins is a Gantt chart in disguise; use a real timeline component (ADR-062's <gy-timeline> — the full one, not the strip).
  • The events form a continuous stream (log lines, request traces). Use a log viewer.
  • There are more than one severity axis. Two overlapping pin colours from two event types on one strip invite misreading; split into two strips stacked vertically.

2.3 The shared rule: never compare more than one series

Neither primitive accepts multiple series. A KPI page that wants "this week vs last week" overlays two full charts, not two sparklines. A timeline strip that wants "deploys vs alerts" stacks two strips, does not overlay them. This is the line that keeps both primitives honest as microvisualisations rather than compact charts.

3. Data contract

Both primitives take pre-computed arrays. They are render-only; they do not fetch, aggregate, or re-bucket.

Sparkline shape:

<gy-sparkline values={[8, 9, 11, 10, 12, 14, 12]} />

An array of scalars, ordered oldest-to-newest. Length is the sample count; the primitive picks sample-per-pixel spacing within its fluid width. Irregular sample intervals are caller-flattened — if samples are every 15 minutes, the caller passes them already aligned; the primitive is not a time-series database.

Timeline strip shape:

<gy-timeline-strip
  start={2026-04-16T00:00:00Z}
  end={2026-04-23T00:00:00Z}
  events={[
    { at: '2026-04-17T10:14:00Z', severity: 'info', title: 'deploy 1f2a' },
    { at: '2026-04-19T22:02:00Z', severity: 'warning', title: 'alert: cpu' },
    ...
  ]}
/>

start and end are ISO timestamps; events sit between them. Events outside the window are silently dropped (not clipped — if a caller passes out-of-window events they have a bug to fix upstream). severity is one of the four status tokens.

4. Data-source options (ADR punts the pick)

Both primitives are render-only; the source of those arrays is a separate and unresolved question. Five options, each with real trade-offs. This ADR lists them; a follow-up decision — likely per-surface — picks between them.

Option What Pros Cons
A. Prometheus /metrics rollups Query Prometheus for a pre-computed rollup (e.g. rate() over 5-min buckets, 7d window) and pass samples straight into <gy-sparkline>. Cheap; Prometheus already runs; no new schema. Lossy — Prometheus discards samples past retention. Event-shape data (deploys, PR merges) is not a Prometheus citizen; forcing it in as a gauge is a smell.
B. Postgres time-bucketed events A Postgres table per event class (deploys, alerts, PR merges) with a bucket(time) function; the backend returns aggregated arrays. Precise; survives retention; joins well with catalog / project data. Expensive to write and maintain; every new event class is a migration; risks becoming a bespoke time-series database.
C. Event-bus streaming A Kafka/NATS/Redis-streams pipeline pushes events into a materialised view; the backend reads from the view. Scales horizontally; supports real-time. Not built. Pulling in a broker to back a 24px-tall widget is the textbook example of infrastructure exceeding the problem.
D. On-demand from external APIs The frontend calls GitHub/Alertmanager/etc directly (via proxy) per render. Zero backend state; always fresh. Rate-limit risk — every /ops page view is N+1 upstream calls; every mounted sparkline on a dashboard multiplies that.
E. playbook_run_events table (gated on ADR-068) ADR-067 committed every screen action to a playbook run; ADR-068 (not yet landed at 2026-04-23) is expected to introduce a playbook_run_events table recording each run's start/end + per-step events. Sparklines and timeline strips over that table are nearly free — the data is written as a side-effect of the primitive we already decided to build. Free source for run-aggregate views; composes naturally with the report-rendering role (§7); typed by kind / persona / owner from ADR-067's schema. Gated — does not help until ADR-068 lands. Does not cover non-playbook event classes (raw Prometheus metrics, git pushes) so A/B/D remain needed alongside it.

Option E is the most interesting one in light of ADR-067. A playbook-run's report already has the data in hand (it ran the steps); a dashboard of runs has the data in the table (ADR-068 territory). The report case does not need options A-D at all — the data is the run. The dashboard case still needs A or B for metrics that are not playbook-bound.

This ADR does not pick. The picks land per-surface:

  • Playbook report — option E (once ADR-068 lands) is the obvious default; until then, the report renders whatever its runner logged into its own run-report.json.
  • /ops alert timeline strip — option A (Alertmanager's own timeseries surfaces via Prometheus) is the low-friction first cut.
  • /me "merged PRs this week" sparkline — option D against the GitHub proxy for v0, with an escape to option B if rate-limit pain materialises.
  • Other surfaces decide as they hit them, documenting the pick in the surface's follow-up PR.

5. Time-horizon rule (window is a journey decision)

The time window a sparkline or timeline strip covers is not a visual decision. A primitive that takes a window="7d" prop and renders differently would be a primitive that encodes product knowledge.

Windows come from the journey (ADR-067 kind: journey; today, ai-frontend/docs/journeys/). The SRE-first-five-minutes journey wants last hour because the paged alert is recent. The manager's weekly readout wants 7 days. The owner's quarterly scan wants 90 days. Each journey documents its window; the caller of the primitive passes whatever the journey requires.

Rule: every call site cites the journey that justifies its window, in a comment beside the prop:

<!-- window per journeys:sre-first-five-minutes — last hour -->
<gy-sparkline values={lastHour.values} />

This keeps the window decision legible and reviewable at the call site, and keeps gyrum-ui free of window opinions.

6. Library placement

Both primitives ship in gyrum-ui (ADR-062 Layer 2) as Web Components. Package placement:

Primitive Canonical Web Component Svelte adapter
Sparkline gyrum-ui/packages/components/sparkline/<gy-sparkline> @gyrum-labs/svelte/Sparkline.svelte
Timeline strip gyrum-ui/packages/components/timeline-strip/<gy-timeline-strip> @gyrum-labs/svelte/TimelineStrip.svelte

Scope of this ADR is the primitives, not their wiring. The PR that lands this ADR also lands:

  • Storybook stories with synthetic data only (no API wiring).
  • Visual-regression baselines (per ADR-062 §Testing) for both primitives, at 16px/24px/32px where the primitive supports each, across the 5 mode palettes.
  • An a11y test (axe-core) asserting role="img" + aria-label generation.
  • No wiring in ai-frontend. A follow-up PR replaces the existing inline KpiCard sparkline with the adapter.

The private-vs-public distinction: gyrum-ui is currently private (pre v1). When it goes public (per ADR-062's open-source path), these two primitives graduate with it; no re-licensing, no contract change. Nothing in this ADR hinges on the publicness of the library.

7. Report-rendering role

ADR-067 established that every screen action is a playbook run and that every run produces a report. A report's job is to answer:

  1. Did the run complete? — scalar outcome.
  2. What did its metrics do during the run? — scalar-over-time.
  3. What discrete events occurred during the run? — events-over-time.

Questions 2 and 3 are exactly the two shapes this ADR standardises. The sparkline is the standard syntax for question 2; the timeline strip is the standard syntax for question 3.

Concretely — and contingent on ADR-068 / ADR-070 landing the <PlaybookReport> component itself — the intended report shape is:

┌─ Run: sre-first-five-minutes — 2026-04-23 14:02 ─────────┐
│ Outcome: acknowledged in 3m 42s                          │
│                                                          │
│ p50 latency  ━━━━━━━━━━━╱━╲━━━  ← gy-sparkline          │
│ Events       ○────●─●──────○──  ← gy-timeline-strip      │
│ Start 14:02           End 14:06                          │
└──────────────────────────────────────────────────────────┘

The report component is ADR-068/070's problem; the syntax it uses to render those two rows is this ADR's. Dashboards that show aggregate runs use the same two primitives against aggregated arrays — same primitives, different data array.

This is the load-bearing framing: sparklines and timeline strips are not dashboard ornaments. They are the report's compact grammar, and the dashboard reuse is a consequence, not the primary purpose.

Consequences

Easier.

  • One sparkline and one timeline strip across the fleet. Call sites are one tag, not ten lines of inline SVG.
  • Playbook reports (ADR-068/070) have their two compact primitives waiting when they land.
  • Visual drift between /ops, /me, /team, and playbook reports is structurally impossible — the DOM is the same component.
  • Mode-tinting passes through for free (CSS custom properties pierce shadow DOM per ADR-062 §Web Components).
  • Adding either primitive to a new surface is a one-line import.

Harder.

  • Two more components to maintain in gyrum-ui — each with visual baselines across 5 modes × 3 sizes (sparkline) or 5 modes × 1 size (strip), plus the a11y and cross-browser matrices.
  • The "no comparing multiple series" rule will be pushed against. Someone will ask for a two-series sparkline within a month. Holding the line keeps the primitive honest; losing it collapses the distinction with the full chart library.
  • Synthetic-data Storybook only means no live-wired example exists until a follow-up PR. Reviewers have to imagine the mounted context. Acceptable trade-off for shipping the primitive separately from its wiring.
  • The data-source decision is punted. Each surface will re-litigate which of A-E fits until a pattern settles. We accept this — a fleet-wide data-source decision before use cases have shaped it would be premature.

What we sign up to maintain.

  • The visual rules in §1 are contract, not guideline. A new height, a new colour rule, a new pin shape is an ADR amendment, not a PR.
  • The "no comparing >1 series" rule holds across both primitives.
  • The mount-only animation budget. Looping motion in either primitive is a regression.
  • The severity-token vocabulary (§1.2). Adding a fifth severity is a design-tokens change (ADR-062 Layer 1), not a per-primitive change.
  • The journey-cites-window convention. Comments at call sites are reviewable; undocumented windows are not.

Alternatives considered

  • One combined <gy-microviz> with mode="scalar|events". Rejected. The branch would double the component's surface area (two data contracts, two accessibility labels, two sets of visual rules) and invite mode="heatmap", mode="histogram" mission creep within a quarter. Two primitives, one role each.
  • Use <gy-bar-chart> at 24px height. Rejected. The full chart carries axes, legends, and tooltips the strip doesn't want; pushing it into a 24px slot produces an illegible chart whose affordances promise a chart the reader can't read.
  • Allow multiple series on the sparkline (with up to 3 distinguishable lines). Rejected. Three lines at 24px is a tangle; the reader cannot identify which line is which without a legend, and a legend is what separates a sparkline from a chart. If the question is "compare", promote to a real chart on a real page.
  • Allow labels on timeline-strip pins. Rejected. Labels beside pins is a Gantt; the full <gy-timeline> (ADR-062 Layer 2) is that component. The strip's job is density — "how many, where, how severe?" — not identity.
  • Ship primitives in ai-frontend first, graduate to gyrum-ui later. Rejected. That is the exact failure mode ADR-062 wrote itself against: the KpiCard inline sparkline is already three near-identical copies because nobody graduated it. Ship in gyrum-ui from day one, consume via the adapter.
  • Pick a data source (A-E) in this ADR. Rejected. Premature. The render contract is stable enough to fix now; the sourcing contract is not, and will be shaped by the first 3-4 call sites. Picking in this ADR would bake an early guess into schema.
  • Make window a prop on both primitives, with a fixed vocabulary (1h | 24h | 7d | 30d | 90d). Rejected — §5. Window is a journey decision; encoding a vocabulary in the primitive pushes product knowledge into gyrum-ui and forces every journey to argue within those five buckets.
  • Allow a zero-baseline toggle on the sparkline. Rejected. Auto-scaling to the sample window is the honest default; a zero-baseline sparkline of data that sits between 10,000 and 10,050 reads as flat, hiding the trend the primitive exists to surface. If a caller genuinely needs zero-baselined continuous data they want a bar chart.

References

  • ADR-062 — design system + component strategy; this ADR extends Layer 2 (Web Components) with two new primitives, Layer 3 (Svelte adapters) with two wrappers.
  • ADR-065 — persona-aware IA + niceties; /me, /ops, /team are the first consumer surfaces and provide the motion and reduced-motion conventions the primitives inherit.
  • ADR-067 — playbooks as a unified primitive; establishes the report-rendering role (§7). Sparklines and timeline strips are the compact syntax for a report's scalar-over-time and events-over-time rows.
  • ADR-068 (expected, not landed at 2026-04-23)<PlaybookReport> component + playbook_run_events table; this ADR references ADR-068 as the gate on data-source option E and as the consumer of the report-rendering role.
  • ADR-070 (placeholder) — follow-up on the dashboard aggregate view across playbook runs; uses the same primitives with aggregated arrays.
  • ai-frontend/docs/visual-language.md §1.2, §2.3 — icon stroke contract and attention-curve motion budget the primitives inherit.
  • ai-frontend/docs/journeys/ (in flight at 2026-04-23 per ADR-067 §7a) — the source of truth for time-horizon decisions (§5).
  • src/lib/styles/mode-tokens.css, src/app.css — the token surface both primitives draw from via currentColor + CSS custom properties.

Review cadence (for this ADR)

Re-review every 90 days (next: 2026-07-22) against:

  • Call-site count per primitive — is either primitive unused (a sign its rules are too tight), or is one being stretched into chart-territory (a sign its rules are leaking)?
  • Visual-regression baseline churn — are baselines being rebaselined per PR (drift) or held across PRs (stable)?
  • Data-source pattern — has one of options A-E clearly won across 3+ surfaces? If so, promote to a default; if not, the punt stands.
  • The "no comparing >1 series" rule — has it been pushed against in review? If yes, record the ask in the ADR's gaps and hold; if the ask accumulates, reconsider in a successor.
  • ADR-068 status — has it landed? If yes, update §4 option E from gated to default for run-aggregate surfaces.

Stale flag on the Owner dashboard once last_reviewed on this ADR passes 90 days (per ADR-067 §11).


Supersedes: none Superseded by: leave blank until a later ADR reverses this one