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
+16 -9
View File
@@ -3,19 +3,26 @@
// the four radio-traffic archetypes (rail ops, public works, utility // the four radio-traffic archetypes (rail ops, public works, utility
// coordination, …) and must always resolve to a styled badge, never fall // coordination, …) and must always resolve to a styled badge, never fall
// through unstyled. // through unstyled.
const TYPE_COLORS: Record<string, string> = { //
fire: "bg-red-900 text-red-300", // Type is carried by SHAPE (TypeGlyph), never hue — see UI_REDESIGN.md §2.3.
police: "bg-blue-900 text-blue-300", // This badge is the transitional/list-row form: glyph + word in neutral ink.
ems: "bg-yellow-900 text-yellow-300", import { TypeGlyph } from "@/components/marks/TypeGlyph";
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300", const TYPE_LABEL: Record<string, string> = {
fire: "Fire",
police: "Police",
ems: "EMS",
accident: "Collision",
collision: "Collision",
other: "Other",
}; };
export function TypeBadge({ type }: { type: string | null }) { export function TypeBadge({ type }: { type: string | null }) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; const label = TYPE_LABEL[type ?? "other"] ?? TYPE_LABEL.other;
return ( return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}> <span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full bg-raised text-ink-2">
{type ?? "other"} <TypeGlyph type={type} size={16} />
{label}
</span> </span>
); );
} }
@@ -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>
);
}
@@ -0,0 +1,93 @@
/**
* The severity ladder's visual encoding — see UI_REDESIGN.md §2.3.
*
* Colour is never the only channel: severity is triple-encoded so it
* survives both grayscale and colour-blindness.
* major — filled triangle, colour, 5px spine
* moderate — outline triangle, colour, 5px spine (thinner fill)
* minor — outline circle, neutral ink, 3px spine
* routine — faint outline circle, neutral ink, 2px spine
*/
import type { Severity } from "@/lib/severity";
import { SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
const SPINE_WIDTH: Record<Severity, number> = { major: 5, moderate: 5, minor: 3, routine: 2 };
const GLYPH_SIZE: Record<"sm" | "md", number> = { sm: 12, md: 16 };
function Glyph({ severity, size }: { severity: Severity; size: number }) {
const color = SEVERITY_COLORS[severity];
if (severity === "major") {
// filled triangle
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden>
<polygon points="8,1.5 14.5,14.5 1.5,14.5" fill={color} />
</svg>
);
}
if (severity === "moderate") {
// outline triangle
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden>
<polygon points="8,1.5 14.5,14.5 1.5,14.5" fill="none" stroke={color} strokeWidth={1.75} strokeLinejoin="round" />
</svg>
);
}
if (severity === "minor") {
// outline circle, neutral
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden>
<circle cx="8" cy="8" r="6" fill="none" stroke={color} strokeWidth={1.75} />
</svg>
);
}
// routine — faint outline circle
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden style={{ opacity: 0.6 }}>
<circle cx="8" cy="8" r="6" fill="none" stroke={color} strokeWidth={1.25} />
</svg>
);
}
export function SeverityMark({
severity,
showLabel = false,
spine = false,
size = "sm",
className,
}: {
severity: Severity;
showLabel?: boolean;
spine?: boolean;
size?: "sm" | "md";
className?: string;
}) {
const color = SEVERITY_COLORS[severity];
return (
<span className={`inline-flex items-center gap-1.5 ${className ?? ""}`}>
{spine && (
<span
aria-hidden
className="inline-block rounded-full self-stretch"
style={{ width: SPINE_WIDTH[severity], background: color, minHeight: "0.9em" }}
/>
)}
<Glyph severity={severity} size={GLYPH_SIZE[size]} />
{showLabel && (
<span className="text-xs font-medium" style={{ color }}>
{SEVERITY_LABEL[severity]}
</span>
)}
</span>
);
}
/** Standalone spine bar — for list rows that render the mark and spine in separate flex slots. */
export function SeveritySpine({ severity, className }: { severity: Severity; className?: string }) {
return (
<span
aria-hidden
className={`inline-block rounded-full self-stretch ${className ?? ""}`}
style={{ width: SPINE_WIDTH[severity], background: SEVERITY_COLORS[severity], minHeight: "100%" }}
/>
);
}
@@ -0,0 +1,79 @@
/**
* Incident type, carried by SHAPE, never hue — see UI_REDESIGN.md §2.3.
* Five stroked SVG glyphs in `currentColor`; the caller sets colour (usually
* severity's colour, or plain ink). Replaces IncidentBadges.tsx's coloured
* pill, which encoded type as hue and collapsed fire↔accident under
* deuteranopia.
*/
type IncidentType = "fire" | "police" | "ems" | "collision" | "other";
const SIZES = { 16: 16, 20: 20, 24: 24 } as const;
function normalize(type: string | null | undefined): IncidentType {
if (type === "fire" || type === "police" || type === "ems" || type === "collision") return type;
if (type === "accident") return "collision";
return "other";
}
export function TypeGlyph({
type,
size = 16,
className,
}: {
type: string | null | undefined;
size?: keyof typeof SIZES;
className?: string;
}) {
const t = normalize(type);
const s = SIZES[size];
const common = {
width: s,
height: s,
viewBox: "0 0 24 24",
fill: "none" as const,
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
className,
"aria-hidden": true,
};
switch (t) {
case "fire":
return (
<svg {...common}>
<path d="M12 2c1 3-2 4-2 7a3 3 0 0 0 6 0c1.5 1.5 2 3.5 2 5a6 6 0 0 1-12 0c0-3 1.5-4.5 3-6.5C10 5.5 11 4 12 2Z" />
</svg>
);
case "police":
return (
<svg {...common}>
<path d="M12 2 4 5v6c0 5 3.4 8.7 8 9 4.6-.3 8-4 8-9V5l-8-3Z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
case "ems":
return (
<svg {...common}>
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M12 7v10M7 12h10" />
</svg>
);
case "collision":
return (
<svg {...common}>
<path d="M3 16l3-7 4 2 2-5 4 3 3-2 2 6" />
<path d="M3 16h18M6 16v3M18 16v3" />
</svg>
);
default:
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 0 1 4.7-1.2c.5.9.2 1.6-.7 2.3-.9.7-1.5 1.2-1.5 2.4" />
<circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none" />
</svg>
);
}
}
+7 -7
View File
@@ -3,19 +3,19 @@ import type { ReactNode } from "react";
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info"; type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
const TONE_CLASSES: Record<Tone, string> = { const TONE_CLASSES: Record<Tone, string> = {
neutral: "bg-gray-800 text-gray-300", neutral: "bg-raised text-ink-2",
brand: "bg-indigo-900 text-indigo-300", brand: "bg-accent/15 text-accent",
success: "bg-green-900 text-green-300", success: "bg-raised text-ink-2",
warning: "bg-yellow-900 text-yellow-300", warning: "bg-sev-moderate/15 text-sev-moderate",
danger: "bg-red-900 text-red-300", danger: "bg-sev-major/15 text-sev-major",
info: "bg-blue-900 text-blue-300", info: "bg-accent/15 text-accent",
}; };
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) { export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
return ( return (
<span <span
className={[ className={[
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap", "inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
TONE_CLASSES[tone], TONE_CLASSES[tone],
className ?? "", className ?? "",
] ]
+5 -5
View File
@@ -6,13 +6,13 @@ type Size = "sm" | "md" | "lg";
const VARIANT_CLASSES: Record<Variant, string> = { const VARIANT_CLASSES: Record<Variant, string> = {
primary: primary:
"bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600", "bg-accent hover:brightness-110 active:brightness-95 text-white shadow-card disabled:hover:brightness-100",
secondary: secondary:
"bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800", "bg-raised hover:brightness-110 active:brightness-95 text-ink border border-line disabled:hover:brightness-100",
ghost: ghost:
"bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent", "bg-transparent hover:bg-raised active:bg-raised text-ink-2 hover:text-ink disabled:hover:bg-transparent",
danger: danger:
"bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700", "bg-sev-major hover:brightness-110 active:brightness-95 text-white disabled:hover:brightness-100",
}; };
const SIZE_CLASSES: Record<Size, string> = { const SIZE_CLASSES: Record<Size, string> = {
@@ -24,7 +24,7 @@ const SIZE_CLASSES: Record<Size, string> = {
const BASE = const BASE =
"inline-flex items-center justify-center font-semibold transition-colors " + "inline-flex items-center justify-center font-semibold transition-colors " +
"disabled:opacity-50 disabled:cursor-not-allowed " + "disabled:opacity-50 disabled:cursor-not-allowed " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-page";
interface CommonProps { interface CommonProps {
variant?: Variant; variant?: Variant;
+5 -5
View File
@@ -19,9 +19,9 @@ export function Card({ children, hover, padding = "md", highlighted, className,
return ( return (
<div <div
className={[ className={[
"bg-gray-900 border rounded-xl", "bg-surface border rounded-xl",
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800", highlighted ? "border-accent/40 shadow-glow" : "border-line",
hover ? "transition-colors hover:border-gray-600" : "", hover ? "transition-colors hover:border-line-strong" : "",
PADDING[padding], PADDING[padding],
className ?? "", className ?? "",
] ]
@@ -38,8 +38,8 @@ export function CardHeader({ title, subtitle, action }: { title: ReactNode; subt
return ( return (
<div className="flex items-start justify-between gap-4 mb-4"> <div className="flex items-start justify-between gap-4 mb-4">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-white font-semibold text-sm">{title}</h3> <h3 className="text-ink font-semibold text-sm">{title}</h3>
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>} {subtitle && <p className="text-ink-muted text-xs mt-0.5 leading-snug">{subtitle}</p>}
</div> </div>
{action && <div className="shrink-0">{action}</div>} {action && <div className="shrink-0">{action}</div>}
</div> </div>
+6 -6
View File
@@ -10,10 +10,10 @@ interface EmptyStateProps {
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */ /** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
export function EmptyState({ icon, title, description, action }: EmptyStateProps) { export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl"> <div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
{icon && <div className="text-gray-700 mb-3">{icon}</div>} {icon && <div className="text-ink-muted mb-3">{icon}</div>}
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p> <p className="text-ink text-sm font-semibold">{title}</p>
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>} {description && <p className="text-ink-muted text-xs mt-1 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>} {action && <div className="mt-4">{action}</div>}
</div> </div>
); );
@@ -22,8 +22,8 @@ export function EmptyState({ icon, title, description, action }: EmptyStateProps
/** Inline error banner — for API/Firestore errors surfaced within a page section. */ /** Inline error banner — for API/Firestore errors surfaced within a page section. */
export function ErrorBanner({ message }: { message: string }) { export function ErrorBanner({ message }: { message: string }) {
return ( return (
<div className="bg-red-950 border border-red-800 rounded-lg p-4"> <div className="bg-sev-major/10 border border-sev-major/40 rounded-lg p-4">
<p className="text-red-400 text-sm font-mono">{message}</p> <p className="text-sev-major text-sm">{message}</p>
</div> </div>
); );
} }
+2 -2
View File
@@ -13,10 +13,10 @@ export function PageHeader({ title, description, badge, action }: PageHeaderProp
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold text-white font-mono">{title}</h1> <h1 className="text-xl font-semibold text-ink">{title}</h1>
{badge} {badge}
</div> </div>
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>} {description && <p className="text-ink-muted text-sm mt-1 max-w-2xl">{description}</p>}
</div> </div>
{action && <div className="shrink-0">{action}</div>} {action && <div className="shrink-0">{action}</div>}
</div> </div>
+3 -3
View File
@@ -1,12 +1,12 @@
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual /** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
* content has a predictable shape (cards, table rows, stat tiles). */ * content has a predictable shape (cards, table rows, stat tiles). */
export function Skeleton({ className }: { className?: string }) { export function Skeleton({ className }: { className?: string }) {
return <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />; return <div className={`skeleton bg-raised rounded-md ${className ?? "h-4 w-full"}`} />;
} }
export function SkeletonCard() { export function SkeletonCard() {
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3"> <div className="bg-surface border border-line rounded-xl p-4 space-y-3">
<Skeleton className="h-4 w-1/3" /> <Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" /> <Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
@@ -16,7 +16,7 @@ export function SkeletonCard() {
export function SkeletonRow({ cols = 5 }: { cols?: number }) { export function SkeletonRow({ cols = 5 }: { cols?: number }) {
return ( return (
<tr className="border-b border-gray-800"> <tr className="border-b border-line">
{Array.from({ length: cols }).map((_, i) => ( {Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-4 py-3"> <td key={i} className="px-4 py-3">
<Skeleton className="h-3 w-full max-w-[8rem]" /> <Skeleton className="h-3 w-full max-w-[8rem]" />
+15 -9
View File
@@ -5,16 +5,25 @@
* treat it as "no severity", not as a fifth level. * treat it as "no severity", not as a fifth level.
*/ */
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { SeverityMark } from "@/components/marks/SeverityMark";
export type Severity = "routine" | "minor" | "moderate" | "major"; export type Severity = "routine" | "minor" | "moderate" | "major";
export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 }; export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" }; export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
/**
* Validated against the dataviz all-pairs colour-blindness checker (see
* UI_REDESIGN.md §2.3). Severity is the ONLY hue channel in the whole app —
* moderate/major carry colour, routine/minor are neutral ink with no hue.
* Colour is never the sole channel: SeverityMark also carries glyph shape
* and spine thickness so the ladder survives grayscale.
*/
export const SEVERITY_COLORS: Record<Severity, string> = { export const SEVERITY_COLORS: Record<Severity, string> = {
routine: "bg-gray-800/40 text-gray-600", routine: "var(--ink-muted)",
minor: "bg-gray-800 text-gray-400", minor: "var(--ink-2)",
moderate: "bg-orange-950 text-orange-400", moderate: "var(--sev-moderate)",
major: "bg-red-950 text-red-400", major: "var(--sev-major)",
}; };
export function isKnownSeverity(s: string | null | undefined): s is Severity { export function isKnownSeverity(s: string | null | undefined): s is Severity {
@@ -26,11 +35,8 @@ export function severityRank(s: string | null | undefined): number {
return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1; return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
} }
/** Renders the full severity mark (glyph + word chip). Use `<SeverityMark>` directly for more control (spine, size, label-off). */
export function severityBadge(severity: string | null | undefined): ReactElement | null { export function severityBadge(severity: string | null | undefined): ReactElement | null {
if (!isKnownSeverity(severity)) return null; if (!isKnownSeverity(severity)) return null;
return ( return <SeverityMark severity={severity} showLabel />;
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
{SEVERITY_LABEL[severity]}
</span>
);
} }