M19: harden performance and recovery
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s

This commit is contained in:
NuklearRabbit
2026-08-10 12:51:41 +02:00
parent fe06ff75a1
commit 5d7a5e7359
10 changed files with 164 additions and 42 deletions
+21
View File
@@ -2459,3 +2459,24 @@ evidence yet."
regenerated. E2E coverage now includes bulk queue assignment and full user editing. regenerated. E2E coverage now includes bulk queue assignment and full user editing.
- Exact next action: split the frontend bundle, harden mobile layout and operational - Exact next action: split the frontend bundle, harden mobile layout and operational
backup/deployment controls, then run clean full acceptance and redeploy. backup/deployment controls, then run clean full acceptance and redeploy.
## M19 — performance and recoverable operations (2026-08-10)
- Route-level React lazy loading reduced the initial production JavaScript chunk from
about **572 kB to 212 kB**; every operational page now ships as a separate bounded
chunk and the previous Vite large-chunk warning is gone. Both npm audits report zero
vulnerabilities.
- Reflowed the five dashboard readiness metrics into a readable 3+2 mobile grid instead
of an overflowing horizontal strip at 390 px.
- Compose now gates dependants on database-backed `/health/ready`. The optional bundled
n8n fallback is pinned to `n8nio/n8n:2.33.7`, matching the central server n8n version;
Unraid still starts no second n8n instance.
- Added guarded Unraid PostgreSQL backup/restore scripts. Backups use custom format and
are structurally verified; restore requires an explicit confirmation, makes a safety
backup, stops API writes, recreates only the configured database and checks Alembic.
A disposable dump/restore drill recovered all **50 vehicles** into a second database.
- Evidence: merged Compose configurations and shell syntax pass; frontend production
build and audits pass; full isolated PostgreSQL suite **225 passed, 1 warning**; ruff
and mypy are clean.
- Exact next action: commit and deploy this milestone, run complete Playwright and visual
acceptance against Unraid, refresh final evidence, push and verify the live hash.
+6 -4
View File
@@ -38,13 +38,14 @@ rollback).
The PoC implements: The PoC implements:
- operations dashboard with a truthful aggregate n8n/MCP integration-status card; - operations dashboard with a truthful aggregate n8n/MCP integration-status card;
- vehicle and booking views with working search, filters and pagination; - vehicle and booking workspaces with planning windows, location filters, service/next-
booking context, operational ordering and pagination;
- server-backed session lifecycle (refresh-safe, central 401 handling); - server-backed session lifecycle (refresh-safe, central 401 handling);
- a role matrix enforced server-side and mirrored in the UI (see - a role matrix enforced server-side and mirrored in the UI (see
`docs/12-security-and-audit.md`); `docs/12-security-and-audit.md`);
- vehicle return capture → authoritative server-evaluated review → commit → result; - vehicle return capture → authoritative server-evaluated review → commit → result;
- five deterministic data-quality checks, each with a bounded resolution flow, plus a - five deterministic data-quality checks with SLA deadlines, assignment, overdue/bulk
manual scan action; queue controls, bounded resolution flows and a manual scan action;
- human review and customer merge; - human review and customer merge;
- audit trail with human-readable before/after evidence and safe entity links; - audit trail with human-readable before/after evidence and safe entity links;
- role-aware global search across vehicles, bookings and (Operations Manager) issues; - role-aware global search across vehicles, bookings and (Operations Manager) issues;
@@ -55,6 +56,7 @@ The PoC implements:
heartbeat evidence and crash-recoverable outbox delivery leases; heartbeat evidence and crash-recoverable outbox delivery leases;
- four read-only MCP tools through ITWorx MCP Hub; - four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase. - deterministic demo reset and five-minute showcase.
- audited operational user creation, role/name editing, password reset and activation.
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent. It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent.
@@ -118,7 +120,7 @@ required for the automation demo, and the full operational runbook.
Endpoints: Endpoints:
- Web: `http://localhost:1228` - Web: `http://localhost:1228`
- API health: `http://localhost:8128/health` - API readiness: `http://localhost:8128/health/ready` (liveness: `/health/live`)
- n8n: `http://localhost:5678` - n8n: `http://localhost:5678`
All defaults are configurable via `.env` (see `.env.example`). All defaults are configurable via `.env` (see `.env.example`).
+14 -3
View File
@@ -354,10 +354,21 @@ def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
def test_apply_recommended_status_resolves_conflict(ops_client): def test_apply_recommended_status_resolves_conflict(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict") targets = ops_client.get(
preview = ops_client.post( "/api/v1/data-quality/issues",
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation" params={"rule_type": "vehicle_status_conflict", "status": "open"},
).json() ).json()
target = None
preview = None
for candidate in targets:
candidate_preview = ops_client.post(
f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation"
).json()
if candidate_preview["safe_to_apply"]:
target = candidate
preview = candidate_preview
break
assert target is not None and preview is not None
assert preview["safe_to_apply"] is True assert preview["safe_to_apply"] is True
assert preview["manual_review_required"] is False assert preview["manual_review_required"] is False
+3 -2
View File
@@ -47,7 +47,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -65,7 +65,8 @@ services:
networks: [mobilityops] networks: [mobilityops]
n8n: n8n:
image: n8nio/n8n:latest # Explicit fallback only; production reuses the server's existing central n8n.
image: n8nio/n8n:2.33.7
environment: environment:
TZ: ${TZ:-Europe/Brussels} TZ: ${TZ:-Europe/Brussels}
GENERIC_TIMEZONE: ${TZ:-Europe/Brussels} GENERIC_TIMEZONE: ${TZ:-Europe/Brussels}
+21
View File
@@ -52,3 +52,24 @@ docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current
docker logs --tail=200 n8n docker logs --tail=200 n8n
``` ```
## Backup and restore
Create and structurally verify a timestamped PostgreSQL custom-format backup:
```bash
./deploy/unraid/backup-postgres.sh
```
Copy backups off the server according to the host backup policy. A restore is deliberately
guarded and creates an additional safety backup before replacing the database:
```bash
./deploy/unraid/restore-postgres.sh \
backups/postgres/mobilityops-YYYYMMDDTHHMMSSZ.dump \
RESTORE-MOBILITYOPS
```
The restore stops the API, recreates only the configured MobilityOps database, restarts
API/web and verifies the active Alembic revision. Test restores in a disposable environment
before using a production backup for incident recovery.
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
project="${COMPOSE_PROJECT_NAME:-mobilityops}"
destination="${1:-backups/postgres}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_file="${destination}/mobilityops-${timestamp}.dump"
temporary_file="${backup_file}.partial"
compose_files="-f compose.yaml -f compose.unraid.yaml"
mkdir -p "$destination"
trap 'rm -f "$temporary_file"' EXIT INT TERM
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \
> "$temporary_file"
docker compose -p "$project" $compose_files exec -T db pg_restore --list \
< "$temporary_file" > /dev/null
mv "$temporary_file" "$backup_file"
trap - EXIT INT TERM
printf '%s\n' "$backup_file"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
backup_file="${1:-}"
confirmation="${2:-}"
project="${COMPOSE_PROJECT_NAME:-mobilityops}"
compose_files="-f compose.yaml -f compose.unraid.yaml"
if [ ! -f "$backup_file" ]; then
echo "Backup file not found: $backup_file" >&2
exit 1
fi
if [ "$confirmation" != "RESTORE-MOBILITYOPS" ]; then
echo "Refusing destructive restore; pass RESTORE-MOBILITYOPS as the second argument" >&2
exit 1
fi
docker compose -p "$project" $compose_files exec -T db pg_restore --list \
< "$backup_file" > /dev/null
safety_backup="$(./deploy/unraid/backup-postgres.sh)"
echo "Pre-restore safety backup: $safety_backup"
restart_api() {
docker compose -p "$project" $compose_files up -d api web > /dev/null
}
trap restart_api EXIT INT TERM
docker compose -p "$project" $compose_files stop api > /dev/null
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
'dropdb --force -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
docker compose -p "$project" $compose_files exec -T db sh -eu -c \
'pg_restore --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
< "$backup_file"
restart_api
trap - EXIT INT TERM
docker compose -p "$project" $compose_files exec -T api alembic current
echo "Restore completed from $backup_file"
+2 -2
View File
@@ -24,7 +24,7 @@ docker compose exec api python -m app.cli seed --reset
Verify: Verify:
```bash ```bash
curl http://localhost:8128/health # {"status":"ok",...} curl http://localhost:8128/health/ready # database-backed readiness
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200 curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
make test # isolated test project/database, all tests pass make test # isolated test project/database, all tests pass
docker compose run --rm api ruff check . # clean docker compose run --rm api ruff check . # clean
@@ -52,7 +52,7 @@ invalidates its next request.
## n8n automation (one-time per environment) ## n8n automation (one-time per environment)
The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any
workflow — including webhook registration — works reliably; `N8N_BASIC_AUTH_ACTIVE` no workflow — including webhook registration — works reliably; `N8N_BASIC_AUTH_ACTIVE` no
longer gates this. This is a one-time step per fresh `docker compose down -v`: longer gates this. This is a one-time step per fresh `docker compose down -v`:
+35 -30
View File
@@ -1,24 +1,29 @@
import { lazy, Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext"; import { DemoManifestProvider } from "./context/DemoManifestContext";
import { DemoGuideProvider } from "./context/DemoGuideContext"; import { DemoGuideProvider } from "./context/DemoGuideContext";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth"; import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login"; const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
import { Dashboard } from "./pages/Dashboard"; const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
import { Vehicles } from "./pages/Vehicles"; const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles })));
import { VehicleDetail } from "./pages/VehicleDetail"; const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail })));
import { Bookings } from "./pages/Bookings"; const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings })));
import { BookingDetail } from "./pages/BookingDetail"; const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail })));
import { BookingCreate } from "./pages/BookingCreate"; const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate })));
import { DataQuality } from "./pages/DataQuality"; const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality })));
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail"; const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail })));
import { Automation } from "./pages/Automation"; const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation })));
import { Knowledge } from "./pages/Knowledge"; const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge })));
import { Audit } from "./pages/Audit"; const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit })));
import { AboutDemo } from "./pages/AboutDemo"; const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
import { Scenarios } from "./pages/Scenarios"; const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
import { Users } from "./pages/Users"; const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
function deferredPage(element: ReactNode) {
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
}
export function App() { export function App() {
return ( return (
@@ -26,7 +31,7 @@ export function App() {
<AuthProvider> <AuthProvider>
<DemoGuideProvider> <DemoGuideProvider>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={deferredPage(<Login />)} />
<Route <Route
element={ element={
<RequireAuth> <RequireAuth>
@@ -34,20 +39,20 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
> >
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={deferredPage(<Dashboard />)} />
<Route path="/vehicles" element={<Vehicles />} /> <Route path="/vehicles" element={deferredPage(<Vehicles />)} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} /> <Route path="/vehicles/:publicRef" element={deferredPage(<VehicleDetail />)} />
<Route path="/bookings" element={<Bookings />} /> <Route path="/bookings" element={deferredPage(<Bookings />)} />
<Route path="/bookings/new" element={<BookingCreate />} /> <Route path="/bookings/new" element={deferredPage(<BookingCreate />)} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} /> <Route path="/bookings/:publicRef" element={deferredPage(<BookingDetail />)} />
<Route path="/data-quality" element={<DataQuality />} /> <Route path="/data-quality" element={deferredPage(<DataQuality />)} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} /> <Route path="/data-quality/:publicRef" element={deferredPage(<DataQualityIssueDetail />)} />
<Route path="/automation" element={<Automation />} /> <Route path="/automation" element={deferredPage(<Automation />)} />
<Route path="/knowledge" element={<Knowledge />} /> <Route path="/knowledge" element={deferredPage(<Knowledge />)} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={deferredPage(<Audit />)} />
<Route path="/users" element={<Users />} /> <Route path="/users" element={deferredPage(<Users />)} />
<Route path="/about" element={<AboutDemo />} /> <Route path="/about" element={deferredPage(<AboutDemo />)} />
<Route path="/scenarios" element={<Scenarios />} /> <Route path="/scenarios" element={deferredPage(<Scenarios />)} />
</Route> </Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
+2 -1
View File
@@ -419,6 +419,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; } .filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); } .state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.route-loading { min-height: 40vh; display: grid; place-items: center; }
.error { color: #9f2929; font-size: .74rem; font-weight: 600; } .error { color: #9f2929; font-size: .74rem; font-weight: 600; }
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); } .api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
.api-error-notice strong { display: block; font-size: .78rem; } .api-error-notice strong { display: block; font-size: .78rem; }
@@ -512,7 +513,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
} }
@media (max-width: 700px) { @media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }.metric-cell { min-width: 0; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; white-space: normal; overflow-wrap: anywhere; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }