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:
parent
e3af0197b7
commit
44f0ff3c3d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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': {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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'
|
||||
Loading…
Reference in New Issue