feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page

Adds GET /api/v1/demo/manifest as a single source of truth for the demo's
fictional org identity (Northstar Mobility -- surfacing the project's
already-locked tenant name), synthetic-data/reset state, and live scenario
readiness. Rewrites the login screen in Dutch with an honest, no-password
demo entry and a guided-demo entry point, replaces the loud full-width
demo banner with a subtle badge + popover, and adds a compact About page
explaining what's real vs. synthetic vs. not yet connected.
This commit is contained in:
NuklearRabbit
2026-08-03 13:45:55 +02:00
parent 728e380d63
commit ac427f4427
25 changed files with 919 additions and 108 deletions
+30 -25
View File
@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login";
@@ -13,33 +14,37 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation";
import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit";
import { AboutDemo } from "./pages/AboutDemo";
export function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
<DemoManifestProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
<Route path="/about" element={<AboutDemo />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
</DemoManifestProvider>
);
}
+34
View File
@@ -252,6 +252,40 @@ export interface IntegrationStatus {
mcp_hub: McpHubIntegrationStatus;
}
export interface DemoScenario {
id: string;
title: string;
operational_problem: string;
estimated_minutes: number;
required_roles: Role[];
start_path: string;
demonstrates: string;
ready: boolean;
blocked_reason: string | null;
}
export interface DemoIntegrationSummary {
key: "n8n" | "ragcore" | "mcp_hub";
label: string;
status_label: string;
detail: string;
}
export interface DemoManifest {
demo_mode: boolean;
organization_name: string;
organization_description: string;
timezone: string;
synthetic_data: boolean;
allow_reset: boolean;
last_reset_at: string | null;
anchor_date: string | null;
guide_available: boolean;
required_roles: Role[];
scenarios: DemoScenario[];
integrations: DemoIntegrationSummary[];
}
export interface AuditEvent {
id: string;
actor_type: string;
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "./Icons";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
export function DemoBadge() {
const { manifest } = useDemoManifest();
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
document.removeEventListener("keydown", handleEscape);
};
}, []);
return (
<div className="demo-badge" ref={boxRef}>
<button
type="button"
className="demo-badge-trigger"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<Icon name="shield" />
<span>Synthetische demo</span>
</button>
{open && (
<div className="demo-badge-popover" role="dialog" aria-label="Over deze demo-omgeving">
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label="Sluiten">
<Icon name="x" />
</button>
<p>
{manifest ? (
<>
<strong>{manifest.organization_name}</strong> is een fictieve organisatie. Alle
namen, voertuigen en boekingen zijn synthetisch.
</>
) : (
"Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch."
)}
</p>
<p>
De workflows, controles en automatisering zijn echt geïmplementeerd enkel de
gegevens zijn verzonnen.
</p>
{manifest && (
<p className="demo-badge-reset">
Laatste reset: <strong>{formatDateTime(manifest.last_reset_at)}</strong> · deze
omgeving is op elk moment herstelbaar.
</p>
)}
<Link to="/about" onClick={() => setOpen(false)}>
Over deze demo <Icon name="chevron" />
</Link>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge";
import { useDemoManifest } from "../context/DemoManifestContext";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
@@ -42,6 +44,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
export function Layout() {
const { user, logout } = useAuth();
const { manifest } = useDemoManifest();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -192,7 +195,7 @@ export function Layout() {
<span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
</div>
{user?.role === "operations_manager" && (
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
{!resetConfirming ? (
@@ -274,6 +277,7 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<DemoBadge />
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && (
<div className="operator">
@@ -287,7 +291,6 @@ export function Layout() {
</div>
</header>
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
</div>
@@ -0,0 +1,52 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { api } from "../api/client";
import type { DemoManifest } from "../api/types";
interface DemoManifestState {
manifest: DemoManifest | null;
loading: boolean;
refresh: () => void;
}
const DemoManifestContext = createContext<DemoManifestState | undefined>(undefined);
export function DemoManifestProvider({ children }: { children: ReactNode }) {
const [manifest, setManifest] = useState<DemoManifest | null>(null);
const [loading, setLoading] = useState(true);
const [version, setVersion] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
// Public endpoint by design: the demo-entry screen needs this before any session
// exists, so it is never gated behind auth.
api
.get<DemoManifest>("/api/v1/demo/manifest")
.then((result) => {
if (!cancelled) setManifest(result);
})
.catch(() => {
if (!cancelled) setManifest(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [version]);
return (
<DemoManifestContext.Provider
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
>
{children}
</DemoManifestContext.Provider>
);
}
export function useDemoManifest(): DemoManifestState {
const ctx = useContext(DemoManifestContext);
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
return ctx;
}
+124
View File
@@ -0,0 +1,124 @@
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
n8n: "n8n",
ragcore: "rag",
mcp_hub: "mcp",
};
export function AboutDemo() {
const { manifest, loading } = useDemoManifest();
const { user } = useAuth();
return (
<div className="page">
<PageHeader
eyebrow="Over deze demo"
title="Wat MobilityOps wel en niet is"
description={
manifest
? `${manifest.organization_name} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.`
: undefined
}
/>
{loading && <LoadingState label="Demo-informatie laden…" />}
{manifest && (
<>
<section className="record-surface about-card">
<h2>Het fictieve probleem</h2>
<p>
{manifest.organization_name} verhuurt zo'n 50 campers en bestelwagens vanuit één
hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit
losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten,
foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen.
MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en
gecontroleerd laat oplossen.
</p>
</section>
<section className="record-surface about-card">
<h2>Wat écht werkt</h2>
<p>
Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde
toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met
serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen
oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n
met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde
testsuite (backend en Playwright end-to-end).
</p>
</section>
<section className="record-surface about-card">
<h2>Wat synthetisch is</h2>
<p>
De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis,
procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig
verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of
bedrijf; e-mailadressen gebruiken uitsluitend het testdomein <code>.test</code>.
</p>
</section>
<section aria-label="Koppelingsstatus" className="record-surface">
<SectionHeading
title="Koppelingen — eerlijk gelabeld"
description="Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is."
/>
<div className="integration-cards">
{manifest.integrations.map((integration) => (
<article key={integration.key}>
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
<div>
<span className="integration-kicker">{integration.status_label}</span>
<h2>{integration.label}</h2>
<p>{integration.detail}</p>
</div>
</article>
))}
</div>
</section>
<section className="record-surface about-card">
<h2>Demo-omgeving herstellen</h2>
<p>
De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "}
<strong>{formatDateTime(manifest.last_reset_at)}</strong>.{" "}
{user?.role === "operations_manager" ? (
<>
Gebruik <strong>Reset demo data</strong> in de zijbalk om opnieuw te beginnen.
</>
) : (
<>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.</>
)}
</p>
</section>
<section className="access-note record-surface" aria-label="Beperkingen">
<Icon name="shield" />
<p>
<strong>Beperkingen</strong>
<span>
Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx
MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale,
afgebakende demokennisbank in plaats van een live RAGcore-omgeving.
</span>
</p>
</section>
</>
)}
</div>
);
}
+36 -16
View File
@@ -1,32 +1,41 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import type { Role } from "../api/types";
import { BrandMark, Icon } from "../components/Icons";
export function Login() {
const { loginAs, loading } = useAuth();
const { manifest } = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
async function handleLogin(role: Role) {
async function handleLogin(role: Role, guided = false) {
setError(null);
try {
await loginAs(role);
navigate("/dashboard");
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
} catch {
setError("Could not start a demo session. The API may be unavailable.");
setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.");
}
}
const orgName = manifest?.organization_name ?? "Northstar Mobility";
const description =
manifest?.organization_description ??
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " +
"verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " +
"vervolgstappen.";
return (
<main className="login-shell">
<section className="login-story" aria-labelledby="product-name">
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Control centre</span></div></div>
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Bedieningscentrum</span></div></div>
<div className="login-message">
<p className="eyebrow">Connected mobility operations</p>
<h1 id="product-name">Every hand-off.<br />One clear view.</h1>
<p>Turn fleet state, rental returns, data quality and automation into one calm operational rhythm.</p>
<p className="eyebrow">Demo-organisatie: {orgName} (fictief)</p>
<h1 id="product-name">Elke overdracht.<br />Eén helder overzicht.</h1>
<p>{description}</p>
</div>
<div className="control-illustration" aria-hidden="true">
<div className="illustration-orbit orbit-one"><span /></div>
@@ -36,28 +45,39 @@ export function Login() {
<span className="illustration-node node-two"><Icon name="bookings" /></span>
<span className="illustration-node node-three"><Icon name="quality" /></span>
</div>
<p className="login-footnote"><Icon name="shield" /> Synthetic proof of concept · no real customer data</p>
<p className="login-footnote"><Icon name="shield" /> Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar</p>
</section>
<section className="login-access" aria-labelledby="login-heading">
<div className="login-panel">
<p className="page-eyebrow">Demo access</p>
<h2 id="login-heading">Choose your workspace</h2>
<p className="login-intro">No password is required. Each role opens a scoped synthetic environment.</p>
<p className="page-eyebrow">Demo-toegang</p>
<h2 id="login-heading">Kies hoe je wil starten</h2>
<p className="login-intro">Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving alle workflows en controles zijn echt geïmplementeerd.</p>
{error && <p className="error" role="alert">{error}</p>}
<button
type="button"
className="button button-primary login-guide-cta"
disabled={loading}
onClick={() => handleLogin("operations_manager", true)}
>
<Icon name="spark" />
Start begeleide demo
</button>
<div className="login-options">
<button type="button" aria-label="Open as Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
<button type="button" aria-label="Verken als Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span>
<span><strong>Operations Manager</strong><small>Full overview, quality resolution and retries</small></span>
<span><strong>Verken als Operations Manager</strong><small>Volledig overzicht, kwaliteitsoplossing en herpogingen</small></span>
<Icon name="chevron" />
</button>
<button type="button" aria-label="Open as Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
<button type="button" aria-label="Verken als Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
<span className="role-icon"><Icon name="user" /></span>
<span><strong>Rental Employee</strong><small>Bookings, returns, fleet and procedures</small></span>
<span><strong>Verken als Rental Employee</strong><small>Boekingen, retours, wagenpark en procedures</small></span>
<Icon name="chevron" />
</button>
</div>
<div className="access-note"><Icon name="shield" /><p><strong>Safe by design</strong><span>All actions are audited and resettable in this demo.</span></p></div>
<div className="access-note"><Icon name="shield" /><p><strong>Veilig ontworpen</strong><span>Elke actie wordt gelogd en is in deze demo herstelbaar.</span></p></div>
</div>
</section>
</main>
+16 -4
View File
@@ -103,8 +103,17 @@ a:hover { color: var(--teal); }
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
.icon-button svg { width: 18px; height: 18px; }
.mobile-menu { display: none; }
.demo-banner { min-height: 32px; margin: 0; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 6px 20px; color: #48566a; background: #eaf0f5; border-bottom: 1px solid #d7e0e8; font-size: .68rem; font-weight: 600; letter-spacing: .02em; }
.demo-banner svg { width: 14px; height: 14px; }
.demo-badge { position: relative; }
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
.demo-badge-trigger:hover { background: #dfe8ef; }
.demo-badge-trigger svg { width: 13px; height: 13px; }
.demo-badge-popover { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(320px, 84vw); padding: 16px; display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }
.demo-badge-popover p { margin: 0; color: var(--ink-soft); font-size: .74rem; line-height: 1.55; }
.demo-badge-reset { color: var(--muted) !important; font-size: .68rem !important; }
.demo-badge-popover a { display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .74rem; font-weight: 700; }
.demo-badge-popover a svg { width: 13px; }
.demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; }
.demo-badge-close svg { width: 14px; height: 14px; }
#main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; }
.app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: var(--muted); border-top: 1px solid var(--line); font-size: .68rem; }
.mobile-nav, .nav-scrim { display: none; }
@@ -219,6 +228,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; }
.tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); }
.record-surface { padding: 18px; margin-bottom: 18px; }
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }
.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; }
@@ -260,7 +272,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.login-brand { height: auto; padding: 0; border: 0; }.login-brand .brand-mark { width: 36px; height: 36px; }.login-brand strong { font-size: 1.05rem; }.login-message { position: relative; z-index: 2; max-width: 620px; margin: auto 0 22px; }.login-message .eyebrow { color: #5eead4; }.login-message h1 { margin: 0; color: white; font-size: clamp(2.8rem, 5.5vw, 5.4rem); line-height: .98; letter-spacing: -.06em; }.login-message > p:last-child { max-width: 520px; margin: 23px 0 0; color: #a9b7c8; font-size: .98rem; line-height: 1.65; }
.control-illustration { position: absolute; width: min(35vw, 500px); aspect-ratio: 1; right: -80px; top: 7vh; opacity: .88; }.illustration-orbit { position: absolute; inset: 10%; border: 1px solid #29435a; border-radius: 50%; }.orbit-two { inset: 28%; border-style: dashed; transform: rotate(35deg); }.illustration-orbit::before, .illustration-orbit::after { content: ""; position: absolute; background: #2dd4bf; border-radius: 50%; }.illustration-orbit::before { width: 7px; height: 7px; top: 13%; left: 16%; }.illustration-orbit::after { width: 5px; height: 5px; right: 2%; bottom: 33%; }.illustration-core { position: absolute; inset: 40%; display: grid; place-items: center; color: white; background: #172a3e; border: 1px solid #2c465e; border-radius: 50%; }.illustration-core svg { width: 45px; }.illustration-node { position: absolute; width: 42px; height: 42px; display: grid; place-items: center; color: #5eead4; background: #152b3f; border: 1px solid #2c465e; border-radius: 50%; }.illustration-node svg { width: 18px; }.node-one { top: 13%; left: 20%; }.node-two { right: 5%; bottom: 27%; }.node-three { left: 15%; bottom: 12%; }
.login-footnote { position: relative; z-index: 2; display: flex; align-items: center; gap: 7px; margin: auto 0 0; color: #7f90a4; font-size: .68rem; }.login-footnote svg { width: 14px; }
.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; }
.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-guide-cta { width: 100%; min-height: 48px; margin-bottom: 18px; font-size: .85rem; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; }
@media (max-width: 1120px) {
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); }
@@ -271,7 +283,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
}
@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-banner { min-height: 28px; padding-inline: 10px; font-size: .59rem; }.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; }
#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; }.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; }
.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; }