Files
sencho/frontend/src/components/fleet/FleetMasthead.tsx
T
Anso 8d9e6574cc feat(appearance): add Calm/Signature visual style, readability mode, and chart palette (#1407)
* feat(appearance): add Calm/Signature visual style, readability mode, and chart palette

Turn the "too intense / italic headers hurt / the security graph fights my
eyes" feedback into a token-driven Visual style with Calm as the new default
and Signature one click back to the prior look.

- Heading family routes through a `.font-heading` utility driven by
  `--font-heading`/`--heading-style`: operational headings render upright in the
  interface face under Calm and italic Instrument Serif under Signature. Base
  rule sets family + style only, so each call site keeps its own weight/tracking
  and Signature stays a true no-op; the Calm lift is a `[data-headings="clean"]`
  descendant rule. Brand lockup, empty-state heroes, and onboarding stay serif.
- Severity charts resolve through `--sev-*` tokens with Muted, Heat, and
  Signature palettes; FindingsByType routes its series through the severity ramp
  plus a neutral so no brand-cyan sits next to rose. The risk trend flattens its
  gradient under Muted/Heat/reduced and keeps the gradient under Signature.
- Appearance settings gain Visual style cards, a Security visualization palette,
  a Readability master toggle, a Motion & effects group, and a "Reset to default"
  button (restores the Calm axes, disabled while readability is on). Contrast
  moves under Readability and Ambient glow under Motion & effects. A card is
  selected only while the stored sub-axes match its preset, so a custom
  combination de-selects both.
- The topbar Theme quick-switch swaps the interface/data font pickers for a
  Visual style switch and a Readability toggle (text size kept); its footer
  Settings link jumps straight to Appearance.
- Readability is a sticky master that forces the calm resolution and a contrast
  lift at apply time without mutating the stored sub-axes.
- New users default to Calm; any pre-existing persisted appearance state keeps
  the Signature look. The pre-paint script mirrors the store.
- SegmentedControl gains a `disabled` prop and a nullable value (no active
  segment for a custom combination, with a roving-tabindex keyboard anchor).
  Adds unit/component coverage for the store, migration, chart shape logic, the
  disabled control, the reset/de-selection, and the quick-switch.

* fix(appearance): migrate Blueprint serif headings and surface readability locks

- Migrate the two operational Blueprint headings (catalog tile name, drift-policy
  option title) from font-serif italic to the .font-heading utility; the first
  pass only covered font-display, so Calm still left these italic. font-serif and
  font-display both resolve to the same display face, so this is the same fix.
- Lock the Visual style cards under Readability (parity with the topbar switch and
  the on-screen guidance to turn Readability off to choose a style by hand).
- Lock the Border brightness slider under Readability and show its forced +0.03
  readout, since Readability overrides the stored value; dragging it previously
  appeared to do nothing.
- Correct the Appearance docs sentence for the topbar quick switch (it listed
  fonts; the quick switch now carries visual style, readability, and text size).
2026-06-22 00:19:57 -04:00

198 lines
6.6 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Bell } from 'lucide-react';
type FleetHealth = 'healthy' | 'degraded' | 'critical';
interface FleetMastheadProps {
nodeCount: number;
onlineCount: number;
criticalCount: number;
totalCpuPercent: number;
worstCpu: { name: string; percent: number } | null;
totalMemUsed: number;
totalMemTotal: number;
activeContainers: number;
totalContainers: number;
lastSyncAt: number | null;
loading: boolean;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 GiB';
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
}
function formatAgo(ms: number): string {
const clamped = Math.max(0, ms);
if (clamped < 60_000) return `${Math.round(clamped / 1000)}s`;
if (clamped < 3_600_000) return `${Math.round(clamped / 60_000)}m`;
return `${Math.round(clamped / 3_600_000)}h`;
}
function useTicker(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
const healthConfig: Record<FleetHealth, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
healthy: {
label: 'The fleet',
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
textClass: 'text-stat-value',
railClass: 'bg-brand',
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
},
degraded: {
label: 'The fleet',
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
textClass: 'text-warning',
railClass: 'bg-warning',
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
},
critical: {
label: 'The fleet',
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
textClass: 'text-destructive',
railClass: 'bg-destructive',
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
},
};
export function FleetMasthead({
nodeCount,
onlineCount,
criticalCount,
totalCpuPercent,
worstCpu,
totalMemUsed,
totalMemTotal,
activeContainers,
totalContainers,
lastSyncAt,
loading,
}: FleetMastheadProps) {
const level: FleetHealth = useMemo(() => {
if (criticalCount > 0) return 'critical';
if (onlineCount < nodeCount) return 'degraded';
return 'healthy';
}, [criticalCount, onlineCount, nodeCount]);
const config = healthConfig[level];
const now = useTicker(1000);
const offlineCount = Math.max(0, nodeCount - onlineCount);
const reasons: string[] = [];
if (offlineCount > 0) reasons.push(`${offlineCount} offline`);
if (criticalCount > 0) reasons.push(`${criticalCount} critical`);
const lastSyncLabel = loading
? 'syncing…'
: lastSyncAt
? `last sync ${formatAgo(now - lastSyncAt)}`
: 'no sync yet';
const metaLine = `${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${onlineCount} online · ${lastSyncLabel}`;
const reasonsLine = reasons.join(' · ');
const cpuTone = totalCpuPercent >= 80 ? 'warn' : 'value';
const memPercent = totalMemTotal > 0 ? (totalMemUsed / totalMemTotal) * 100 : 0;
return (
<div className="relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors mb-4">
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
<div className="flex items-center gap-4">
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
/>
<div className="flex flex-col gap-1">
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
{config.label}
</span>
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{metaLine}
</span>
{reasonsLine ? (
<span className="font-mono text-[11px] text-stat-subtitle/90">
{reasonsLine}
</span>
) : null}
</div>
</div>
<div className="hidden items-stretch justify-end gap-0 md:flex">
<StatTile
label="CPU"
value={`${totalCpuPercent.toFixed(0)}%`}
sub={worstCpu ? `peak ${worstCpu.name} ${worstCpu.percent.toFixed(0)}%` : undefined}
tone={cpuTone}
/>
<StatTile
label="MEM"
value={formatBytes(totalMemUsed)}
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} · ${memPercent.toFixed(0)}%` : undefined}
tone="value"
divider
/>
<StatTile
label="CONTAINERS"
value={`${activeContainers}`}
sub={`of ${totalContainers} total`}
tone="value"
divider
/>
</div>
<div className="flex items-center gap-2 pl-4">
<Bell
className={`h-3.5 w-3.5 ${criticalCount > 0 ? 'text-destructive' : 'text-stat-icon'}`}
strokeWidth={1.5}
/>
<span
className={`font-mono text-sm tabular-nums ${criticalCount > 0 ? 'text-destructive' : 'text-stat-subtitle'}`}
>
{criticalCount}
</span>
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{criticalCount === 1 ? 'alert' : 'alerts'}
</span>
</div>
</div>
</div>
);
}
function StatTile({
label,
value,
sub,
tone,
divider,
}: {
label: string;
value: string;
sub?: string;
tone: 'value' | 'warn';
divider?: boolean;
}) {
return (
<div className={`flex flex-col gap-1 px-5 ${divider ? 'border-l border-border/60' : ''}`}>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{label}
</span>
<span
className={`font-mono tabular-nums text-xl leading-none ${tone === 'warn' ? 'text-warning' : 'text-stat-value'}`}
>
{value}
</span>
{sub ? (
<span className="font-mono text-[10px] text-stat-subtitle/80">{sub}</span>
) : null}
</div>
);
}