From 8779f40edfb1a2d8ffc944f97e0a1a1211ffdb0f Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Fri, 23 Oct 2026 22:36:27 -0700 Subject: [PATCH 1/6] Add redesign tokens, shared UI primitives Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/globals.css | 326 +++++++++++++++++--------- frontend/src/app/layout.tsx | 12 +- frontend/src/components/header.tsx | 56 ++--- frontend/src/components/ui/button.tsx | 31 +-- frontend/src/components/ui/card.tsx | 69 ++++++ frontend/src/components/ui/input.tsx | 24 ++ 6 files changed, 360 insertions(+), 158 deletions(-) create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/input.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a799056..7713628 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,155 +1,261 @@ @import "tailwindcss"; -@custom-variant dark (&:is(.dark *)); +/* FirstWave is dark only (see design.md §1). `dark:` utilities stay usable but + always apply, so shadcn components that ship with them keep working. */ +@custom-variant dark (&:where(:root, :root *)); @theme inline { + /* Surfaces & text */ --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-emerald-400: oklch(0.765 0.177 163.223); - --color-emerald-500: oklch(0.696 0.17 162.48); - --color-emerald-600: oklch(0.596 0.145 163.225); - --color-emerald-900: oklch(0.359 0.078 165.612); - --color-emerald-950: oklch(0.262 0.051 166.109); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-card-raised: var(--card-raised); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); --color-input: var(--input); --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); + --color-border-strong: var(--border-strong); --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-subtle-foreground: var(--subtle-foreground); + --color-ring: var(--ring); + + /* Accent */ --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); + --color-primary-foreground: var(--primary-foreground); + --color-primary-soft: var(--primary-soft); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + /* Status */ + --color-success: var(--success); + --color-warning: var(--warning); + --color-danger: var(--danger); + --color-destructive: var(--destructive); + + /* Priority scale (P1–P4) */ + --color-p1: var(--p1); + --color-p2: var(--p2); + --color-p3: var(--p3); + --color-p4: var(--p4); + + /* Resource types */ + --color-resource-medical: var(--resource-medical); + --color-resource-fire: var(--resource-fire); + --color-resource-police: var(--resource-police); + --color-resource-pharmacy: var(--resource-pharmacy); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + /* Type */ + --font-sans: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; + + /* Radius: inputs/tiles 10px, cards 14px, buttons/chips pill */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); + --radius-tile: 0.625rem; + --radius-card: 0.875rem; + + --shadow-elevated: 0 20px 60px rgb(0 0 0 / 0.45); + + --animate-pulse-dot: pulse-dot 1.6s ease-in-out infinite; + --animate-wave-ring: wave-ring 2.4s ease-out infinite; } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.12 0.01 240); - --foreground: oklch(0.985 0 0); - --card: oklch(0.16 0.01 240); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.16 0.01 240); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.696 0.17 162.48); - --primary-foreground: oklch(0.12 0.01 240); - --secondary: oklch(0.22 0.01 240); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.22 0.01 240); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.696 0.17 162.48); - --accent-foreground: oklch(0.12 0.01 240); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 12%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.696 0.17 162.48); - --chart-1: oklch(0.696 0.17 162.48); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.16 0.01 240); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.696 0.17 162.48); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.22 0.01 240); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 12%); - --sidebar-ring: oklch(0.696 0.17 162.48); + color-scheme: dark; + + --radius: 0.625rem; /* 10px */ + + --background: #070b12; + --foreground: #e8ecf4; + --card: #0c1220; + --card-foreground: var(--foreground); + --card-raised: #111a2b; + --popover: var(--card-raised); + --popover-foreground: var(--foreground); + --input: #060a11; + --border: #1a2335; + --border-strong: #26324a; + --muted: var(--card-raised); + --muted-foreground: #8b95a8; + --subtle-foreground: #5f6b80; + + --primary: #38d4f2; + --primary-foreground: #06121a; + --primary-soft: color-mix(in srgb, var(--primary) 12%, transparent); + --ring: var(--primary); + --secondary: var(--card-raised); + --secondary-foreground: var(--foreground); + --accent: var(--card-raised); + --accent-foreground: var(--foreground); + + --success: #5ee6c8; + --warning: #f5a94a; + --danger: #f2708a; + --destructive: var(--danger); + + --p1: #f2708a; + --p2: #f5a94a; + --p3: #a7a9ff; + --p4: #8b95a8; + + --resource-medical: #38d4f2; + --resource-fire: #ff7a45; + --resource-police: #a7a9ff; + --resource-pharmacy: #8b95a8; + + --chart-1: var(--primary); + --chart-2: var(--success); + --chart-3: var(--warning); + --chart-4: var(--p3); + --chart-5: var(--danger); +} + +/* Accent options (Settings → Appearance). Only the primary changes. */ +:root[data-accent="indigo"] { + --primary: #8b8cf8; +} +:root[data-accent="amber"] { + --primary: #f5a94a; +} +:root[data-accent="mint"] { + --primary: #5ee6c8; } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; + } + html { + -webkit-text-size-adjust: 100%; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-sans antialiased; + font-size: 15px; + line-height: 1.55; + } + h1, + h2, + h3 { + @apply font-display text-foreground; + font-weight: 700; + } + :focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + ::selection { + background: var(--primary-soft); + color: var(--foreground); + } +} + +@layer components { + /* 11px mono uppercase, primary color. "AI EMERGENCY DISPATCH ASSISTANT" */ + .eyebrow { + @apply font-mono text-primary uppercase; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.18em; + } + /* 11px sans uppercase, muted. "LOCATION", "RATIONALE" */ + .field-label { + @apply text-muted-foreground uppercase; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.08em; + } +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.45; + transform: scale(0.8); + } +} + +@keyframes wave-ring { + 0% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(2.4); } } -/* Leaflet custom marker styles */ +/* Decorative motion (pulsing dots, wave rings) stops under the OS setting or + Settings → "Reduce motion" (data-reduce-motion on ). Spinners keep going. */ +@media (prefers-reduced-motion: reduce) { + .animate-pulse-dot, + .animate-wave-ring, + .animate-ping, + .animate-pulse { + animation: none !important; + } +} +:root[data-reduce-motion] :is(.animate-pulse-dot, .animate-wave-ring, .animate-ping, .animate-pulse) { + animation: none !important; +} + +/* Leaflet */ .custom-marker, .user-marker { background: transparent !important; border: none !important; } +.leaflet-container { + font-family: inherit; + background: var(--input); +} + .leaflet-popup-content-wrapper { - background: var(--card); + background: var(--card-raised); color: var(--foreground); - border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: 0 20px 60px rgb(0 0 0 / 0.45); } .leaflet-popup-tip { - background: var(--card); + background: var(--card-raised); } .leaflet-popup-close-button { color: var(--muted-foreground) !important; } -.leaflet-container { - font-family: inherit; +.leaflet-bar a { + background: var(--card) !important; + color: var(--foreground) !important; + border-color: var(--border) !important; +} + +.leaflet-control-attribution { + background: color-mix(in srgb, var(--card) 85%, transparent) !important; + color: var(--subtle-foreground) !important; +} + +.leaflet-control-attribution a { + color: var(--muted-foreground) !important; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 6f63757..029d40b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,11 +1,11 @@ import type React from "react" import type { Metadata, Viewport } from "next" -import { Inter, Geist_Mono } from "next/font/google" +import { Bricolage_Grotesque, Geist_Mono } from "next/font/google" import { Analytics } from "@vercel/analytics/next" import "./globals.css" -const inter = Inter({ subsets: ["latin"] }) -const _geistMono = Geist_Mono({ subsets: ["latin"] }) +const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage" }) +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" }) export const metadata: Metadata = { title: "FirstWave", @@ -14,7 +14,7 @@ export const metadata: Metadata = { } export const viewport: Viewport = { - themeColor: "#0f172a", + themeColor: "#070b12", width: "device-width", initialScale: 1, } @@ -25,8 +25,8 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - + + {children} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index efd8582..19e78d9 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,7 +1,10 @@ "use client" import { useEffect, useState } from "react" -import { Activity } from "lucide-react" +import Link from "next/link" +import { Radio, SlidersHorizontal } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000" const HEALTH_POLL_MS = 15000 @@ -25,37 +28,36 @@ export function Header() { }, []) return ( -
-
-
-
-
- -
-
-

- FirstWave -

-

Emergency Response System

-
+
+
+ + +
+

FirstWave

+

Emergency Response System

+ -
- - - - - +
+
+ + System:{" "} - {online ? "Online" : "Offline"} + {online ? "Online" : "Offline"}
+ +
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 37a7d4b..9504793 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -5,30 +5,31 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-[background-color,border-color,color,filter] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary aria-invalid:border-destructive", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + default: "bg-primary text-primary-foreground hover:brightness-106", + destructive: "bg-danger text-primary-foreground hover:brightness-106", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "border border-border-strong bg-transparent text-foreground hover:bg-card-raised", + secondary: "bg-card-raised text-foreground hover:bg-border-strong", + ghost: "text-muted-foreground hover:bg-card-raised hover:text-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", + default: "h-11 px-5 has-[>svg]:px-4", + sm: "h-8 gap-1.5 px-3 text-[13px] has-[>svg]:px-2.5", + lg: "h-12 px-6 text-[15px] has-[>svg]:px-5", + icon: "size-10 rounded-tile", + "icon-sm": "size-8 rounded-tile", + "icon-lg": "size-11 rounded-tile", }, }, + compoundVariants: [ + // Icon buttons sit on a raised square (mic, settings) unless they're primary (send). + { variant: "outline", size: ["icon", "icon-sm", "icon-lg"], class: "bg-card-raised border-border" }, + ], defaultVariants: { variant: "default", size: "default", diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..091c0c5 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,69 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +/** 52px header row: title left, status (CardAction) right, divider below. */ +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { + return ( +

+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return

+} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +/** Nested tile inside a card (summary fields, unit counts). */ +function CardTile({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter, CardTile } diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..e968592 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } From 452597ba500112c088c22091b26a7fd33364e497 Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Fri, 23 Oct 2026 22:40:03 -0700 Subject: [PATCH 2/6] Add restyled /settings page (visual only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header with Back to console pill, title, sticky scroll-spy section nav (horizontal tabs on mobile) and Voice / Dispatch / Map / Appearance / Connection cards per design.md §4.3. Controls hold local state only; SettingsProvider, persistence and backend URL validation are not wired. Slider and select are native elements styled locally until shadcn slider/select are added. Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/settings/page.tsx | 20 ++ .../components/settings/settings-controls.tsx | 252 +++++++++++++++ .../src/components/settings/settings-form.tsx | 286 ++++++++++++++++++ .../components/settings/settings-header.tsx | 23 ++ .../src/components/settings/settings-nav.tsx | 78 +++++ 5 files changed, 659 insertions(+) create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/components/settings/settings-controls.tsx create mode 100644 frontend/src/components/settings/settings-form.tsx create mode 100644 frontend/src/components/settings/settings-header.tsx create mode 100644 frontend/src/components/settings/settings-nav.tsx diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..06087bf --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next" +import { SettingsHeader } from "@/components/settings/settings-header" +import { SettingsForm } from "@/components/settings/settings-form" + +export const metadata: Metadata = { + title: "Settings · FirstWave", +} + +export default function SettingsPage() { + return ( +
+ +
+

Settings

+

Tune how FirstWave listens, speaks and looks.

+ +
+
+ ) +} diff --git a/frontend/src/components/settings/settings-controls.tsx b/frontend/src/components/settings/settings-controls.tsx new file mode 100644 index 0000000..7966b52 --- /dev/null +++ b/frontend/src/components/settings/settings-controls.tsx @@ -0,0 +1,252 @@ +"use client" + +import * as React from "react" +import { ChevronDown } from "lucide-react" +import { Card } from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +/* Local building blocks for the settings page. The design calls for shadcn + `slider` / `select` (design.md §7), which are not installed yet, so the + slider and select here are native elements styled to §3. */ + +export function SettingsSection({ + id, + title, + children, +}: { + id: string + title: string + children: React.ReactNode +}) { + const headingId = `${id}-heading` + return ( +
+ +

+ {title} +

+
{children}
+
+
+ ) +} + +/** One row: label + optional description left, control right. Rows are divided by a 1px border. */ +export function SettingRow({ + label, + description, + htmlFor, + labelId, + descriptionId, + children, +}: { + label: string + description?: string + htmlFor?: string + labelId?: string + descriptionId?: string + children: React.ReactNode +}) { + const LabelTag = htmlFor ? "label" : "span" + return ( +
+
+ + {label} + + {description && ( +

+ {description} +

+ )} +
+
{children}
+
+ ) +} + +/** Switch row. `locked` renders a checked, non-interactive switch at 55% opacity (Human approval). */ +export function SwitchRow({ + id, + label, + description, + checked, + onCheckedChange, + locked = false, +}: { + id: string + label: string + description?: string + checked: boolean + onCheckedChange?: (checked: boolean) => void + locked?: boolean +}) { + const descriptionId = description ? `${id}-description` : undefined + return ( + + + + ) +} + +export function NativeSelect({ + className, + children, + ...props +}: React.ComponentProps<"select">) { + return ( +
+ + +
+ ) +} + +/** Native range input: --primary filled track, light thumb (design.md §3). */ +export function RangeSlider({ + value, + min, + max, + step = 1, + onValueChange, + className, + ...props +}: Omit, "type" | "value" | "onChange" | "min" | "max" | "step"> & { + value: number + min: number + max: number + step?: number + onValueChange: (value: number) => void +}) { + const pct = ((value - min) / (max - min)) * 100 + return ( + onValueChange(Number(e.target.value))} + style={{ + background: `linear-gradient(to right, var(--primary) ${pct}%, var(--border-strong) ${pct}%)`, + }} + className={cn( + "h-1.5 w-full cursor-pointer appearance-none rounded-full", + "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-card [&::-webkit-slider-thumb]:bg-foreground", + "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-card [&::-moz-range-thumb]:bg-foreground", + "[&::-moz-range-track]:bg-transparent", + className + )} + {...props} + /> + ) +} + +const chipBase = + "inline-flex h-9 items-center gap-2.5 rounded-[8px] border px-3 text-left text-sm transition-colors" +const chipOn = "border-primary/50 bg-primary-soft text-foreground" +const chipOff = "border-border bg-transparent text-muted-foreground hover:border-border-strong hover:text-foreground" + +/** Toggle chip (Settings → Map types). Dot + label, selected = --primary-soft fill + --primary @ 50% border. */ +export function FilterChip({ + selected, + dotClassName, + children, + className, + ...props +}: Omit, "type"> & { + selected: boolean + dotClassName: string +}) { + return ( + + ) +} + +/** Single-choice chip group with roving focus and arrow-key navigation (Settings → Accent color). */ +export function SwatchGroup({ + label, + labelId, + options, + value, + onValueChange, +}: { + label: string + labelId: string + options: { value: T; label: string; color: string }[] + value: T + onValueChange: (value: T) => void +}) { + const refs = React.useRef<(HTMLButtonElement | null)[]>([]) + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + const delta = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0 + if (!delta) return + e.preventDefault() + const next = (index + delta + options.length) % options.length + onValueChange(options[next].value) + refs.current[next]?.focus() + } + + return ( +
+ + {label} + +
+ {options.map((option, i) => { + const selected = option.value === value + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/settings/settings-form.tsx b/frontend/src/components/settings/settings-form.tsx new file mode 100644 index 0000000..80266e7 --- /dev/null +++ b/frontend/src/components/settings/settings-form.tsx @@ -0,0 +1,286 @@ +"use client" + +import { useEffect, useState } from "react" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" +import { + FilterChip, + NativeSelect, + RangeSlider, + SettingRow, + SettingsSection, + SwatchGroup, + SwitchRow, +} from "./settings-controls" +import { SettingsNav, type SettingsNavItem } from "./settings-nav" + +/* Visual-only settings form. All values live in local state and reset on reload. + TODO: replace with `useSettings()` from `src/lib/settings.tsx` (SettingsProvider, + localStorage `firstwave.settings`, backend URL validation) per design.md §4.3. */ + +const DEFAULT_BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000" + +type ResourceType = "medical" | "fire" | "police" | "pharmacy" +type Accent = "wave" | "indigo" | "amber" | "mint" + +type SettingsState = { + ttsEnabled: boolean + liveTranscription: boolean + recognitionLang: string + suggestFollowUps: boolean + showAgentPipeline: boolean + searchRadiusMi: number + mapTypes: Record + accent: Accent + reduceMotion: boolean + backendUrl: string +} + +const DEFAULTS: SettingsState = { + ttsEnabled: true, + liveTranscription: true, + recognitionLang: "en-US", + suggestFollowUps: true, + showAgentPipeline: true, + searchRadiusMi: 5, + mapTypes: { medical: true, fire: true, police: true, pharmacy: true }, + accent: "wave", + reduceMotion: false, + backendUrl: DEFAULT_BACKEND_URL, +} + +const NAV_ITEMS: SettingsNavItem[] = [ + { id: "voice", label: "Voice" }, + { id: "dispatch", label: "Dispatch" }, + { id: "map", label: "Map" }, + { id: "appearance", label: "Appearance" }, + { id: "connection", label: "Connection" }, +] + +const LANGUAGES = [ + { value: "en-US", label: "English (US)" }, + { value: "en-GB", label: "English (UK)" }, + { value: "es-US", label: "Spanish (US)" }, + { value: "es-ES", label: "Spanish (Spain)" }, + { value: "fr-FR", label: "French" }, + { value: "de-DE", label: "German" }, + { value: "zh-CN", label: "Chinese (Mandarin)" }, + { value: "vi-VN", label: "Vietnamese" }, +] + +const MAP_TYPES: { value: ResourceType; label: string; dot: string }[] = [ + { value: "medical", label: "Hospitals", dot: "bg-resource-medical" }, + { value: "fire", label: "Fire stations", dot: "bg-resource-fire" }, + { value: "police", label: "Police", dot: "bg-resource-police" }, + { value: "pharmacy", label: "Pharmacies", dot: "bg-resource-pharmacy" }, +] + +// Fixed swatch colors: `--primary` itself changes with the accent, so it can't be used here. +const ACCENTS: { value: Accent; label: string; color: string }[] = [ + { value: "wave", label: "Wave", color: "#38d4f2" }, + { value: "indigo", label: "Indigo", color: "#8b8cf8" }, + { value: "amber", label: "Amber", color: "var(--warning)" }, + { value: "mint", label: "Mint", color: "var(--success)" }, +] + +type HealthStatus = "checking" | "online" | "offline" + +/** One-off read-only /health check of the build-time backend, so the pill isn't fake. */ +function useBackendHealth(url: string): HealthStatus { + const [status, setStatus] = useState("checking") + useEffect(() => { + const controller = new AbortController() + fetch(`${url}/health`, { cache: "no-store", signal: controller.signal }) + .then((res) => setStatus(res.ok ? "online" : "offline")) + .catch(() => { + if (!controller.signal.aborted) setStatus("offline") + }) + return () => controller.abort() + }, [url]) + return status +} + +const HEALTH_LABEL: Record = { checking: "Checking", online: "Online", offline: "Offline" } + +export function SettingsForm() { + const [s, setS] = useState(DEFAULTS) + const set = (key: K, value: SettingsState[K]) => + setS((prev) => ({ ...prev, [key]: value })) + + const health = useBackendHealth(DEFAULT_BACKEND_URL) + + return ( +
+ + +
+ + set("ttsEnabled", v)} + /> + set("liveTranscription", v)} + /> + + set("recognitionLang", e.target.value)} + > + {LANGUAGES.map((lang) => ( + + ))} + + + + + + + set("suggestFollowUps", v)} + /> + set("showAgentPipeline", v)} + /> + + + +
+
+ + + {s.searchRadiusMi} mi + +
+ set("searchRadiusMi", v)} + aria-valuetext={`${s.searchRadiusMi} ${s.searchRadiusMi === 1 ? "mile" : "miles"}`} + /> +
+
+ + Show on map + +
+ {MAP_TYPES.map((type) => ( + set("mapTypes", { ...s.mapTypes, [type.value]: !s.mapTypes[type.value] })} + > + {type.label} + + ))} +
+
+
+ + +
+ set("accent", v)} + /> +
+ set("reduceMotion", v)} + /> +
+ + +
+ +
+ set("backendUrl", e.target.value)} + /> +
+ + + Backend status: + {HEALTH_LABEL[health]} + +
+
+
+
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/settings/settings-header.tsx b/frontend/src/components/settings/settings-header.tsx new file mode 100644 index 0000000..58eb5db --- /dev/null +++ b/frontend/src/components/settings/settings-header.tsx @@ -0,0 +1,23 @@ +import Link from "next/link" +import { Radio } from "lucide-react" +import { Button } from "@/components/ui/button" + +/** Settings page header: logo left, "Back to console" pill right (design.md §4.3). */ +export function SettingsHeader() { + return ( +
+
+ + + FirstWave + + + +
+
+ ) +} diff --git a/frontend/src/components/settings/settings-nav.tsx b/frontend/src/components/settings/settings-nav.tsx new file mode 100644 index 0000000..cbbb83d --- /dev/null +++ b/frontend/src/components/settings/settings-nav.tsx @@ -0,0 +1,78 @@ +"use client" + +import { useEffect, useState } from "react" +import { cn } from "@/lib/utils" + +export type SettingsNavItem = { id: string; label: string } + +/** + * Sticky section nav that scroll-spies the cards on the right (design.md §4.3). + * Desktop: vertical list. Mobile: horizontal scrolling tab row under the header. + */ +export function SettingsNav({ items }: { items: SettingsNavItem[] }) { + const [active, setActive] = useState(items[0]?.id) + + useEffect(() => { + const sections = items + .map((item) => document.getElementById(item.id)) + .filter((el): el is HTMLElement => el !== null) + if (!sections.length) return + + const visible = new Map() + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) visible.set(entry.target.id, entry.isIntersecting) + // Topmost section inside the activation band wins. + const first = items.find((item) => visible.get(item.id)) + if (first) setActive(first.id) + }, + { rootMargin: "-25% 0px -60% 0px" } + ) + sections.forEach((section) => observer.observe(section)) + + // At the very bottom the last (short) section may never reach the band. + const onScroll = () => { + const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 + if (atBottom) setActive(items[items.length - 1].id) + } + window.addEventListener("scroll", onScroll, { passive: true }) + + return () => { + observer.disconnect() + window.removeEventListener("scroll", onScroll) + } + }, [items]) + + return ( + + ) +} From 82f82644c069a7a1a4b1f680494db7c3d2e239d4 Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Fri, 23 Oct 2026 22:41:37 -0700 Subject: [PATCH 3/6] Restyle landing page to match redesign mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dashboard at / with the landing page from design.md §4.1: nav, hero with sample call card and wave rings, multi-agent pipeline grid, human-in-control priority scale, static resource map illustration, CTA band and footer. Console CTAs link to /console. Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/page.tsx | 22 ++- frontend/src/components/landing/hero.tsx | 115 ++++++++++++++ .../src/components/landing/landing-nav.tsx | 62 ++++++++ frontend/src/components/landing/links.ts | 4 + .../components/landing/map-illustration.tsx | 85 +++++++++++ frontend/src/components/landing/sections.tsx | 144 ++++++++++++++++++ 6 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/landing/hero.tsx create mode 100644 frontend/src/components/landing/landing-nav.tsx create mode 100644 frontend/src/components/landing/links.ts create mode 100644 frontend/src/components/landing/map-illustration.tsx create mode 100644 frontend/src/components/landing/sections.tsx diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 94235f5..68c873d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,11 +1,19 @@ -import { Header } from "@/components/header" -import { EmergencyDashboard } from "@/components/emergency-dashboard" +import { Hero } from "@/components/landing/hero" +import { LandingNav } from "@/components/landing/landing-nav" +import { CtaBand, LandingFooter, MapSection, PipelineSection, PrioritySection } from "@/components/landing/sections" -export default function Home() { +export default function LandingPage() { return ( -
-
- -
+
+ +
+ + + + + +
+ +
) } diff --git a/frontend/src/components/landing/hero.tsx b/frontend/src/components/landing/hero.tsx new file mode 100644 index 0000000..28da3e6 --- /dev/null +++ b/frontend/src/components/landing/hero.tsx @@ -0,0 +1,115 @@ +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { CONSOLE_HREF, DEMO_URL } from "./links" + +/** Thin concentric cyan rings behind the hero, top-right (~15% opacity). */ +function HeroRings() { + return ( + + ) +} + +function FactChip({ label, value }: { label: string; value: string }) { + return ( +
  • + {label} {value} +
  • + ) +} + +function SampleCallCard() { + return ( +
    +
    +

    +

    + Sample call +
    + +
    +
    +

    CALLER

    +

    + There was a crash. My dad is not waking up. We're on Market near Fifth. +

    +
    +
    +

    FIRSTWAVE

    +

    Stay with me. Is he breathing right now?

    +
    +
    + +
    + +
      + + + +
    + +
    + P1 +

    + Life-threatening. +
    + Recommend EMS now. +

    +

    Awaiting dispatcher approval

    +
    +
    + ) +} + +export function Hero() { + return ( +
    + +
    +
    +

    AI Emergency Dispatch Assistant

    +

    + The first wave of help starts with the first second of the call. +

    +

    + FirstWave talks to the caller, structures what it hears, and hands the dispatcher a triaged, mapped + recommendation. A human approves every dispatch. +

    +
    + + +
    +

    + Over 240 million emergency calls are made in the US each year (NENA). Key details get missed when callers + panic. +

    +
    + + +
    +
    + ) +} diff --git a/frontend/src/components/landing/landing-nav.tsx b/frontend/src/components/landing/landing-nav.tsx new file mode 100644 index 0000000..da705b2 --- /dev/null +++ b/frontend/src/components/landing/landing-nav.tsx @@ -0,0 +1,62 @@ +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { CONSOLE_HREF, SETTINGS_HREF } from "./links" + +/** `((•))` mark. Decorative: the wordmark next to it carries the name. */ +export function WaveLogo({ className }: { className?: string }) { + return ( + + ) +} + +const NAV_LINKS = [ + { href: "#pipeline", label: "Pipeline" }, + { href: "#priority", label: "Priority" }, + { href: "#map", label: "Map" }, +] + +const linkClass = + "rounded-full px-1 text-[15px] font-medium text-muted-foreground transition-colors hover:text-foreground" + +export function LandingNav() { + return ( +
    + +
    + ) +} diff --git a/frontend/src/components/landing/links.ts b/frontend/src/components/landing/links.ts new file mode 100644 index 0000000..385ffbd --- /dev/null +++ b/frontend/src/components/landing/links.ts @@ -0,0 +1,4 @@ +export const CONSOLE_HREF = "/console" +export const SETTINGS_HREF = "/settings" +export const DEMO_URL = "https://youtu.be/tViIWf1pbC8" +export const GITHUB_URL = "https://github.com/darrensh3n/FirstWaveAI" diff --git a/frontend/src/components/landing/map-illustration.tsx b/frontend/src/components/landing/map-illustration.tsx new file mode 100644 index 0000000..51ceca4 --- /dev/null +++ b/frontend/src/components/landing/map-illustration.tsx @@ -0,0 +1,85 @@ +import { Cross, Flame, Pill, Shield, type LucideIcon } from "lucide-react" + +// Static illustration only. The console uses the live Leaflet map. +const W = 614 +const H = 458 +const INCIDENT = { x: 307, y: 229 } + +type Marker = { label: string; legend: string; x: number; y: number; icon: LucideIcon; bg: string } + +const MARKERS: Marker[] = [ + { label: "Hospital", legend: "Hospitals", x: 187, y: 142, icon: Cross, bg: "bg-resource-medical" }, + { label: "Fire station", legend: "Fire stations", x: 438, y: 153, icon: Flame, bg: "bg-resource-fire" }, + { label: "Police", legend: "Police", x: 384, y: 339, icon: Shield, bg: "bg-resource-police" }, + { label: "Pharmacy", legend: "Pharmacies", x: 165, y: 317, icon: Pill, bg: "bg-resource-pharmacy" }, +] + +const pos = (x: number, y: number) => ({ left: `${(x / W) * 100}%`, top: `${(y / H) * 100}%` }) + +export function MapIllustration() { + return ( +
    +
    + + + {/* Incident pin: P1 color, dot + ring halo */} +
    + +
    +
      + {MARKERS.map(({ legend, bg }) => ( +
    • +
    • + ))} +
    +
    +
    + ) +} diff --git a/frontend/src/components/landing/sections.tsx b/frontend/src/components/landing/sections.tsx new file mode 100644 index 0000000..840d699 --- /dev/null +++ b/frontend/src/components/landing/sections.tsx @@ -0,0 +1,144 @@ +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { CONSOLE_HREF, GITHUB_URL } from "./links" +import { MapIllustration } from "./map-illustration" + +const container = "mx-auto max-w-[1120px] px-4 sm:px-6" +const h2Class = "mt-5 text-[32px] leading-[1.1] tracking-[-0.02em] sm:text-[40px]" +const copyClass = "mt-6 text-[17px] leading-[1.6] text-muted-foreground" + +const AGENTS = [ + { title: "Extraction", body: "Pulls location, injuries, hazards and headcount out of the live transcript." }, + { title: "Triage", body: "Classifies the emergency and assigns a P1 to P4 priority." }, + { title: "Next-Question", body: "Spots what is still missing and suggests the follow-up to ask." }, + { title: "Dispatch Planner", body: "Recommends EMS, Fire or Police for the incident." }, + { title: "Resource Locator", body: "Finds the nearest available units with ETAs." }, + { title: "Safety Guardrail", body: "Checks every recommendation before it reaches the dispatcher." }, +] + +export function PipelineSection() { + return ( +
    +
    +

    Multi-agent pipeline

    +

    + Six agents work the call
    while the caller is still talking. +

    +
      + {AGENTS.map((agent, i) => ( +
    1. + {String(i + 1).padStart(2, "0")} +

      {agent.title}

      +

      {agent.body}

      +
    2. + ))} +
    +
    +
    + ) +} + +const PRIORITIES = [ + { level: "P1", label: "Life-threatening, immediate response", color: "text-p1" }, + { level: "P2", label: "Urgent, serious but stable", color: "text-p2" }, + { level: "P3", label: "Needs a response, not time-critical", color: "text-p3" }, + { level: "P4", label: "Low priority, can wait", color: "text-p4" }, +] + +export function PrioritySection() { + return ( +
    +
    +
    +

    Human in control

    +

    + The AI recommends.
    The dispatcher decides. +

    +

    + Every recommendation lands with a priority, a rationale and the units to send. Nothing goes out until a + dispatcher approves it, and cancelling takes one click. +

    + {/* Illustration of the console controls. Non-functional, so hidden from assistive tech and tab order. */} + +
    + +
      + {PRIORITIES.map((p) => ( +
    • + + {p.level} + + {p.label} +
    • + ))} +
    +
    +
    + ) +} + +export function MapSection() { + return ( +
    +
    +
    + +
    +
    +

    Resource mapping

    +

    + See the nearest help before you send it. +

    +

    + An interactive map plots the incident and the closest hospitals, fire stations, police and pharmacies, each + with distance and travel time, built on Leaflet and OpenStreetMap. +

    +
    +
    +
    + ) +} + +export function CtaBand() { + return ( +
    +
    +
    +

    + Give every dispatcher
    a faster first minute. +

    +
    + + +
    +
    +
    +
    + ) +} + +export function LandingFooter() { + return ( +
    +

    FirstWave · MIT License

    +

    Next.js · FastAPI · LangGraph · Groq

    +
    + ) +} From 5c075f0e170dc2f489d9b8aa7f4e8a955550cbdf Mon Sep 17 00:00:00 2001 From: Darren Shen Date: Fri, 23 Oct 2026 22:47:17 -0700 Subject: [PATCH 4/6] Restyle dispatcher console to match redesign Add /console route, restyle header, title row, transcript, AI summary, dispatch, nearby resources (dark Leaflet map, circle markers, priority- tinted incident pin, legend filters) and a visual agent pipeline card. Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/console/page.tsx | 18 + frontend/src/app/globals.css | 110 +++++ frontend/src/components/agent-pipeline.tsx | 100 ++++ frontend/src/components/ai-summary.tsx | 194 ++++---- frontend/src/components/chat-panel.tsx | 279 +++++------- frontend/src/components/dispatch-panel.tsx | 283 +++++------- .../src/components/emergency-dashboard.tsx | 79 +++- frontend/src/components/header.tsx | 18 +- frontend/src/components/nearby-resources.tsx | 428 ++++++++---------- frontend/src/components/resource-map.tsx | 142 +++--- 10 files changed, 854 insertions(+), 797 deletions(-) create mode 100644 frontend/src/app/console/page.tsx create mode 100644 frontend/src/components/agent-pipeline.tsx diff --git a/frontend/src/app/console/page.tsx b/frontend/src/app/console/page.tsx new file mode 100644 index 0000000..eb89f32 --- /dev/null +++ b/frontend/src/app/console/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next" +import { Header } from "@/components/header" +import { EmergencyDashboard } from "@/components/emergency-dashboard" + +export const metadata: Metadata = { + title: "Dispatcher console · FirstWave", +} + +export default function ConsolePage() { + return ( +
    +
    +
    + +
    +
    + ) +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7713628..8895191 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -259,3 +259,113 @@ .leaflet-control-attribution a { color: var(--muted-foreground) !important; } + +/* ========================================================================== + Console map: Leaflet overrides (design.md §5) + Added by the console restyle. Markers, incident rings, zoom control and + popup content. Uses existing tokens only. + ========================================================================== */ +/* Dark basemap: invert the standard OSM tiles so the map doesn't glare. */ +.fw-map .leaflet-tile-pane { + filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.35); +} + +.fw-marker { + background: transparent !important; + border: none !important; +} + +.fw-marker__dot { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 9999px; + border: 2px solid var(--card); + box-shadow: 0 2px 6px rgb(0 0 0 / 0.5); +} + +.fw-incident { + position: relative; + display: block; + width: 44px; + height: 44px; +} + +.fw-incident__ring, +.fw-incident__dot { + position: absolute; + inset: 0; + margin: auto; + border-radius: 9999px; +} + +.fw-incident__ring { + width: 30px; + height: 30px; + border: 1.5px solid color-mix(in srgb, var(--fw-incident) 70%, transparent); +} + +.fw-incident__ring--outer { + width: 44px; + height: 44px; + border-color: color-mix(in srgb, var(--fw-incident) 40%, transparent); + transform-origin: center; +} + +/* The shared wave-ring keyframes scale to 2.4x, too big for a map pin. + Keeps the .animate-wave-ring class so the reduce-motion rules above apply. */ +.fw-incident__ring--outer.animate-wave-ring { + animation-name: fw-incident-ring; +} + +@keyframes fw-incident-ring { + 0% { + opacity: 0.9; + transform: scale(0.7); + } + 100% { + opacity: 0; + transform: scale(1.35); + } +} + +.fw-incident__dot { + width: 14px; + height: 14px; + background: var(--fw-incident); + border: 2px solid var(--card); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--fw-incident) 60%, transparent); +} + +.leaflet-container .leaflet-bar { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: none; +} + +.leaflet-container .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; +} + +.leaflet-container .leaflet-bar a:hover { + background: var(--card-raised) !important; +} + +.leaflet-container .leaflet-bar a.leaflet-disabled { + color: var(--subtle-foreground) !important; +} + +.leaflet-popup-content { + margin: 10px 14px; + font-size: 13px; + line-height: 1.45; +} + +.leaflet-control-attribution { + font-size: 10px; +} diff --git a/frontend/src/components/agent-pipeline.tsx b/frontend/src/components/agent-pipeline.tsx new file mode 100644 index 0000000..84d71c4 --- /dev/null +++ b/frontend/src/components/agent-pipeline.tsx @@ -0,0 +1,100 @@ +"use client" + +import { Check, Circle, Loader2 } from "lucide-react" +import { Card, CardAction, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +// Mirrors the backend LangGraph agents (same ids/names as agent-panel.tsx). +// design.md §4.2 wants these in src/lib/agents.ts; kept local until that file exists. +const AGENTS = [ + { id: "extraction", name: "Extraction Agent" }, + { id: "triage", name: "Triage Agent" }, + { id: "next_question", name: "Question Agent" }, + { id: "dispatch_planner", name: "Dispatch Planner" }, + { id: "resource_locator", name: "Resource Locator" }, + { id: "safety_guardrail", name: "Safety Guardrail" }, +] as const + +type RowStatus = "waiting" | "running" | "complete" + +const STATUS_LABEL: Record = { + waiting: "Waiting", + running: "Running", + complete: "Complete", +} + +interface AgentPipelineProps { + /** Agent id of the most recent SSE event, or null. */ + currentAgent: string | null + isProcessing: boolean + /** True once a run has produced a dispatch recommendation (all agents ran). */ + hasCompletedRun: boolean +} + +/** + * Visual pipeline card. Row state is derived from the dashboard's existing + * `currentAgent` / `isProcessing` values: each SSE event marks its agent (and the + * ones before it) complete and the next one running. + */ +export function AgentPipeline({ currentAgent, isProcessing, hasCompletedRun }: AgentPipelineProps) { + const currentIndex = currentAgent ? AGENTS.findIndex((a) => a.id === currentAgent) : -1 + // wait_for_info: the graph stopped after the question agent to ask for more details. + const pausedAfter = currentAgent === "wait_for_info" ? AGENTS.findIndex((a) => a.id === "next_question") : null + + const statusFor = (index: number): RowStatus => { + if (isProcessing && pausedAfter !== null) { + return index <= pausedAfter ? "complete" : "waiting" + } + if (isProcessing) { + if (index <= currentIndex) return "complete" + if (index === currentIndex + 1) return "running" + return "waiting" + } + return hasCompletedRun ? "complete" : "waiting" + } + + const statuses = AGENTS.map((_, i) => statusFor(i)) + const doneCount = statuses.filter((s) => s === "complete").length + + return ( + + + Agent pipeline + + {doneCount}/{AGENTS.length} + + +
      + {AGENTS.map((agent, i) => { + const status = statuses[i] + return ( +
    1. + + {status === "complete" ? ( + + + + ) : status === "running" ? ( + + ) : ( + + )} + + + {agent.name} + + + {STATUS_LABEL[status]} + +
    2. + ) + })} +
    +
    + ) +} diff --git a/frontend/src/components/ai-summary.tsx b/frontend/src/components/ai-summary.tsx index ba5da80..9f7e24e 100644 --- a/frontend/src/components/ai-summary.tsx +++ b/frontend/src/components/ai-summary.tsx @@ -1,6 +1,7 @@ "use client" -import {MapPin, AlertTriangle, Gauge, FileText, HelpCircle, Loader2} from "lucide-react" +import { Loader2 } from "lucide-react" +import { Card, CardAction, CardContent, CardHeader, CardTitle, CardTile } from "@/components/ui/card" export type SummaryData = { location: string | null @@ -15,135 +16,92 @@ interface AISummaryProps { data: SummaryData } -function SummaryField({ - icon: Icon, - label, - value, - isLoading -}: { - icon: React.ElementType - label: string - value: string | null - isLoading?: boolean -}) { +function Analyzing() { return ( -
    -
    - -
    -
    -

    {label}

    - {isLoading ? ( -
    - - Analyzing... -
    - ) : value ? ( -

    {value}

    - ) : ( -

    ?

    - )} -
    -
    + + + Analyzing… + ) } -function ListField({ - icon: Icon, - label, - items, - emptyText, - isLoading -}: { - icon: React.ElementType - label: string - items: string[] - emptyText: string - isLoading?: boolean -}) { +function SummaryTile({ label, value, isLoading }: { label: string; value: string | null; isLoading?: boolean }) { return ( -
    -
    - -
    -
    -

    {label}

    - {isLoading ? ( -
    - - Analyzing... -
    - ) : items.length > 0 ? ( -
      - {items.map((item) => ( -
    • - • - {item} -
    • - ))} -
    - ) : ( -

    {emptyText}

    - )} -
    -
    + +

    {label}

    + {isLoading ? ( + + ) : value ? ( +

    {value}

    + ) : ( +

    Unknown

    + )} +
    ) } -export function AISummary({data}: AISummaryProps) { - const {location, incident, priority, keyFacts, missingInfo, isProcessing} = data +export function AISummary({ data }: AISummaryProps) { + const { location, incident, keyFacts, missingInfo, isProcessing } = data + const hasData = Boolean(location || incident || keyFacts.length || missingInfo.length) return ( -
    - {/* Header */} -
    -

    Incident Summary

    -

    Analysis

    -
    - - {/* Summary Content */} -
    - - - - - + + + AI summary + {isProcessing && ( + + + Updating + + )} + -
    - + +
    + +
    -
    - -
    -
    -
    +
    +

    Key facts

    + {isProcessing && keyFacts.length === 0 ? ( + + ) : keyFacts.length > 0 ? ( +
      + {keyFacts.map((item) => ( +
    • + + {item} +
    • + ))} +
    + ) : ( +

    + {hasData ? "No key facts yet" : "Waiting for the caller…"} +

    + )} +
    + +
    +

    Still missing

    + {isProcessing && missingInfo.length === 0 ? ( + + ) : missingInfo.length > 0 ? ( +
      + {missingInfo.map((item) => ( +
    • + {item} +
    • + ))} +
    + ) : ( +

    {hasData ? "All information collected" : "—"}

    + )} +
    + + ) } diff --git a/frontend/src/components/chat-panel.tsx b/frontend/src/components/chat-panel.tsx index ab25b50..8ea73c3 100644 --- a/frontend/src/components/chat-panel.tsx +++ b/frontend/src/components/chat-panel.tsx @@ -1,10 +1,10 @@ "use client" import {useEffect, useRef, useState, useCallback} from "react" -import {Mic, MicOff, Send, User, Bot, Keyboard, MessageSquare, AlertCircle, Volume2, VolumeX} from "lucide-react" +import {Mic, MicOff, ArrowRight, AlertCircle, Volume2, VolumeX} from "lucide-react" import {Button} from "@/components/ui/button" -import {Textarea} from "@/components/ui/textarea" -import {Switch} from "@/components/ui/switch" +import {Card, CardAction, CardHeader, CardTitle} from "@/components/ui/card" +import {Input} from "@/components/ui/input" import {cn} from "@/lib/utils" export type ChatMessage = { @@ -53,12 +53,10 @@ export function ChatPanel({ isSpeaking = false, }: ChatPanelProps) { const [speechSupported, setSpeechSupported] = useState(true) - const [useTextInput, setUseTextInput] = useState(false) const [textInput, setTextInput] = useState("") const [speechError, setSpeechError] = useState(null) const recognitionRef = useRef(null) - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) + const logRef = useRef(null) // Use ref to track accumulated transcript to avoid stale closure issues const accumulatedTranscriptRef = useRef("") @@ -190,7 +188,9 @@ export function ChatPanel({ // Scroll to bottom when messages change useEffect(() => { - messagesEndRef.current?.scrollIntoView({behavior: "smooth"}) + // Scroll the transcript itself (scrollIntoView would also scroll the page) + const log = logRef.current + if (log) log.scrollTo({top: log.scrollHeight, behavior: "smooth"}) }, [messages]) const handleMicClick = useCallback(() => { @@ -248,105 +248,61 @@ export function ChatPanel({ } }, [handleTextSubmit]) - const toggleInputMode = useCallback(() => { - // Stop listening if switching to text mode - if (!useTextInput && isListening) { - intentionalStopRef.current = true - - // Clean shutdown of recognition - if (recognitionRef.current) { - recognitionRef.current.onresult = null - recognitionRef.current.onerror = null - recognitionRef.current.onend = null - try { - recognitionRef.current.stop() - } catch { - // Ignore errors - } - recognitionRef.current = null - } - - onStopListening() - onTranscriptChange("") - accumulatedTranscriptRef.current = "" - } - setSpeechError(null) - setUseTextInput(!useTextInput) - }, [useTextInput, isListening, onStopListening, onTranscriptChange]) - const dismissError = useCallback(() => { setSpeechError(null) }, []) + const isEmpty = messages.length === 0 && !currentTranscript + return ( -
    - {/* Header */} -
    -
    -
    -

    Caller ↔ AI Dispatcher

    -

    - {useTextInput ? "Text input mode" : "Voice input mode"} -

    -
    -
    - {/* TTS Toggle */} - {onTtsToggle && ( -
    - {ttsEnabled ? ( - - ) : ( - - )} - -
    - )} - {/* Status indicators */} - {isSpeaking && ( -
    - - Speaking -
    - )} - {isListening && ( -
    - - Recording -
    - )} + + + Live transcript + + {isSpeaking && ( + + + Speaking + + )} + {isListening && ( + + + Listening + + )} + {/* Spoken replies toggle. Lives here until Settings → Voice is wired up. */} + {onTtsToggle && ( -
    -
    -
    + )} + + {/* Speech Error Banner */} {speechError && ( -
    -
    - +
    +
    +

    {speechError}

    @@ -354,124 +310,93 @@ export function ChatPanel({ )} {/* Chat Messages */} -
    - {messages.length === 0 && !currentTranscript && ( -
    -
    - -
    -

    Chat bubbles appear here

    -

    Click start to begin recording

    +
    + {isEmpty && ( +
    +

    Waiting for the caller…

    +

    Type the caller's words or press the mic to listen.

    )} {messages.map((message) => (
    - {message.role === "ai" && ( -
    - -
    - )}
    + {message.role === "caller" ? "Caller: " : "FirstWave: "} {message.content}
    - {message.role === "caller" && ( -
    - -
    - )}
    ))} {/* Current transcript being recorded */} {currentTranscript && ( -
    -
    +
    +
    + Caller (live): {currentTranscript} - -
    -
    - +
    )} -
    {/* Input Area */} -
    - {useTextInput ? ( - /* Text Input Mode */ -
    -