- {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 &&
-
- {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 ? (
+
+ ) : (
+ <>
+
+
+ {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);
+ assert.match(status, /ul-status-badge--warning/);
+ assert.match(status, /aria-hidden="true"/);
+
+ const error = renderToStaticMarkup(
+ React.createElement(PageState, {
+ kind: "error",
+ title: "Jobs could not be loaded",
+ }),
+ );
+ assert.match(error, /role="alert"/);
+ assert.match(error, /Jobs could not be loaded/);
+});
+
+test("responsive, focus, touch-target, and reduced-motion rules are present", () => {
+ 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/);
+});