Frontend redesign chunk 2: primitives and marks
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:
@@ -3,19 +3,26 @@
|
||||
// the four radio-traffic archetypes (rail ops, public works, utility
|
||||
// coordination, …) and must always resolve to a styled badge, never fall
|
||||
// through unstyled.
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "bg-red-900 text-red-300",
|
||||
police: "bg-blue-900 text-blue-300",
|
||||
ems: "bg-yellow-900 text-yellow-300",
|
||||
accident: "bg-orange-900 text-orange-300",
|
||||
other: "bg-gray-800 text-gray-300",
|
||||
//
|
||||
// Type is carried by SHAPE (TypeGlyph), never hue — see UI_REDESIGN.md §2.3.
|
||||
// This badge is the transitional/list-row form: glyph + word in neutral ink.
|
||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
|
||||
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 }) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
const label = TYPE_LABEL[type ?? "other"] ?? TYPE_LABEL.other;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
<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">
|
||||
<TypeGlyph type={type} size={16} />
|
||||
{label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,19 @@ import type { ReactNode } from "react";
|
||||
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
neutral: "bg-gray-800 text-gray-300",
|
||||
brand: "bg-indigo-900 text-indigo-300",
|
||||
success: "bg-green-900 text-green-300",
|
||||
warning: "bg-yellow-900 text-yellow-300",
|
||||
danger: "bg-red-900 text-red-300",
|
||||
info: "bg-blue-900 text-blue-300",
|
||||
neutral: "bg-raised text-ink-2",
|
||||
brand: "bg-accent/15 text-accent",
|
||||
success: "bg-raised text-ink-2",
|
||||
warning: "bg-sev-moderate/15 text-sev-moderate",
|
||||
danger: "bg-sev-major/15 text-sev-major",
|
||||
info: "bg-accent/15 text-accent",
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
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],
|
||||
className ?? "",
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ type Size = "sm" | "md" | "lg";
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
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:
|
||||
"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:
|
||||
"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:
|
||||
"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> = {
|
||||
@@ -24,7 +24,7 @@ const SIZE_CLASSES: Record<Size, string> = {
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center font-semibold transition-colors " +
|
||||
"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 {
|
||||
variant?: Variant;
|
||||
|
||||
@@ -19,9 +19,9 @@ export function Card({ children, hover, padding = "md", highlighted, className,
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"bg-gray-900 border rounded-xl",
|
||||
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
|
||||
hover ? "transition-colors hover:border-gray-600" : "",
|
||||
"bg-surface border rounded-xl",
|
||||
highlighted ? "border-accent/40 shadow-glow" : "border-line",
|
||||
hover ? "transition-colors hover:border-line-strong" : "",
|
||||
PADDING[padding],
|
||||
className ?? "",
|
||||
]
|
||||
@@ -38,8 +38,8 @@ export function CardHeader({ title, subtitle, action }: { title: ReactNode; subt
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm">{title}</h3>
|
||||
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||
<h3 className="text-ink font-semibold text-sm">{title}</h3>
|
||||
{subtitle && <p className="text-ink-muted text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,10 @@ interface EmptyStateProps {
|
||||
/** 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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
|
||||
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
|
||||
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
|
||||
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
|
||||
<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-ink-muted mb-3">{icon}</div>}
|
||||
<p className="text-ink text-sm font-semibold">{title}</p>
|
||||
{description && <p className="text-ink-muted text-xs mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</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. */
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">{message}</p>
|
||||
<div className="bg-sev-major/10 border border-sev-major/40 rounded-lg p-4">
|
||||
<p className="text-sev-major text-sm">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="min-w-0">
|
||||
<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}
|
||||
</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>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
|
||||
* content has a predictable shape (cards, table rows, stat tiles). */
|
||||
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() {
|
||||
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-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
@@ -16,7 +16,7 @@ export function SkeletonCard() {
|
||||
|
||||
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-800">
|
||||
<tr className="border-b border-line">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton className="h-3 w-full max-w-[8rem]" />
|
||||
|
||||
Reference in New Issue
Block a user