diff --git a/drb-frontend/components/IncidentBadges.tsx b/drb-frontend/components/IncidentBadges.tsx index 5a2da71..669aa72 100644 --- a/drb-frontend/components/IncidentBadges.tsx +++ b/drb-frontend/components/IncidentBadges.tsx @@ -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 = { - 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 = { + 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 ( - - {type ?? "other"} + + + {label} ); } diff --git a/drb-frontend/components/marks/NodeMark.tsx b/drb-frontend/components/marks/NodeMark.tsx new file mode 100644 index 0000000..38a0a70 --- /dev/null +++ b/drb-frontend/components/marks/NodeMark.tsx @@ -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 ( + + + + + + + ); + } + + if (status === "online") { + return ( + + + + ); + } + + if (status === "unconfigured") { + return ( + + + + ); + } + + // offline — hollow + return ( + + + + ); +} diff --git a/drb-frontend/components/marks/SeverityMark.tsx b/drb-frontend/components/marks/SeverityMark.tsx new file mode 100644 index 0000000..ace7fb0 --- /dev/null +++ b/drb-frontend/components/marks/SeverityMark.tsx @@ -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 = { 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 ( + + + + ); + } + if (severity === "moderate") { + // outline triangle + return ( + + + + ); + } + if (severity === "minor") { + // outline circle, neutral + return ( + + + + ); + } + // routine — faint outline circle + return ( + + + + ); +} + +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 ( + + {spine && ( + + )} + + {showLabel && ( + + {SEVERITY_LABEL[severity]} + + )} + + ); +} + +/** 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 ( + + ); +} diff --git a/drb-frontend/components/marks/TypeGlyph.tsx b/drb-frontend/components/marks/TypeGlyph.tsx new file mode 100644 index 0000000..d63b733 --- /dev/null +++ b/drb-frontend/components/marks/TypeGlyph.tsx @@ -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 ( + + + + ); + case "police": + return ( + + + + + ); + case "ems": + return ( + + + + + ); + case "collision": + return ( + + + + + ); + default: + return ( + + + + + + ); + } +} diff --git a/drb-frontend/components/ui/Badge.tsx b/drb-frontend/components/ui/Badge.tsx index 66585e4..00c41b8 100644 --- a/drb-frontend/components/ui/Badge.tsx +++ b/drb-frontend/components/ui/Badge.tsx @@ -3,19 +3,19 @@ import type { ReactNode } from "react"; type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info"; const TONE_CLASSES: Record = { - 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 ( = { 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 = { @@ -24,7 +24,7 @@ const SIZE_CLASSES: Record = { 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; diff --git a/drb-frontend/components/ui/Card.tsx b/drb-frontend/components/ui/Card.tsx index 5683550..4cfd58d 100644 --- a/drb-frontend/components/ui/Card.tsx +++ b/drb-frontend/components/ui/Card.tsx @@ -19,9 +19,9 @@ export function Card({ children, hover, padding = "md", highlighted, className, return (
-

{title}

- {subtitle &&

{subtitle}

} +

{title}

+ {subtitle &&

{subtitle}

}
{action &&
{action}
}
diff --git a/drb-frontend/components/ui/EmptyState.tsx b/drb-frontend/components/ui/EmptyState.tsx index 5f50524..a9917bd 100644 --- a/drb-frontend/components/ui/EmptyState.tsx +++ b/drb-frontend/components/ui/EmptyState.tsx @@ -10,10 +10,10 @@ interface EmptyStateProps { /** Consistent "nothing here yet" panel — replaces the ad-hoc `

` scattered across pages. */ export function EmptyState({ icon, title, description, action }: EmptyStateProps) { return ( -

- {icon &&
{icon}
} -

{title}

- {description &&

{description}

} +
+ {icon &&
{icon}
} +

{title}

+ {description &&

{description}

} {action &&
{action}
}
); @@ -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 ( -
-

{message}

+
+

{message}

); } diff --git a/drb-frontend/components/ui/PageHeader.tsx b/drb-frontend/components/ui/PageHeader.tsx index 68d5006..17904a3 100644 --- a/drb-frontend/components/ui/PageHeader.tsx +++ b/drb-frontend/components/ui/PageHeader.tsx @@ -13,10 +13,10 @@ export function PageHeader({ title, description, badge, action }: PageHeaderProp
-

{title}

+

{title}

{badge}
- {description &&

{description}

} + {description &&

{description}

}
{action &&
{action}
}
diff --git a/drb-frontend/components/ui/Skeleton.tsx b/drb-frontend/components/ui/Skeleton.tsx index ca6325a..a655898 100644 --- a/drb-frontend/components/ui/Skeleton.tsx +++ b/drb-frontend/components/ui/Skeleton.tsx @@ -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
; + return
; } export function SkeletonCard() { return ( -
+
@@ -16,7 +16,7 @@ export function SkeletonCard() { export function SkeletonRow({ cols = 5 }: { cols?: number }) { return ( - + {Array.from({ length: cols }).map((_, i) => ( diff --git a/drb-frontend/lib/severity.tsx b/drb-frontend/lib/severity.tsx index 6406530..3ae58e3 100644 --- a/drb-frontend/lib/severity.tsx +++ b/drb-frontend/lib/severity.tsx @@ -5,16 +5,25 @@ * treat it as "no severity", not as a fifth level. */ import type { ReactElement } from "react"; +import { SeverityMark } from "@/components/marks/SeverityMark"; export type Severity = "routine" | "minor" | "moderate" | "major"; export const SEVERITY_ORDER: Record = { routine: 0, minor: 1, moderate: 2, major: 3 }; export const SEVERITY_LABEL: Record = { 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 = { - routine: "bg-gray-800/40 text-gray-600", - minor: "bg-gray-800 text-gray-400", - moderate: "bg-orange-950 text-orange-400", - major: "bg-red-950 text-red-400", + routine: "var(--ink-muted)", + minor: "var(--ink-2)", + moderate: "var(--sev-moderate)", + major: "var(--sev-major)", }; 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; } +/** Renders the full severity mark (glyph + word chip). Use `` directly for more control (spine, size, label-off). */ export function severityBadge(severity: string | null | undefined): ReactElement | null { if (!isKnownSeverity(severity)) return null; - return ( - - {SEVERITY_LABEL[severity]} - - ); + return ; }