Frontend redesign chunk 2: primitives and marks
Build & Deploy / Build & push images (push) Successful in 5m11s
Build & Deploy / Deploy to VM (push) Failing after 3m33s

Rewrite components/ui/* (Button, Card, Badge, PageHeader, EmptyState,
Skeleton) against the chunk-1 tokens instead of hardcoded gray-9xx classes,
and drop the remaining font-mono from label/heading text.

Add the three colour-blindness-validated encoding components from
UI_REDESIGN.md §2.3:
- components/marks/SeverityMark.tsx — glyph (filled triangle / outline
  triangle / outline circle) + optional spine + optional label, from the
  four-level severity ladder. Colour is never the only channel.
- components/marks/TypeGlyph.tsx — five stroked SVG glyphs (fire, police,
  ems, collision, other) in currentColor. Incident type is now shape, not
  hue, since five hues can't clear an all-pairs CVD gate.
- components/marks/NodeMark.tsx — diamond at four weights (filled+ring /
  filled / hollow / hollow-dashed). Green is gone from node state entirely.

lib/severity.tsx now renders through SeverityMark; SEVERITY_COLORS reads
the validated sev-moderate/sev-major tokens with routine/minor neutral.
IncidentBadges.tsx's TypeBadge is reimplemented on TypeGlyph instead of a
coloured pill.

Per UI_REDESIGN.md chunk 2.
This commit is contained in:
Logan Cusano
2026-08-19 22:56:02 -04:00
parent c6bc712b54
commit 3a786bc227
11 changed files with 307 additions and 46 deletions
@@ -0,0 +1,76 @@
/**
* Node state, carried by VALUE, never hue — see UI_REDESIGN.md §2.3.
* One diamond mark at four weights. Green is gone entirely (it measured
* ΔE 4.1 against major-red under deuteranopia — the same colour).
* recording — filled + accent ring (the one state allowed a colour
* ring, since it doubles as the "live now" indicator,
* not a hue distinguishing it from other node states)
* online — filled ink
* offline — hollow ink
* unconfigured — hollow ink, dashed
*/
import type { NodeStatus } from "@/lib/types";
export function NodeMark({
status,
size = 14,
className,
}: {
status: NodeStatus;
size?: number;
className?: string;
}) {
const half = size / 2;
const points = `${half},1 ${size - 1},${half} ${half},${size - 1} 1,${half}`;
if (status === "recording") {
return (
<span className={`relative inline-block ${className ?? ""}`} style={{ width: size * 1.8, height: size * 1.8 }}>
<span
aria-hidden
className="node-pulse-ring absolute rounded-full"
style={{
width: size * 1.8,
height: size * 1.8,
border: "2px solid var(--accent)",
top: 0,
left: 0,
}}
/>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="absolute"
style={{ top: size * 0.4, left: size * 0.4 }}
aria-hidden
>
<polygon points={points} fill="var(--ink)" stroke="var(--accent)" strokeWidth={1.5} />
</svg>
</span>
);
}
if (status === "online") {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={className} aria-hidden>
<polygon points={points} fill="var(--ink)" />
</svg>
);
}
if (status === "unconfigured") {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={className} aria-hidden>
<polygon points={points} fill="none" stroke="var(--ink-muted)" strokeWidth={1.5} strokeDasharray="2.5 2" />
</svg>
);
}
// offline — hollow
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={className} aria-hidden>
<polygon points={points} fill="none" stroke="var(--ink-muted)" strokeWidth={1.5} />
</svg>
);
}