feat(monitor/frontend): shared Panel shell + KPI + Sparkline + StaleIndicator primitives

The shared shell every operator panel uses. 28px header strip with
UPPERCASE letter-spaced title (--type-label, --fg-2), optional muted
subtitle (--fg-3), StaleIndicator dot, and an external-link "raw"
icon → upstream source. Body fills remaining space and scrolls
internally. Border-bottom 1px --fg-4 separates panels in the column.

Primitives in shared/:
  KPI — value (mono, --type-num-l, tabular-nums, --fg-1) + label
        (UPPERCASE, --type-label, --fg-3). No card wrapper, no
        rounded corner, no background. The number IS the unit.
  Sparkline — d3-scale + d3-array driven inline SVG. Single stroke
        in currentColor, no axes / no fill / no animation. Used
        only when the shape IS the insight (per ethos). Returns
        null with <2 points so empty panels don't ship a flat line
        as a placeholder.
  StaleIndicator — 6px square dot. --ok green (fresh), --amber
        (stale, breached threshold), --fg-4 muted (unknown / no
        data yet). Title attribute carries last update UTC time
        for hover context.

RawLink not extracted yet — Panel inlines the icon since one raw
link per panel is the rule. Extract when a panel needs more.

Adds d3-scale 4.0.2 + d3-array 3.2.4 + their @types — small d3
modules (not the full umbrella) per spec §10.3 to keep bundle lean.

Plan 5 Task 13 Design QA: panel header 28px, title UPPERCASE
letter-spaced, sparkline pure stroke (no axes / fill), stale dot
6px, no border-radius anywhere, no hover scale.
This commit is contained in:
CarterPerez-dev 2026-05-03 07:12:20 -04:00
parent e3af0197b7
commit 44f0ff3c3d
9 changed files with 292 additions and 0 deletions

View File

@ -19,6 +19,8 @@
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.0",
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
@ -34,6 +36,8 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/d3-array": "^3.2.2",
"@types/d3-scale": "^4.0.9",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@ -17,6 +17,12 @@ importers:
axios:
specifier: ^1.13.0
version: 1.13.2
d3-array:
specifier: ^3.2.4
version: 3.2.4
d3-scale:
specifier: ^4.0.2
version: 4.0.2
react:
specifier: ^19.2.1
version: 19.2.1
@ -57,6 +63,12 @@ importers:
'@tanstack/react-query-devtools':
specifier: ^5.91.1
version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)
'@types/d3-array':
specifier: ^3.2.2
version: 3.2.2
'@types/d3-scale':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^24.10.2
version: 24.10.2
@ -572,6 +584,15 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -2264,6 +2285,14 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-time@3.0.4': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}

View File

@ -0,0 +1,60 @@
// ©AngelaMos | 2026
// Panel.module.scss
.panel {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--fg-4);
min-height: 0;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 8px;
background: var(--bg);
border-bottom: 1px solid var(--fg-4);
}
.title {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-2);
}
.subtitle {
color: var(--fg-3);
}
.meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--fg-3);
}
.rawLink {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-3);
&:hover {
color: var(--fg-1);
}
&:focus-visible {
outline: 1px solid var(--fg-2);
outline-offset: 1px;
}
}
.body {
flex: 1;
overflow: auto;
padding: 4px 0;
min-height: 0;
}

View File

@ -0,0 +1,55 @@
// ©AngelaMos | 2026
// Panel.tsx
import type { ReactNode } from 'react'
import { FiExternalLink } from 'react-icons/fi'
import styles from './Panel.module.scss'
import { StaleIndicator } from './shared/StaleIndicator'
export interface PanelProps {
title: string
subtitle?: string
rawHref?: string
rawLabel?: string
isStale?: boolean
lastTickAt?: number
children: ReactNode
}
export function Panel({
title,
subtitle,
rawHref,
rawLabel,
isStale,
lastTickAt,
children,
}: PanelProps): React.ReactElement {
return (
<article className={styles.panel}>
<header className={styles.head}>
<span className={styles.title}>
{title}
{subtitle && <span className={styles.subtitle}> · {subtitle}</span>}
</span>
<span className={styles.meta}>
<StaleIndicator stale={isStale} lastTickAt={lastTickAt} />
{rawHref && (
<a
className={styles.rawLink}
href={rawHref}
target="_blank"
rel="noreferrer noopener"
aria-label={rawLabel ?? 'Raw source'}
>
<FiExternalLink aria-hidden />
</a>
)}
</span>
</header>
<div className={styles.body}>{children}</div>
</article>
)
}
Panel.displayName = 'Panel'

View File

@ -0,0 +1,24 @@
// ©AngelaMos | 2026
// KPI.module.scss
.kpi {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px;
}
.value {
font-family: var(--font-mono);
font-size: var(--type-num-l);
font-variant-numeric: tabular-nums;
color: var(--fg-1);
line-height: 1;
}
.label {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
}

View File

@ -0,0 +1,21 @@
// ©AngelaMos | 2026
// KPI.tsx
import type { ReactNode } from 'react'
import styles from './KPI.module.scss'
export interface KPIProps {
label: string
value: ReactNode
}
export function KPI({ label, value }: KPIProps): React.ReactElement {
return (
<div className={styles.kpi}>
<div className={styles.value}>{value}</div>
<div className={styles.label}>{label}</div>
</div>
)
}
KPI.displayName = 'KPI'

View File

@ -0,0 +1,49 @@
// ©AngelaMos | 2026
// Sparkline.tsx
import { extent } from 'd3-array'
import { scaleLinear } from 'd3-scale'
const DEFAULT_WIDTH = 80
const DEFAULT_HEIGHT = 16
const STROKE_WIDTH = 1
const MIN_DATA_POINTS = 2
export interface SparklineProps {
data: number[]
width?: number
height?: number
}
export function Sparkline({
data,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
}: SparklineProps): React.ReactElement | null {
if (data.length < MIN_DATA_POINTS) return null
const [min, max] = extent(data)
if (min === undefined || max === undefined) return null
const x = scaleLinear()
.domain([0, data.length - 1])
.range([0, width])
const y = scaleLinear().domain([min, max]).range([height, 0])
const path = data
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(v)}`)
.join('')
return (
<svg width={width} height={height} role="img" aria-label="Trend sparkline">
<title>Trend sparkline</title>
<path
d={path}
fill="none"
stroke="currentColor"
strokeWidth={STROKE_WIDTH}
/>
</svg>
)
}
Sparkline.displayName = 'Sparkline'

View File

@ -0,0 +1,21 @@
// ©AngelaMos | 2026
// StaleIndicator.module.scss
.dot {
display: inline-block;
width: 6px;
height: 6px;
flex-shrink: 0;
}
.live {
background: var(--ok);
}
.stale {
background: var(--amber);
}
.unknown {
background: var(--fg-4);
}

View File

@ -0,0 +1,29 @@
// ©AngelaMos | 2026
// StaleIndicator.tsx
import styles from './StaleIndicator.module.scss'
export interface StaleIndicatorProps {
stale?: boolean
lastTickAt?: number
}
export function StaleIndicator({
stale,
lastTickAt,
}: StaleIndicatorProps): React.ReactElement {
const className =
stale === undefined
? `${styles.dot} ${styles.unknown}`
: stale
? `${styles.dot} ${styles.stale}`
: `${styles.dot} ${styles.live}`
const title = lastTickAt
? `Last update ${new Date(lastTickAt).toISOString().slice(11, 19)} UTC`
: undefined
return <span className={className} title={title} aria-hidden />
}
StaleIndicator.displayName = 'StaleIndicator'