From d0b2f2876223e2c41fb64a9f064e9892d12fee97 Mon Sep 17 00:00:00 2001 From: Brent Perteet Date: Thu, 20 Aug 2026 22:00:38 -0500 Subject: [PATCH] feat(sprint-3): refresh responsive UlHub portal --- web/.eslintrc.json | 12 + web/components/Layout.tsx | 162 +++-- web/components/PortalUi.tsx | 96 +++ web/components/map/esri/EsriJobMap.tsx | 94 +-- web/lib/use-job-stream.ts | 32 +- web/package.json | 3 +- web/pages/_app.tsx | 5 +- web/pages/index.tsx | 190 +++--- web/pages/jobs/[jobId].tsx | 245 +++++-- web/styles/global.css | 860 +++++++++++++++++++++++++ web/styles/tokens.css | 99 +++ web/tests/s3-portal.test.mjs | 140 ++++ 12 files changed, 1710 insertions(+), 228 deletions(-) create mode 100644 web/.eslintrc.json create mode 100644 web/components/PortalUi.tsx create mode 100644 web/styles/global.css create mode 100644 web/styles/tokens.css create mode 100644 web/tests/s3-portal.test.mjs diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..703116e --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "extends": "next/core-web-vitals", + "overrides": [ + { + "files": ["pages/settings/mqtt-certs.tsx", "pages/sim/index.tsx"], + "rules": { + "@next/next/no-html-link-for-pages": "off", + "react/no-unescaped-entities": "off" + } + } + ] +} diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index 09cc965..cbf6fd0 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -1,66 +1,134 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -import { ReactNode } from 'react'; -import { useAuth } from '../lib/auth-context'; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { ReactNode } from "react"; +import { useAuth } from "../lib/auth-context"; -export default function Layout({ children, title }: { children: ReactNode; title?: string }) { +const NAV_ITEMS = [ + { href: "/", label: "Jobs" }, + { href: "/settings/devices", label: "Devices" }, + { href: "/settings/members", label: "Members" }, + { href: "/settings/api-keys", label: "API keys" }, + { href: "/settings/mqtt-certs", label: "MQTT certs" }, +]; + +export default function Layout({ + children, + title, +}: { + children: ReactNode; + title?: string; +}) { const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth(); const router = useRouter(); + const isActive = (href: string) => + href === "/" + ? router.pathname === "/" || router.pathname.startsWith("/jobs") + : router.pathname.startsWith(href); + + const handleLogout = async () => { + await logout(); + router.push("/login"); + }; + + const navLinks = NAV_ITEMS.map((item) => ( + + {item.label} + + )); + return ( <> - {title ? `${title} · UlHub` : 'UlHub'} + {title ? `${title} · UlHub` : "UlHub"} -
- - UlHub + + Skip to main content + +
+ + + UlHub {user && ( <> -
-
{children}
+
+ {children} +
); } diff --git a/web/components/PortalUi.tsx b/web/components/PortalUi.tsx new file mode 100644 index 0000000..a5c77f1 --- /dev/null +++ b/web/components/PortalUi.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; + +type StatusTone = "info" | "success" | "warning" | "danger" | "neutral"; + +const STATUS_PRESENTATION: Record< + string, + { icon: string; label: string; tone: StatusTone } +> = { + OPEN: { icon: "○", label: "Open", tone: "info" }, + IN_PROGRESS: { icon: "◐", label: "In progress", tone: "warning" }, + COMPLETED: { icon: "✓", label: "Completed", tone: "success" }, + CANCELLED: { icon: "—", label: "Cancelled", tone: "neutral" }, + LIVE: { icon: "●", label: "Live", tone: "success" }, + CONNECTING: { icon: "↻", label: "Connecting", tone: "info" }, + OFFLINE: { icon: "○", label: "Offline · reconnecting", tone: "warning" }, + ERROR: { icon: "!", label: "Connection error · retrying", tone: "danger" }, +}; + +export function humanizeStatus(status: string): string { + return ( + STATUS_PRESENTATION[status]?.label ?? + status + .toLowerCase() + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + ); +} + +export function StatusBadge({ + status, + label, +}: { + status: string; + label?: string; +}) { + const presentation = STATUS_PRESENTATION[status] ?? { + icon: "•", + label: humanizeStatus(status), + tone: "neutral" as const, + }; + + return ( + + + {label ?? presentation.label} + + ); +} + +export function PageState({ + kind, + title, + children, +}: { + kind: "loading" | "empty" | "error"; + title: string; + children?: ReactNode; +}) { + const icon = kind === "error" ? "!" : kind === "empty" ? "□" : "…"; + + return ( +
+ +
+ {title} + {children &&
{children}
} +
+
+ ); +} + +export function Metric({ + label, + value, + detail, +}: { + label: string; + value: ReactNode; + detail?: ReactNode; +}) { + return ( +
+ {label} + {value} + {detail && {detail}} +
+ ); +} diff --git a/web/components/map/esri/EsriJobMap.tsx b/web/components/map/esri/EsriJobMap.tsx index 6290efa..ec1cd4c 100644 --- a/web/components/map/esri/EsriJobMap.tsx +++ b/web/components/map/esri/EsriJobMap.tsx @@ -9,7 +9,6 @@ import type MapViewConstructor from '@arcgis/core/views/MapView.js'; import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || ''; -const LIVE_COLOR = '#1a73e8'; const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat] const DEFAULT_ZOOM = 4; @@ -63,6 +62,10 @@ function colorFor(utilityType: string): string { return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN; } +function semanticColor(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + function orderKey(p: MapPoint): number { return p.sequence ?? new Date(p.recordedAt).getTime(); } @@ -78,10 +81,10 @@ function popupHtml(point: MapPoint): string { const rows = pointDetailRows(point) .map( ([label, value]) => - `${label}${value}`, + `${label}${value}`, ) .join(''); - return `${rows}
`; + return `${rows}
`; } // Esri opens/closes/dismisses-on-outside-click popups natively via each @@ -94,7 +97,7 @@ function pointGraphic(mods: EsriModules, point: MapPoint, color: string) { style: 'circle', color, size: 10, - outline: { color: '#ffffff', width: 1.5 }, + outline: { color: semanticColor('--ul-color-on-primary'), width: 1.5 }, }, popupTemplate: { title: `${point.utilityType} · ${point.fixType}`, @@ -119,15 +122,16 @@ function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) { } function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) { + const liveColor = semanticColor('--ul-color-primary'); const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat }; const marker = new mods.Graphic({ geometry: position, symbol: { type: 'simple-marker', style: 'circle', - color: LIVE_COLOR, + color: liveColor, size: 16, - outline: { color: '#ffffff', width: 2 }, + outline: { color: semanticColor('--ul-color-on-primary'), width: 2 }, }, popupTemplate: { title: `Live · ${status.serial}`, @@ -144,15 +148,15 @@ function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) { }), symbol: { type: 'simple-fill', - color: [...hexToRgb(LIVE_COLOR), 0.15], - outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 }, + color: [...hexChannels(liveColor), 0.15], + outline: { color: [...hexChannels(liveColor), 0.3], width: 1 }, }, } as ConstructorParameters[0]); return [halo, marker]; } -function hexToRgb(hex: string): [number, number, number] { +function hexChannels(hex: string): [number, number, number] { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } @@ -165,6 +169,9 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true const modsRef = useRef(null); const fittedSignatureRef = useRef(''); const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(false); + const sizeClass = heightPx <= 360 ? 'ul-map--compact' : heightPx >= 500 ? 'ul-map--detail' : ''; + const mapClassName = ['ul-map', sizeClass].filter(Boolean).join(' '); useEffect(() => { if (!containerRef.current || !API_KEY) { @@ -173,26 +180,33 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true let cancelled = false; let view: InstanceType | undefined; - loadEsriModules().then((mods) => { - if (cancelled || !containerRef.current) { - return; - } - const pointsLayer = new mods.GraphicsLayer(); - const liveLayer = new mods.GraphicsLayer(); - const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] }); - view = new mods.MapView({ - container: containerRef.current, - map, - center: DEFAULT_CENTER, - zoom: DEFAULT_ZOOM, - }); + loadEsriModules() + .then((mods) => { + if (cancelled || !containerRef.current) { + return; + } + const pointsLayer = new mods.GraphicsLayer(); + const liveLayer = new mods.GraphicsLayer(); + const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] }); + view = new mods.MapView({ + container: containerRef.current, + map, + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + }); - modsRef.current = mods; - pointsLayerRef.current = pointsLayer; - liveLayerRef.current = liveLayer; - viewRef.current = view; - setReady(true); - }); + modsRef.current = mods; + pointsLayerRef.current = pointsLayer; + liveLayerRef.current = liveLayer; + viewRef.current = view; + setReady(true); + }) + .catch((error: Error) => { + if (!cancelled) { + console.error('EsriJobMap: map failed to load', error); + setLoadError(true); + } + }); return () => { cancelled = true; @@ -267,25 +281,23 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true if (!API_KEY) { return ( -
+
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
); } + if (loadError) { + return ( +
+ The locate map could not be loaded. Check the network connection and ArcGIS configuration. +
+ ); + } + return ( -
-
+
+
); } diff --git a/web/lib/use-job-stream.ts b/web/lib/use-job-stream.ts index 29e64dc..a53b646 100644 --- a/web/lib/use-job-stream.ts +++ b/web/lib/use-job-stream.ts @@ -1,5 +1,7 @@ -import { useEffect, useRef } from 'react'; -import type { LiveStatus, MapPoint } from '../components/map/types'; +import { useEffect, useRef, useState } from "react"; +import type { LiveStatus, MapPoint } from "../components/map/types"; + +export type JobStreamState = "connecting" | "live" | "offline" | "error"; // Subscribes to live points + live position status for a job over the // backend WebSocket. Reconnects with capped exponential backoff; resubscribes @@ -9,6 +11,8 @@ export function useJobStream( onPoints: (points: MapPoint[]) => void, onStatus?: (status: LiveStatus) => void, ) { + const [connectionState, setConnectionState] = + useState("connecting"); const pointsRef = useRef(onPoints); pointsRef.current = onPoints; const statusRef = useRef(onStatus); @@ -16,6 +20,7 @@ export function useJobStream( useEffect(() => { if (!jobId) { + setConnectionState("offline"); return; } @@ -25,12 +30,17 @@ export function useJobStream( let timer: ReturnType | null = null; const connect = () => { - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + let socketErrored = false; + setConnectionState(attempt === 0 ? "connecting" : "offline"); + const proto = window.location.protocol === "https:" ? "wss" : "ws"; socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); socket.onopen = () => { attempt = 0; - socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` })); + setConnectionState("live"); + socket?.send( + JSON.stringify({ type: "subscribe", channel: `job:${jobId}` }), + ); }; socket.onmessage = (event) => { @@ -39,9 +49,9 @@ export function useJobStream( if (msg.jobId !== jobId) { return; } - if (msg.type === 'points') { + if (msg.type === "points") { pointsRef.current(msg.points); - } else if (msg.type === 'status') { + } else if (msg.type === "status") { statusRef.current?.(msg as LiveStatus); } } catch { @@ -49,10 +59,18 @@ export function useJobStream( } }; + socket.onerror = () => { + socketErrored = true; + setConnectionState("error"); + }; + socket.onclose = () => { if (closed) { return; } + if (!socketErrored) { + setConnectionState("offline"); + } attempt += 1; const delay = Math.min(1000 * 2 ** attempt, 15000); timer = setTimeout(connect, delay); @@ -69,4 +87,6 @@ export function useJobStream( socket?.close(); }; }, [jobId]); + + return connectionState; } diff --git a/web/package.json b/web/package.json index 4af1ae5..097c29b 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 3000", "build": "next build", "start": "next start -p 3000", - "lint": "next lint" + "lint": "next lint", + "test": "node --test tests/*.test.mjs" }, "dependencies": { "esri-loader": "^3.7.0", diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index e661bbb..67cb49f 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -1,5 +1,6 @@ -import type { AppProps } from 'next/app'; -import { AuthProvider } from '../lib/auth-context'; +import type { AppProps } from "next/app"; +import { AuthProvider } from "../lib/auth-context"; +import "../styles/global.css"; export default function App({ Component, pageProps }: AppProps) { return ( diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 462b93f..202247b 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,8 +1,9 @@ -import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import Layout from '../components/Layout'; -import { api } from '../lib/api'; -import { useRequireAuth } from '../lib/auth-context'; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import Layout from "../components/Layout"; +import { humanizeStatus, PageState, StatusBadge } from "../components/PortalUi"; +import { api } from "../lib/api"; +import { useRequireAuth } from "../lib/auth-context"; interface JobRow { id: string; @@ -16,103 +17,146 @@ interface JobRow { _count: { points: number }; } -const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; - -const STATUS_COLORS: Record = { - OPEN: '#1976d2', - IN_PROGRESS: '#f57c00', - COMPLETED: '#388e3c', - CANCELLED: '#9e9e9e', -}; +const STATUSES = ["", "OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"]; export default function JobsPage() { const { user, activeOrg, loading } = useRequireAuth(); const [jobs, setJobs] = useState([]); const [total, setTotal] = useState(0); - const [status, setStatus] = useState(''); - const [q, setQ] = useState(''); + const [status, setStatus] = useState(""); + const [q, setQ] = useState(""); const [error, setError] = useState(null); + const [fetching, setFetching] = useState(false); useEffect(() => { if (!activeOrg) { return; } + let cancelled = false; const params = new URLSearchParams(); - if (status) params.set('status', status); - if (q) params.set('q', q); + if (status) params.set("status", status); + if (q) params.set("q", q); + setFetching(true); api - .get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`) + .get<{ jobs: JobRow[]; total: number }>( + `/api/orgs/${activeOrg.org.id}/jobs?${params}`, + ) .then((res) => { + if (cancelled) return; setJobs(res.jobs); setTotal(res.total); setError(null); }) - .catch((err) => setError(err.message)); + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setFetching(false); + }); + return () => { + cancelled = true; + }; }, [activeOrg, status, q]); if (loading || !user) { - return null; + return ; } return ( -
-

