UX: implement visual product roadmap

This commit is contained in:
NuklearRabbit
2026-08-10 01:05:07 +02:00
parent 13ad2ba6a3
commit f2cdad194c
40 changed files with 1010 additions and 281 deletions
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
expect(vehicles.ok()).toBeTruthy();
const vehiclePage = await vehicles.json();
expect(vehiclePage.items).toHaveLength(25);
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
expect(audit.ok()).toBeTruthy();
const auditPage = await audit.json();
expect(auditPage.items.length).toBeLessThanOrEqual(25);
await page.goto("/vehicles?status=maintenance&page=1");
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
await expect(page).toHaveURL(/status=maintenance/);
});
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.locator(".page-actions .badge")).toBeVisible();
await page.goto("/vehicles");
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
});
+9 -1
View File
@@ -20,6 +20,14 @@ export interface Vehicle {
attention: boolean;
}
export interface Page<T> {
items: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
}
export interface BookingSummary {
public_ref: string;
customer_ref: string;
@@ -260,7 +268,7 @@ export interface KnowledgeHealth {
tenant: string;
workspace: string;
collection: string;
document_count: number;
document_count: number | null;
}
export interface N8nWorkflowEvidence {
+13 -9
View File
@@ -313,19 +313,23 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger />
<DemoBadge />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && (
<div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</div>
<details className="operator-menu">
<summary className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div>
</header>
+25
View File
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
export function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const { t } = useTranslation("common");
if (totalPages <= 1) return null;
return (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
@@ -4,6 +4,12 @@
"description": "Trace important state changes, actors and correlation references.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "e.g. demo_login",
"actorFilterLabel": "Actor",
"actorFilterPlaceholder": "Name or service",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "e.g. MO-016",
"fromFilterLabel": "From",
"toFilterLabel": "To",
"managerOnly": "The audit trail is visible to Operations Managers only.",
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
"loading": "Loading audit trail…",
@@ -22,6 +22,12 @@
"success": "Success",
"noResults": "No results"
},
"pagination": {
"label": "Page navigation",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{page}} of {{total}}"
},
"states": {
"loadingDefault": "Loading workspace…",
"errorTitle": "We couldn't load this workspace."
@@ -36,6 +36,7 @@
"title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue",
"remaining": "View {{count}} more attention item(s)",
"filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue",
"severityAll": "All severity",
@@ -68,6 +69,7 @@
"n8nNoEvidence": "No workflow evidence recorded",
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummary": "{{count}} procedures indexed",
"knowledgeIndexUnknown": "Knowledge source available · index size unknown",
"knowledgeUnavailable": "Health check unavailable",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registration enabled",
@@ -11,6 +11,7 @@
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.",
"knowledgeUnavailable": "Health evidence is currently unavailable.",
"gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub",
@@ -7,6 +7,7 @@
"statusAvailable": "Available",
"statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed",
"proceduresIndexUnknown": "Index size is not available through this connection",
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question",
"askSubheading": "Retrieval → evidence check → grounded answer",
@@ -20,6 +20,8 @@
"statusRejected": "Rejected",
"ruleTypeLabel": "Rule type",
"ruleTypeAll": "All rule types",
"severityLabel": "Severity",
"severityAll": "All severity levels",
"demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…",
"queueClear": "Queue is clear",
@@ -4,6 +4,12 @@
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "p. ex. demo_login",
"actorFilterLabel": "Intervenant",
"actorFilterPlaceholder": "Nom ou service",
"entityFilterLabel": "Dossier",
"entityFilterPlaceholder": "p. ex. MO-016",
"fromFilterLabel": "À partir du",
"toFilterLabel": "Jusquau",
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement de la piste d'audit…",
@@ -22,6 +22,12 @@
"success": "Réussi",
"noResults": "Aucun résultat"
},
"pagination": {
"label": "Navigation entre les pages",
"previous": "Précédent",
"next": "Suivant",
"pageOf": "Page {{page}} sur {{total}}"
},
"states": {
"loadingDefault": "Chargement de l'espace de travail…",
"errorTitle": "Impossible de charger cet espace de travail."
@@ -36,6 +36,7 @@
"title": "File d'attention",
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
"reviewQueue": "Examiner la file",
"remaining": "Voir encore {{count}} point(s) dattention",
"filterPlaceholder": "Filtrer les problèmes…",
"filterAriaLabel": "Rechercher dans la file d'attention",
"severityAll": "Toute gravité",
@@ -68,6 +69,7 @@
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummary": "{{count}} procédures indexées",
"knowledgeIndexUnknown": "Source de connaissances disponible · taille dindex inconnue",
"knowledgeUnavailable": "Vérification de santé indisponible",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Enregistrement activé",
@@ -11,6 +11,7 @@
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryIndexUnknown": "Source de connaissances disponible · taille dindex indisponible pour {{collection}}.",
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
"gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub",
@@ -7,6 +7,7 @@
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées",
"proceduresIndexUnknown": "La taille de lindex nest pas disponible via cette connexion",
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
"askHeading": "Poser une question de procédure",
"askSubheading": "Recherche → vérification des preuves → réponse étayée",
@@ -20,6 +20,8 @@
"statusRejected": "Rejeté",
"ruleTypeLabel": "Type de règle",
"ruleTypeAll": "Tous les types de règles",
"severityLabel": "Gravité",
"severityAll": "Tous les niveaux de gravité",
"demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide",
@@ -4,6 +4,12 @@
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
"actionFilterLabel": "Actie",
"actionFilterPlaceholder": "bv. demo-login",
"actorFilterLabel": "Uitvoerder",
"actorFilterPlaceholder": "Naam of dienst",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "bv. MO-016",
"fromFilterLabel": "Vanaf",
"toFilterLabel": "Tot en met",
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"loading": "Auditgeschiedenis laden…",
@@ -22,6 +22,12 @@
"success": "Gelukt",
"noResults": "Geen resultaten"
},
"pagination": {
"label": "Paginanavigatie",
"previous": "Vorige",
"next": "Volgende",
"pageOf": "Pagina {{page}} van {{total}}"
},
"states": {
"loadingDefault": "Werkruimte wordt geladen…",
"errorTitle": "We konden deze werkruimte niet laden."
@@ -36,6 +36,7 @@
"title": "Aandachtspunten",
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
"reviewQueue": "Wachtrij bekijken",
"remaining": "Nog {{count}} aandachtspunt(en) bekijken",
"filterPlaceholder": "Filter aandachtspunten…",
"filterAriaLabel": "Zoek in aandachtspunten",
"severityAll": "Alle ernst",
@@ -68,6 +69,7 @@
"n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd",
"knowledgeTitle": "Kennisassistent",
"knowledgeSummary": "{{count}} procedures geïndexeerd",
"knowledgeIndexUnknown": "Kennisbron bereikbaar · indexomvang onbekend",
"knowledgeUnavailable": "Statuscontrole niet beschikbaar",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie ingeschakeld",
@@ -11,6 +11,7 @@
"knowledgeTitle": "Kennisassistent",
"knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryIndexUnknown": "Kennisbron bereikbaar · indexomvang niet beschikbaar voor {{collection}}.",
"knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.",
"gatewayKicker": "Tool-gateway",
"mcpTitle": "MCP Hub",
@@ -7,6 +7,7 @@
"statusAvailable": "Beschikbaar",
"statusUnavailable": "Niet beschikbaar",
"proceduresIndexed": "{{count}} procedures geïndexeerd",
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
"providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.",
"askHeading": "Stel een procedurevraag",
"askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord",
@@ -20,6 +20,8 @@
"statusRejected": "Verworpen",
"ruleTypeLabel": "Regeltype",
"ruleTypeAll": "Alle regeltypes",
"severityLabel": "Ernst",
"severityAll": "Alle ernstniveaus",
"demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg",
+79 -182
View File
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { AuditEvent } from "../api/types";
import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
function humanizeField(field: string): string {
const spaced = field.replace(/_/g, " ");
@@ -20,210 +21,106 @@ function actionLabel(t: (key: string, options?: Record<string, unknown>) => stri
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
}
function ChangeDiff({
before,
after,
t,
}: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | null; after: Record<string, unknown> | null; t: (key: string, options?: Record<string, unknown>) => string }) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
const lines: { field: string; text: string }[] = [];
for (const key of keys) {
const b = before?.[key];
const a = after?.[key];
if (JSON.stringify(b) === JSON.stringify(a)) continue;
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
}
if (lines.length === 0) return <p className="table-subtext">{t("noFieldChange")}</p>;
return (
<ul className="change-diff">
{lines.map((line) => (
<li key={line.field}>{line.text}</li>
))}
</ul>
);
const lines = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])]
.filter((key) => JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key]))
.slice(0, 3)
.map((key) => {
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
const b = before?.[key];
const a = after?.[key];
if (b === undefined) return t("diff.setTo", { field, value: JSON.stringify(a) });
if (a === undefined) return t("diff.was", { field, value: JSON.stringify(b) });
return t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) });
});
return lines.length ? <ul className="change-diff">{lines.map((line) => <li key={line}>{line}</li>)}</ul> : <p className="table-subtext">{t("noFieldChange")}</p>;
}
interface EventGroup {
correlationId: string;
primary: AuditEvent;
related: AuditEvent[];
}
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
export function Audit() {
const { t } = useTranslation("audit");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<AuditEvent[] | null>(null);
const [events, setEvents] = useState<Page<AuditEvent> | null>(null);
const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState(searchParams.get("action") ?? "");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const action = searchParams.get("action") ?? "";
const actor = searchParams.get("actor") ?? "";
const entityRef = searchParams.get("entity_ref") ?? "";
const from = searchParams.get("from") ?? "";
const to = searchParams.get("to") ?? "";
const correlationId = searchParams.get("correlation_id") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateParams(updates: Record<string, string | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
useEffect(() => {
if (user?.role !== "operations_manager") return;
setEvents(null);
setError(null);
const params = new URLSearchParams();
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (action) params.set("action", action);
if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId);
api
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
.then(setEvents)
.catch(() => setError(t("unavailable")));
}, [action, correlationId, user]);
if (from) params.set("occurred_from", `${from}T00:00:00Z`);
if (to) params.set("occurred_to", `${to}T23:59:59Z`);
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => {
if (!events) return [];
const order: string[] = [];
const byCorrelation = new Map<string, AuditEvent[]>();
for (const event of events) {
if (!byCorrelation.has(event.correlation_id)) {
order.push(event.correlation_id);
byCorrelation.set(event.correlation_id, []);
}
byCorrelation.get(event.correlation_id)!.push(event);
}
return order.map((id) => {
const group = byCorrelation.get(id)!;
return { correlationId: id, primary: group[0], related: group.slice(1) };
});
for (const event of events?.items ?? []) byCorrelation.set(event.correlation_id, [...(byCorrelation.get(event.correlation_id) ?? []), event]);
return [...byCorrelation.entries()].map(([id, grouped]) => ({ correlationId: id, primary: grouped[0], related: grouped.slice(1) }));
}, [events]);
function showRelatedEvents(id: string) {
setSearchParams({ correlation_id: id });
}
if (user?.role !== "operations_manager") return <div className="page"><PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /><p>{t("managerOnlyDetail")}</p></div>;
function clearCorrelationFilter() {
setSearchParams(action ? { action } : {});
}
function toggleExpanded(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
<p>{t("managerOnlyDetail")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label={t("title")}>
<label>
{t("actionFilterLabel")}
<input
type="text"
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder={t("actionFilterPlaceholder")}
/>
</label>
</form>
{correlationId && (
<p className="quiet-empty" role="status">
{t("relatedFilterActive", { count: events?.length ?? 0 })}{" "}
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
{t("clearFilter")}
</button>
</p>
)}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return (
<li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading">
<strong>{actionLabel(t, e.action)}</strong>
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
</div>
<div className="audit-group-meta">
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
<span>{e.entity_ref ?? e.entity_type}</span>
)}
</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && (
<button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
{isExpanded
? t("hideTechnicalEvents")
: t("showTechnicalEvents", { count: group.related.length })}
</button>
)}
{!correlationId && (
<button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
{t("viewRelatedEvents")}
</button>
)}
</div>
</div>
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div>
<div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details>
{isExpanded && group.related.length > 0 && (
<ul className="audit-related-list">
{group.related.map((related) => (
<li key={related.id}>
<div className="audit-group-heading">
<strong>{actionLabel(t, related.action)}</strong>
<span className="table-subtext">{formatDateTime(related.occurred_at)}</span>
</div>
<ChangeDiff before={related.before} after={related.after} t={t} />
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(related.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{related.id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre>
</details>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label={t("title")} onSubmit={(event) => event.preventDefault()}>
<label>{t("actionFilterLabel")}<input type="search" value={action} onChange={(e) => updateParams({ action: e.target.value, page: 1 })} placeholder={t("actionFilterPlaceholder")} /></label>
<label>{t("actorFilterLabel")}<input type="search" value={actor} onChange={(e) => updateParams({ actor: e.target.value, page: 1 })} placeholder={t("actorFilterPlaceholder")} /></label>
<label>{t("entityFilterLabel")}<input type="search" value={entityRef} onChange={(e) => updateParams({ entity_ref: e.target.value, page: 1 })} placeholder={t("entityFilterPlaceholder")} /></label>
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
</form>
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.items.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && <div className="table-shell audit-shell">
<div className="table-meta"><span>{t("count", { count: events!.total })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return <li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading"><strong>{actionLabel(t, e.action)}</strong><span className="table-subtext">{formatDateTime(e.occurred_at)}</span></div>
<div className="audit-group-meta"><span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>{e.entity_link ? <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> : <span>{e.entity_ref ?? e.entity_type}</span>}</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && <button type="button" className="link-button" onClick={() => setExpanded((current) => ({ ...current, [group.correlationId]: !current[group.correlationId] }))}>{isExpanded ? t("hideTechnicalEvents") : t("showTechnicalEvents", { count: group.related.length })}</button>}
{!correlationId && <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: group.correlationId, page: 1 })}>{t("viewRelatedEvents")}</button>}
</div>
</div>
<details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><dl className="detail-grid audit-technical-grid"><div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div><div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div><div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div></dl><pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre></details>
{isExpanded && group.related.length > 0 && <ul className="audit-related-list">{group.related.map((related) => <li key={related.id}><div className="audit-group-heading"><strong>{actionLabel(t, related.action)}</strong><span className="table-subtext">{formatDateTime(related.occurred_at)}</span></div><ChangeDiff before={related.before} after={related.after} t={t} /><details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre></details></li>)}</ul>}
</li>;
})}
</ul>
<Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
</div>}
</div>;
}
+6 -4
View File
@@ -201,10 +201,12 @@ export function Automation() {
<h2>{t("cards.knowledgeTitle")}</h2>
<p>
{knowledge
? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
count: knowledge.document_count,
collection: knowledge.collection,
})
? knowledge.document_count === null
? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")}
</p>
</div>
+9 -2
View File
@@ -91,7 +91,11 @@ export function Dashboard() {
const isUnfiltered = severity === "all" && query === "";
const attentionTiers = useMemo(() => {
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
const bySeverity = (level: string) => attention.filter((item) => item.severity === level);
// Keep the initial dashboard queue deliberately bounded. The full evidence-backed
// queue remains one click away in Data Quality; this view is for deciding what to
// do next, not for rendering the whole backlog.
const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6);
const bySeverity = (level: string) => visible.filter((item) => item.severity === level);
return [
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
@@ -185,6 +189,9 @@ export function Dashboard() {
</ul>
</div>
))}
{isUnfiltered && attention.length > 6 && canSeeQuality && (
<p className="queue-more"><Link to="/data-quality">{t("attention.remaining", { count: attention.length - 6 })} <Icon name="chevron" /></Link></p>
)}
</>
)}
</section>
@@ -238,7 +245,7 @@ export function Dashboard() {
<IntegrationMark kind="rag" />
<div>
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
<span>{knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}</span>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
+42 -17
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, ScanResult } from "../api/types";
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -19,15 +20,27 @@ const RULE_TYPES = [
export function DataQuality() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("open");
const [ruleType, setRuleType] = useState("");
const status = searchParams.get("status") ?? "open";
const ruleType = searchParams.get("rule_type") ?? "";
const severity = searchParams.get("severity") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
const load = useCallback(() => {
if (user?.role !== "operations_manager") return;
@@ -36,11 +49,14 @@ export function DataQuality() {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (ruleType) params.set("rule_type", ruleType);
if (severity) params.set("severity", severity);
params.set("page", String(page));
params.set("page_size", "25");
api
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, user]);
}, [status, ruleType, severity, page, user, t]);
useEffect(() => {
load();
@@ -73,8 +89,8 @@ export function DataQuality() {
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
const visibleIssues = issues
? demoScenariosOnly
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues.items
: [];
return (
@@ -118,7 +134,7 @@ export function DataQuality() {
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option>
@@ -128,7 +144,7 @@ export function DataQuality() {
</label>
<label>
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
<option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => (
<option key={r} value={r}>
@@ -137,11 +153,20 @@ export function DataQuality() {
))}
</select>
</label>
<label>
{t("list.severityLabel")}
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
<option value="">{t("list.severityAll")}</option>
<option value="high">{t("severities.high")}</option>
<option value="medium">{t("severities.medium")}</option>
<option value="low">{t("severities.low")}</option>
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -149,13 +174,13 @@ export function DataQuality() {
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.length > 0 && visibleIssues.length === 0 && (
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)}
{visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
@@ -184,7 +209,7 @@ export function DataQuality() {
</tr>
))}
</tbody>
</table></div>
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
+1 -1
View File
@@ -69,7 +69,7 @@ export function Knowledge() {
<strong>{providerLabel}</strong>
<span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{t("proceduresIndexed", { count: status.document_count })}
{status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })}
</span>
</div>
<small>{status.collection}</small>
+30 -18
View File
@@ -1,22 +1,34 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Vehicle } from "../api/types";
import type { Page, Vehicle } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() {
const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat();
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [attentionOnly, setAttentionOnly] = useState(false);
const [query, setQuery] = useState("");
const status = searchParams.get("status") ?? "";
const attentionOnly = searchParams.get("attention_only") === "true";
const query = searchParams.get("q") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
}
useEffect(() => {
setVehicles(null);
@@ -24,11 +36,14 @@ export function Vehicles() {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query);
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]);
}, [status, attentionOnly, query, page, t]);
return (
<div className="page">
@@ -37,11 +52,11 @@ export function Vehicles() {
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
@@ -54,7 +69,7 @@ export function Vehicles() {
<input
type="checkbox"
checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)}
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
/>
{t("list.attentionOnly")}
</label>
@@ -62,11 +77,9 @@ export function Vehicles() {
{error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
{vehicles && vehicles.length > 0 && (() => {
const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase()));
return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
{vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
@@ -79,7 +92,7 @@ export function Vehicles() {
</tr>
</thead>
<tbody>
{filtered.map((v) => (
{vehicles.items.map((v) => (
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label={t("list.columns.reference")}>
{v.public_ref}
@@ -97,8 +110,7 @@ export function Vehicles() {
</tr>
))}
</tbody>
</table></div>;
})()}
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}
+27 -21
View File
@@ -28,6 +28,10 @@
--focus: #14b8a6;
--radius: 4px;
--shadow-float: 0 16px 40px rgba(15, 23, 42, .12);
--type-body: .875rem;
--type-meta: .75rem;
--type-label: .75rem;
--type-badge: .75rem;
}
* { box-sizing: border-box; }
@@ -91,10 +95,11 @@ a:hover { color: var(--teal); }
.search-result-copy { display: flex; flex-direction: column; min-width: 0; }
.search-result-copy strong { font-size: .82rem; }
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
.timezone svg { width: 15px; }
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
.operator-menu { position: relative; }.operator-menu summary { list-style: none; cursor: pointer; }.operator-menu summary::-webkit-details-marker { display: none; }.operator-menu[open] .operator { color: var(--teal-dark); }.operator-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 31; width: 220px; display: grid; gap: 10px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.operator-popover .timezone { font-size: var(--type-meta); }.operator-logout { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.operator-logout svg { width: 16px; }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; }
.operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
@@ -175,6 +180,7 @@ a:hover { color: var(--teal); }
.attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; }
.attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; }
.attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; }
.queue-more { margin: 0; padding: 13px 18px; border-top: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }.queue-more a { display: inline-flex; align-items: center; gap: 5px; }
.attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; }
.attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); }
@@ -206,7 +212,7 @@ a:hover { color: var(--teal); }
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; }
.recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; }
.badge { min-height: 20px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border: 1px solid transparent; border-radius: 999px; font-size: .59rem; font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
.badge { min-height: 24px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid transparent; border-radius: 999px; font-size: var(--type-badge); font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
.badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; }
.severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; }
@@ -216,25 +222,25 @@ a:hover { color: var(--teal); }
.badge-dot { display: inline-block; }
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; letter-spacing: .01em; }
.filters input[type="text"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 40px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .78rem; }
.filters input[type="text"] { min-width: 230px; }.filters select { min-width: 160px; }
.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; letter-spacing: .01em; }
.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 44px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: var(--type-body); }
.filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); }
.table-shell { overflow: hidden; }
.table-meta { min-height: 40px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; }
.table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { height: 50px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: .74rem; vertical-align: middle; }
.data-table th, .data-table td { min-height: 52px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: var(--type-body); vertical-align: middle; }
.data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; }
.data-table thead th { height: 38px; color: var(--muted); background: #f6f8fa; font-size: .61rem; font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
.data-table thead th { height: 42px; color: var(--muted); background: #f6f8fa; font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
.data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; }
.row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; }
.data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
.data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; }
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: .62rem; }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pagination { min-height: 52px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; }
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: var(--type-meta); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pagination { min-height: 56px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--type-meta); }
details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; }
.tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); }
@@ -259,18 +265,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; }
.detail-grid dt { margin: 0; color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: var(--type-body); font-weight: 700; }
.record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; }
.return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; }
.return-form > .section-heading { padding: 19px 22px; }.return-progress { min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; font-weight: 700; }
.return-form > .section-heading { padding: 19px 22px; }.return-progress { position: sticky; top: 64px; z-index: 8; min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: .6rem; text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: .76rem; font-weight: 700; }
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }
.impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; }
.commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); }
.success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; }
@@ -293,7 +299,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.evidence-list li { margin-bottom: 2px; }
.status-decision .button { margin-top: 16px; }
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
@@ -337,9 +343,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }
.integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; }
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: .6rem; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
@@ -453,16 +459,16 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) {
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
}
@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: .6rem; }.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 { display: none; }.page-description { font-size: .78rem; }.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: .53rem; }.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 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: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.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: .57rem; 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; }
#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; }
.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; }
.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; }
}
@media (max-width: 440px) {
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
.global-search { max-width: 144px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 58px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: var(--type-badge); }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
}