Jobs

- {total} total -
- setQ(e.target.value)} /> - setQ(event.target.value)} + /> + + + + + {error ? ( + + {error} + + ) : jobs.length === 0 && !fetching ? ( + + {q || status + ? "Try clearing or changing the current filters." + : "Create a job, or let a field device post points to auto-create its ticket."} + + ) : ( +
+ + + + + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + + + ))} + +
+ Jobs for the active organization +
TicketTitleAddressStatusSourceAssignee + Points +
+ + {job.ticketNumber} + + {job.title}{job.address ?? "—"} + + {humanizeStatus(job.source)}{job.assignedTo?.name ?? "—"} + {job._count.points} +
-
- - {error &&

{error}

} - - - - - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - - - - ))} - {jobs.length === 0 && !error && ( - - - - )} - -
TicketTitleAddressStatusSourceAssigneePoints
- {job.ticketNumber} - {job.title}{job.address ?? '—'} - {job.status} - {job.source}{job.assignedTo?.name ?? '—'}{job._count.points}
- No jobs yet. Create one, or let a device post points to auto-create its ticket. -
+ )} ); } diff --git a/web/pages/jobs/[jobId].tsx b/web/pages/jobs/[jobId].tsx index afe80a4..cf440e5 100644 --- a/web/pages/jobs/[jobId].tsx +++ b/web/pages/jobs/[jobId].tsx @@ -1,11 +1,22 @@ -import { useRouter } from 'next/router'; -import { useCallback, useEffect, useState } from 'react'; -import Layout from '../../components/Layout'; -import JobMap from '../../components/map/JobMap'; -import { pointSummary, type LiveStatus, type MapPoint } from '../../components/map/types'; -import { api } from '../../lib/api'; -import { useRequireAuth } from '../../lib/auth-context'; -import { useJobStream } from '../../lib/use-job-stream'; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useState } from "react"; +import Layout from "../../components/Layout"; +import { + humanizeStatus, + Metric, + PageState, + StatusBadge, +} from "../../components/PortalUi"; +import JobMap from "../../components/map/JobMap"; +import { + pointSummary, + type LiveStatus, + type MapPoint, +} from "../../components/map/types"; +import { api } from "../../lib/api"; +import { useRequireAuth } from "../../lib/auth-context"; +import { useJobStream } from "../../lib/use-job-stream"; interface JobDetail { id: string; @@ -21,12 +32,13 @@ interface JobDetail { _count: { points: number }; } -const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; +const STATUSES = ["OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"]; export default function JobDetailPage() { const { user, activeOrg, loading } = useRequireAuth(); const router = useRouter(); - const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null; + const jobId = + typeof router.query.jobId === "string" ? router.query.jobId : null; const orgId = activeOrg?.org.id ?? null; const [job, setJob] = useState(null); @@ -34,19 +46,40 @@ export default function JobDetailPage() { const [live, setLive] = useState(0); const [liveStatus, setLiveStatus] = useState(null); const [error, setError] = useState(null); + const [fetching, setFetching] = useState(false); + const [updatingStatus, setUpdatingStatus] = useState(false); useEffect(() => { if (!orgId || !jobId) { return; } - api - .get(`/api/orgs/${orgId}/jobs/${jobId}`) - .then(setJob) - .catch((err) => setError(err.message)); - api - .get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`) - .then((res) => setPoints(res.points)) - .catch((err) => setError(err.message)); + let cancelled = false; + setJob(null); + setPoints([]); + setLive(0); + setLiveStatus(null); + setError(null); + setFetching(true); + Promise.all([ + api.get(`/api/orgs/${orgId}/jobs/${jobId}`), + api.get<{ points: MapPoint[] }>( + `/api/orgs/${orgId}/jobs/${jobId}/points`, + ), + ]) + .then(([jobResult, pointResult]) => { + if (cancelled) return; + setJob(jobResult); + setPoints(pointResult.points); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setFetching(false); + }); + return () => { + cancelled = true; + }; }, [orgId, jobId]); const onLivePoints = useCallback((incoming: MapPoint[]) => { @@ -58,67 +91,163 @@ export default function JobDetailPage() { setLive((n) => n + incoming.length); }, []); - const onStatus = useCallback((status: LiveStatus) => setLiveStatus(status), []); + const onStatus = useCallback( + (status: LiveStatus) => setLiveStatus(status), + [], + ); - useJobStream(jobId, onLivePoints, onStatus); + const streamState = useJobStream(jobId, onLivePoints, onStatus); const updateStatus = async (status: string) => { if (!orgId || !jobId) { return; } + setUpdatingStatus(true); try { - setJob(await api.patch(`/api/orgs/${orgId}/jobs/${jobId}`, { status })); + setJob( + await api.patch(`/api/orgs/${orgId}/jobs/${jobId}`, { + status, + }), + ); + setError(null); } catch (err: any) { setError(err.message); + } finally { + setUpdatingStatus(false); } }; if (loading || !user) { - return null; + return ; } - return ( - - {error &&

{error}

} - {job && ( - <> -
-

{job.ticketNumber}

- {job.title} - -
-

- {job.address && <>{job.address} · } - source {job.source} · created {new Date(job.createdAt).toLocaleString()} - {job.assignedTo && <> · assigned to {job.assignedTo.name}} - {job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}} -

- {job.description &&

{job.description}

} + const streamStatus = streamState.toUpperCase(); + const streamLabel = + streamState === "live" + ? live > 0 + ? `Live · ${live} new ${live === 1 ? "point" : "points"} this session` + : "Live · waiting for activity" + : undefined; -
- - {points.length} points - - 0 ? '#388e3c' : '#999' }}> - ● live{live > 0 ? ` (+${live} this session)` : ''} - - {points.length > 0 && ( - latest: {pointSummary(points[points.length - 1])} + return ( + + + + {error && !job ? ( + + {error} + + ) : fetching || !job ? ( + + ) : ( + <> +
+
+ Locate job +
+

{job.ticketNumber}

+ {job.title} +
+

+ {job.address ?? "No address"} · {humanizeStatus(job.source)}{" "} + source +

+
+ +
+ + {error && ( + + {error} + + )} + +
+ } + /> + + + + + + {job.description && ( +

{job.description}

)} - {liveStatus && ( - - ● transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()}) +
+ +
+ + {points.length > 0 && ( + + Latest point: {pointSummary(points[points.length - 1])} )} + {liveStatus && ( + + )}
- +
+
+
+ + Live operations + +

Locate map

+
+ +
+ +
)}
diff --git a/web/styles/global.css b/web/styles/global.css new file mode 100644 index 0000000..55e153b --- /dev/null +++ b/web/styles/global.css @@ -0,0 +1,860 @@ +@import "./tokens.css"; + +:root { + color-scheme: light; + --portal-canvas: var(--ul-color-canvas); + --portal-surface: var(--ul-color-surface); + --portal-surface-subtle: var(--ul-color-surface-raised); + --portal-map-field: var(--ul-color-map); + --portal-text: var(--ul-color-text); + --portal-text-muted: var(--ul-color-text-muted); + --portal-primary: var(--ul-color-primary); + --portal-on-primary: var(--ul-color-on-primary); + --portal-attention: var(--ul-color-accent); + --portal-success: var(--ul-color-success); + --portal-warning: var(--ul-color-warning); + --portal-danger: var(--ul-color-error); + --portal-border: var(--ul-color-border); + --portal-border-strong: var(--ul-color-text-muted); + --portal-focus: var(--ul-color-focus); + --portal-font-ui: var(--ul-type-family-sans); + --portal-font-mono: var(--ul-type-family-mono); + --portal-space-1: var(--ul-space-2xs); + --portal-space-2: var(--ul-space-xs); + --portal-space-3: var(--ul-space-sm); + --portal-space-4: var(--ul-space-md); + --portal-space-6: var(--ul-space-lg); + --portal-space-8: var(--ul-space-xl); + --portal-radius-sm: var(--ul-radius-sm); + --portal-radius-md: var(--ul-radius-md); + --portal-shadow: var(--ul-elevation-raised); + --portal-motion: var(--ul-motion-base) ease-out; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--portal-canvas); + color: var(--portal-text); + font-family: var(--portal-font-ui); +} + +body { + min-width: 20rem; + min-height: 100vh; + margin: 0; + background: var(--portal-canvas); + color: var(--portal-text); + font-family: var(--portal-font-ui); + line-height: 1.5; +} + +a { + color: var(--portal-primary); + text-underline-offset: 0.2em; +} + +button, +input, +select, +textarea { + color: inherit; + font: inherit; +} + +button, +select, +input[type="button"], +input[type="submit"] { + min-height: var(--ul-touch-minimum); +} + +input, +select, +textarea { + min-height: var(--ul-touch-minimum); + border: 1px solid var(--portal-border-strong); + border-radius: var(--portal-radius-sm); + background: var(--portal-surface); + padding: var(--portal-space-2) var(--portal-space-3); +} + +:where(a, button, input, select, textarea, summary):focus-visible { + outline: 3px solid var(--portal-focus); + outline-offset: 3px; +} + +.ul-skip-link { + position: fixed; + z-index: 1000; + top: var(--portal-space-2); + left: var(--portal-space-2); + translate: 0 -200%; + border-radius: var(--portal-radius-sm); + background: var(--portal-surface); + padding: var(--portal-space-3) var(--portal-space-4); + color: var(--portal-text); + font-weight: 700; + box-shadow: var(--portal-shadow); +} + +.ul-skip-link:focus { + translate: 0; +} + +.ul-shell__header { + position: sticky; + z-index: 50; + top: 0; + display: flex; + min-height: 4.5rem; + align-items: center; + gap: var(--portal-space-6); + border-bottom: 1px solid var(--portal-border); + background: var(--portal-surface); + padding: var(--portal-space-3) clamp(1rem, 3vw, 2rem); + box-shadow: var(--portal-shadow); +} + +.ul-shell__brand { + display: inline-flex; + flex: 0 0 auto; + min-height: var(--ul-touch-minimum); + align-items: center; + gap: var(--portal-space-2); + color: var(--portal-text); + font-size: 1.125rem; + font-weight: 750; + text-decoration: none; +} + +.ul-shell__brand-mark { + display: inline-grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border-radius: var(--portal-radius-sm); + background: var(--portal-primary); + color: var(--portal-on-primary); + font-family: var(--portal-font-mono); + font-size: 0.875rem; + letter-spacing: 0.04em; +} + +.ul-shell__nav { + display: flex; + align-items: center; + gap: var(--portal-space-1); +} + +.ul-shell__nav-link { + display: inline-flex; + min-height: var(--ul-touch-minimum); + align-items: center; + border-radius: var(--portal-radius-sm); + padding: 0 var(--portal-space-3); + color: var(--portal-text-muted); + font-weight: 650; + text-decoration: none; +} + +.ul-shell__nav-link:hover, +.ul-shell__nav-link[aria-current="page"] { + background: var(--portal-surface-subtle); + color: var(--portal-text); +} + +.ul-shell__nav-link[aria-current="page"] { + box-shadow: inset 0 -0.2rem var(--portal-primary); +} + +.ul-shell__context { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + justify-content: flex-end; + gap: var(--portal-space-4); +} + +.ul-org-context { + display: grid; + min-width: 10rem; + grid-template-columns: minmax(0, auto) auto; + align-items: center; + column-gap: var(--portal-space-2); +} + +.ul-org-context__label { + grid-column: 1 / -1; + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.ul-org-context__select { + max-width: 13rem; + height: var(--ul-touch-minimum); + min-height: var(--ul-touch-minimum); + padding-block: 0; + font-weight: 700; +} + +.ul-org-context__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ul-org-context__role { + border: 1px solid var(--portal-border); + border-radius: 999px; + padding: 0 var(--portal-space-2); + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: lowercase; +} + +.ul-shell__account { + display: flex; + align-items: center; + gap: var(--portal-space-2); +} + +.ul-shell__email { + max-width: 12rem; + overflow: hidden; + color: var(--portal-text-muted); + font-size: 0.875rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ul-shell__menu { + display: none; + position: relative; +} + +.ul-shell__menu summary { + display: inline-flex; + min-height: var(--ul-touch-minimum); + cursor: pointer; + align-items: center; + border: 1px solid var(--portal-border-strong); + border-radius: var(--portal-radius-sm); + padding: 0 var(--portal-space-3); + font-weight: 700; + list-style: none; +} + +.ul-shell__menu summary::-webkit-details-marker { + display: none; +} + +.ul-shell__nav--mobile { + position: absolute; + top: calc(100% + var(--portal-space-2)); + right: 0; + width: min(18rem, calc(100vw - 2rem)); + flex-direction: column; + align-items: stretch; + border: 1px solid var(--portal-border); + border-radius: var(--portal-radius-md); + background: var(--portal-surface); + padding: var(--portal-space-2); + box-shadow: var(--portal-shadow); +} + +.ul-shell__mobile-account { + display: grid; + gap: var(--portal-space-2); + margin-top: var(--portal-space-2); + border-top: 1px solid var(--portal-border); + padding: var(--portal-space-3) var(--portal-space-2) var(--portal-space-2); + color: var(--portal-text-muted); + overflow-wrap: anywhere; +} + +.ul-shell__main { + width: min(100% - 2rem, 75rem); + margin-inline: auto; + padding-block: clamp(1.25rem, 4vw, 2.5rem) 4rem; +} + +.ul-button { + display: inline-flex; + min-height: var(--ul-touch-minimum); + cursor: pointer; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--portal-radius-sm); + background: var(--portal-primary); + padding: 0 var(--portal-space-4); + color: var(--portal-on-primary); + font-weight: 700; + text-decoration: none; + transition: + filter var(--portal-motion), + transform var(--portal-motion); +} + +.ul-button:hover { + filter: brightness(0.9); +} + +.ul-button:active { + transform: translateY(1px); +} + +.ul-button:disabled { + cursor: not-allowed; + filter: grayscale(1); + opacity: 0.6; +} + +.ul-button--quiet { + border-color: var(--portal-border); + background: transparent; + color: var(--portal-text); +} + +.ul-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--portal-space-4); + margin-bottom: var(--portal-space-6); +} + +.ul-page-header__title-group { + min-width: 0; +} + +.ul-page-header h1, +.ul-detail-header h1 { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.5rem); + line-height: 1.15; +} + +.ul-page-header__eyebrow, +.ul-section-heading__eyebrow, +.ul-detail-header__eyebrow { + display: block; + margin-bottom: var(--portal-space-1); + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ul-page-header__meta, +.ul-detail-header__subtitle { + margin: var(--portal-space-1) 0 0; + color: var(--portal-text-muted); +} + +.ul-panel { + border: 1px solid var(--portal-border); + border-radius: var(--portal-radius-md); + background: var(--portal-surface); + box-shadow: var(--portal-shadow); +} + +.ul-filter-bar { + display: grid; + grid-template-columns: minmax(14rem, 1fr) minmax(10rem, auto); + gap: var(--portal-space-3); + margin-bottom: var(--portal-space-4); + padding: var(--portal-space-4); +} + +.ul-field { + display: grid; + gap: var(--portal-space-1); +} + +.ul-field__label { + color: var(--portal-text-muted); + font-size: 0.875rem; + font-weight: 700; +} + +.ul-table-wrap { + overflow-x: auto; +} + +.ul-table { + width: 100%; + border-collapse: collapse; +} + +.ul-table th, +.ul-table td { + padding: var(--portal-space-3) var(--portal-space-4); + border-bottom: 1px solid var(--portal-border); + text-align: left; + vertical-align: middle; +} + +.ul-table th { + background: var(--portal-surface-subtle); + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ul-table tbody tr:last-child td { + border-bottom: 0; +} + +.ul-table tbody tr:hover { + background: color-mix( + in srgb, + var(--portal-primary) 5%, + var(--portal-surface) + ); +} + +.ul-table__numeric { + font-family: var(--portal-font-mono); + font-variant-numeric: tabular-nums; + text-align: right !important; +} + +.ul-ticket-link { + display: inline-flex; + min-height: var(--ul-touch-minimum); + align-items: center; + font-family: var(--portal-font-mono); + font-weight: 700; +} + +.ul-status-badge { + display: inline-flex; + min-height: 2rem; + align-items: center; + gap: var(--portal-space-2); + border: 1px solid currentColor; + border-radius: 999px; + padding: 0 var(--portal-space-3); + font-size: 0.8125rem; + font-weight: 750; + line-height: 1.2; + white-space: nowrap; +} + +.ul-status-badge__icon { + font-family: var(--portal-font-mono); + font-size: 1rem; +} + +.ul-status-badge--info { + color: var(--portal-primary); +} + +.ul-status-badge--success { + color: var(--portal-success); +} + +.ul-status-badge--warning { + color: var(--portal-warning); +} + +.ul-status-badge--danger { + color: var(--portal-danger); +} + +.ul-status-badge--neutral { + color: var(--portal-text-muted); +} + +.ul-page-state { + display: flex; + min-height: 7rem; + align-items: center; + justify-content: center; + gap: var(--portal-space-3); + border: 1px dashed var(--portal-border-strong); + border-radius: var(--portal-radius-md); + background: var(--portal-surface-subtle); + padding: var(--portal-space-6); + color: var(--portal-text-muted); + text-align: left; +} + +.ul-page-state--error { + border-style: solid; + color: var(--portal-danger); +} + +.ul-page-state__icon { + display: inline-grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid currentColor; + border-radius: 50%; + font-family: var(--portal-font-mono); + font-weight: 800; +} + +.ul-page-state__detail { + margin-top: var(--portal-space-1); +} + +.ul-detail-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(12rem, auto); + gap: var(--portal-space-6); + align-items: end; + margin-bottom: var(--portal-space-4); +} + +.ul-detail-header__title-row { + display: flex; + align-items: baseline; + gap: var(--portal-space-3); + flex-wrap: wrap; +} + +.ul-detail-header__title { + font-size: clamp(1.125rem, 2vw, 1.375rem); + font-weight: 550; +} + +.ul-breadcrumb { + margin-bottom: var(--portal-space-4); +} + +.ul-breadcrumb a { + display: inline-flex; + min-height: var(--ul-touch-minimum); + align-items: center; + font-weight: 700; +} + +.ul-job-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--portal-space-4); + margin-block: var(--portal-space-4) var(--portal-space-6); + padding: var(--portal-space-4); +} + +.ul-job-summary__description { + grid-column: 1 / -1; + margin: 0; + padding-top: var(--portal-space-3); + border-top: 1px solid var(--portal-border); +} + +.ul-metric { + display: grid; + align-content: start; + gap: var(--portal-space-1); +} + +.ul-metric__label { + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.ul-metric__value { + font-family: var(--portal-font-mono); + font-size: 1.5rem; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.ul-metric__detail { + color: var(--portal-text-muted); + font-size: 0.875rem; +} + +.ul-live-strip { + display: flex; + align-items: center; + gap: var(--portal-space-3); + flex-wrap: wrap; + margin-bottom: var(--portal-space-4); + border: 1px solid var(--portal-border); + border-radius: var(--portal-radius-md); + background: var(--portal-surface); + padding: var(--portal-space-3) var(--portal-space-4); +} + +.ul-live-strip__latest { + min-width: 12rem; + flex: 1 1 auto; + color: var(--portal-text-muted); + font-family: var(--portal-font-mono); + font-size: 0.875rem; +} + +.ul-map-section { + overflow: hidden; + border: 1px solid var(--portal-border); + border-radius: var(--portal-radius-md); + background: var(--portal-map-field); + box-shadow: var(--portal-shadow); +} + +.ul-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--portal-space-4); + border-bottom: 1px solid var(--portal-border); + background: var(--portal-surface); + padding: var(--portal-space-3) var(--portal-space-4); +} + +.ul-section-heading h2 { + margin: 0; + font-size: 1.25rem; +} + +.ul-map { + position: relative; + height: 30rem; + overflow: hidden; + background: var(--portal-map-field); +} + +.ul-map--compact { + height: 20rem; +} + +.ul-map--detail { + height: min(32.5rem, 65vh); + min-height: 24rem; +} + +.ul-map__canvas { + height: 100%; +} + +.ul-map__fallback { + display: grid; + place-items: center; + padding: var(--portal-space-6); +} + +.ul-map-popup-table { + border-collapse: collapse; + font-family: var(--portal-font-ui); +} + +.ul-map-popup-table td { + padding-block: var(--portal-space-1); +} + +.ul-map-popup-table__label { + padding-right: var(--portal-space-3); + color: var(--portal-text-muted); + white-space: nowrap; +} + +.ul-map-popup-table__value { + font-family: var(--portal-font-mono); + font-weight: 650; +} + +.ul-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; +} + +/* CSS custom properties are not valid in media conditions; values mirror the generated breakpoint tokens. */ +@media (max-width: 1200px) { + .ul-shell__nav--desktop, + .ul-shell__email { + display: none; + } + + .ul-shell__menu { + display: block; + } +} + +@media (max-width: 900px) { + .ul-shell__header { + align-items: flex-start; + gap: var(--portal-space-3); + flex-wrap: wrap; + } + + .ul-shell__context { + width: 100%; + justify-content: space-between; + } + + .ul-org-context { + min-width: 0; + flex: 1 1 auto; + } + + .ul-shell__account { + order: 3; + } + + .ul-page-header, + .ul-detail-header { + grid-template-columns: 1fr; + flex-direction: column; + } + + .ul-page-header .ul-button { + width: 100%; + } + + .ul-filter-bar { + grid-template-columns: 1fr; + } + + .ul-table-wrap { + overflow: visible; + } + + .ul-table, + .ul-table tbody, + .ul-table tr, + .ul-table td { + display: block; + width: 100%; + } + + .ul-table thead { + display: none; + } + + .ul-table tbody { + display: grid; + gap: var(--portal-space-3); + } + + .ul-table tbody tr { + overflow: hidden; + border: 1px solid var(--portal-border); + border-radius: var(--portal-radius-md); + background: var(--portal-surface); + } + + .ul-table td { + display: grid; + min-height: var(--ul-touch-minimum); + grid-template-columns: 7rem minmax(0, 1fr); + align-items: center; + gap: var(--portal-space-3); + border-bottom: 1px solid var(--portal-border); + text-align: left !important; + } + + .ul-table td::before { + content: attr(data-label); + color: var(--portal-text-muted); + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .ul-table td:first-child { + background: var(--portal-surface-subtle); + } + + .ul-table td:last-child { + border-bottom: 0; + } + + .ul-job-summary { + grid-template-columns: 1fr 1fr; + } + + .ul-map--detail { + height: 28rem; + min-height: 22rem; + } +} + +@media (max-width: 600px) { + .ul-shell__header { + position: static; + } + + .ul-shell__brand span:last-child, + .ul-shell__account { + display: none; + } + + .ul-org-context__role { + display: none; + } + + .ul-shell__main { + width: min(100% - 1.5rem, 75rem); + } + + .ul-page-header h1, + .ul-detail-header h1 { + overflow-wrap: anywhere; + } + + .ul-job-summary { + grid-template-columns: 1fr; + } + + .ul-job-summary__description { + grid-column: auto; + } + + .ul-live-strip { + align-items: flex-start; + flex-direction: column; + } + + .ul-map--detail { + height: 24rem; + min-height: 20rem; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (forced-colors: active) { + .ul-status-badge, + .ul-page-state, + .ul-panel, + .ul-map-section { + forced-color-adjust: auto; + } +} diff --git a/web/styles/tokens.css b/web/styles/tokens.css new file mode 100644 index 0000000..18a5f12 --- /dev/null +++ b/web/styles/tokens.css @@ -0,0 +1,99 @@ +/* GENERATED FILE — DO NOT EDIT. + * Source: meta/contracts/design-tokens.json v3.0.0 + * Run: node meta/scripts/generate-design-tokens.mjs + */ + +:root, +[data-ul-theme="default"] { + color-scheme: light; + --ul-color-canvas: #EEF3F2; + --ul-color-surface: #FCFEFD; + --ul-color-surface-raised: #FFFFFF; + --ul-color-surface-strong: #14252A; + --ul-color-text: #14252A; + --ul-color-text-muted: #5D6B6E; + --ul-color-primary: #0B7774; + --ul-color-primary-pressed: #075F5D; + --ul-color-accent: #B82D46; + --ul-color-border: #C7D2D0; + --ul-color-focus: #B82D46; + --ul-color-map: #DCE9E7; + --ul-color-success: #347A55; + --ul-color-warning: #A85C00; + --ul-color-error: #B42318; + --ul-color-neutral: #40565B; + --ul-color-disabled: #819092; + --ul-color-on-strong: #FFFFFF; + --ul-color-on-primary: #FFFFFF; + --ul-color-on-accent: #FFFFFF; + --ul-space-2xs: 4px; + --ul-space-xs: 8px; + --ul-space-sm: 12px; + --ul-space-md: 16px; + --ul-space-lg: 24px; + --ul-space-xl: 32px; + --ul-space-2xl: 48px; + --ul-radius-sm: 8px; + --ul-radius-md: 12px; + --ul-radius-lg: 20px; + --ul-radius-pill: 999px; + --ul-touch-minimum: 48px; + --ul-touch-comfortable: 56px; + --ul-touch-primary: 64px; + --ul-type-family-sans: "IBM Plex Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --ul-type-family-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --ul-type-display-size: 2.5rem; + --ul-type-display-line-height: 1.2; + --ul-type-display-weight: 700; + --ul-type-page-size: 1.75rem; + --ul-type-page-line-height: 1.2142857142857142; + --ul-type-page-weight: 700; + --ul-type-section-size: 1.375rem; + --ul-type-section-line-height: 1.2727272727272727; + --ul-type-section-weight: 600; + --ul-type-body-size: 1rem; + --ul-type-body-line-height: 1.5; + --ul-type-body-weight: 400; + --ul-type-body-sm-size: 0.875rem; + --ul-type-body-sm-line-height: 1.4285714285714286; + --ul-type-body-sm-weight: 400; + --ul-type-label-size: 0.875rem; + --ul-type-label-line-height: 1.4285714285714286; + --ul-type-label-weight: 600; + --ul-type-mono-size: 1rem; + --ul-type-mono-line-height: 1.375; + --ul-type-mono-weight: 500; + --ul-motion-fast: 120ms; + --ul-motion-base: 200ms; + --ul-elevation-raised: 0 2px 8px rgba(20, 37, 42, 0.12); + --ul-elevation-overlay: 0 6px 16px rgba(20, 37, 42, 0.16); + --ul-breakpoint-compact: 600px; + --ul-breakpoint-medium: 900px; + --ul-breakpoint-wide: 1200px; +} + + +[data-ul-theme="sunlight"] { + color-scheme: light; + --ul-color-canvas: #FFFFFF; + --ul-color-surface: #FFFFFF; + --ul-color-surface-raised: #FFFFFF; + --ul-color-surface-strong: #000000; + --ul-color-text: #000000; + --ul-color-text-muted: #344448; + --ul-color-primary: #075F5D; + --ul-color-primary-pressed: #14252A; + --ul-color-accent: #982138; + --ul-color-border: #5D6B6E; + --ul-color-focus: #982138; + --ul-color-map: #E8F0EF; + --ul-color-success: #245F40; + --ul-color-warning: #754000; + --ul-color-error: #871A12; + --ul-color-neutral: #14252A; + --ul-color-disabled: #344448; + --ul-color-on-strong: #FFFFFF; + --ul-color-on-primary: #FFFFFF; + --ul-color-on-accent: #FFFFFF; +} + diff --git a/web/tests/s3-portal.test.mjs b/web/tests/s3-portal.test.mjs new file mode 100644 index 0000000..6888e78 --- /dev/null +++ b/web/tests/s3-portal.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (path) => readFileSync(resolve(webRoot, path), "utf8"); +const require = createRequire(import.meta.url); + +function loadTsxComponent(path) { + const typescript = require("typescript"); + const output = typescript.transpileModule(read(path), { + compilerOptions: { + esModuleInterop: true, + jsx: typescript.JsxEmit.ReactJSX, + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2020, + }, + }).outputText; + const loadedModule = { exports: {} }; + new Function("require", "module", "exports", output)( + require, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +const migratedPresentation = [ + "components/Layout.tsx", + "components/PortalUi.tsx", + "pages/index.tsx", + "pages/jobs/[jobId].tsx", +]; + +test("migrated shell and job pages use reusable classes without inline presentation", () => { + for (const path of migratedPresentation) { + const source = read(path); + assert.doesNotMatch( + source, + /\bstyle\s*=/, + `${path} must not use inline style props`, + ); + assert.doesNotMatch( + source, + /#[\da-f]{3,8}\b/i, + `${path} must not define visual color literals`, + ); + } +}); + +test("the authenticated shell keeps organization and navigation context accessible", () => { + const layout = read("components/Layout.tsx"); + assert.match(layout, /Active organization/); + assert.match(layout, /activeOrg\.role/); + assert.match(layout, /aria-current=/); + assert.match(layout, /Skip to main content/); + assert.match(layout, /aria-label="Primary navigation"/); +}); + +test("status presentation always combines a readable label with an icon", () => { + const components = read("components/PortalUi.tsx"); + for (const status of [ + "OPEN", + "IN_PROGRESS", + "COMPLETED", + "CANCELLED", + "LIVE", + "OFFLINE", + "ERROR", + ]) { + assert.match( + components, + new RegExp(`${status}: \\{ icon: .+, label: .+, tone:`), + ); + } + assert.match(components, /ul-status-badge__icon/); + assert.match(components, /aria-hidden="true"/); +}); + +test("status and error-state components render accessible text and semantics", () => { + const React = require("react"); + const { renderToStaticMarkup } = require("react-dom/server"); + const { PageState, StatusBadge } = loadTsxComponent( + "components/PortalUi.tsx", + ); + + const status = renderToStaticMarkup( + React.createElement(StatusBadge, { status: "IN_PROGRESS" }), + ); + assert.match(status, />In progress { + const css = read("styles/global.css"); + assert.ok(css.startsWith('@import "./tokens.css";')); + for (const token of [ + "--ul-color-canvas", + "--ul-color-primary", + "--ul-color-map", + "--ul-touch-minimum", + "--ul-type-family-sans", + ]) { + assert.match(css, new RegExp(`var\\(${token}\\)`)); + } + assert.match(css, /:focus-visible/); + assert.match(css, /min-height:\s*var\(--ul-touch-minimum\)/); + assert.match(css, /@media \(max-width: 1200px\)/); + assert.match(css, /@media \(max-width: 900px\)/); + assert.match(css, /@media \(max-width: 600px\)/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); +}); + +test("job pages preserve organization-scoped APIs, streaming, and provider-neutral map usage", () => { + const jobs = read("pages/index.tsx"); + const detail = read("pages/jobs/[jobId].tsx"); + const dispatcher = read("components/map/JobMap.tsx"); + const stream = read("lib/use-job-stream.ts"); + + assert.match(jobs, /\/api\/orgs\/\$\{activeOrg\.org\.id\}\/jobs/); + assert.match(detail, /\/api\/orgs\/\$\{orgId\}\/jobs\/\$\{jobId\}/); + assert.match(detail, / import\('\.\/esri\/EsriJobMap'\)/); + assert.match(stream, /channel: `job:\$\{jobId\}`/); + assert.match(stream, /return connectionState/); +});