feat(ui): introduce Control Rail design system and shell

This commit is contained in:
NuklearRabbit
2026-08-02 03:27:00 +02:00
parent 73e361002d
commit d0f34e6933
5 changed files with 526 additions and 296 deletions
+71
View File
@@ -0,0 +1,71 @@
import type { SVGProps } from "react";
export type IconName =
| "activity"
| "alert"
| "arrow-left"
| "audit"
| "bookings"
| "check"
| "chevron"
| "clock"
| "fleet"
| "integrations"
| "knowledge"
| "logout"
| "menu"
| "quality"
| "search"
| "shield"
| "spark"
| "user"
| "x";
const paths: Record<IconName, JSX.Element> = {
activity: <><path d="M3 12h4l2.5-6 5 12 2.5-6h4" /></>,
alert: <><path d="M10.3 3.4 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
"arrow-left": <><path d="m15 18-6-6 6-6" /></>,
audit: <><path d="M9 4h6" /><path d="M9 2h6v4H9z" /><path d="M7 4H5a2 2 0 0 0-2 2v14h18V6a2 2 0 0 0-2-2h-2" /><path d="m8 13 2 2 5-5" /></>,
bookings: <><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 10h18" /></>,
check: <><path d="m5 12 4 4L19 6" /></>,
chevron: <><path d="m9 18 6-6-6-6" /></>,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
fleet: <><path d="m5 16-1 3h2" /><path d="m19 16 1 3h-2" /><path d="M4 16h16l-1.4-6.2A2.3 2.3 0 0 0 16.4 8H7.6a2.3 2.3 0 0 0-2.2 1.8L4 16Z" /><path d="M6 13h12" /><circle cx="7" cy="16" r="1" /><circle cx="17" cy="16" r="1" /></>,
integrations: <><circle cx="12" cy="12" r="3" /><circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="20" r="2" /><path d="m7 7.5 2.7 2.3m4.6 0L17 7.5M12 15v3" /></>,
knowledge: <><path d="M4 5.5A3.5 3.5 0 0 1 7.5 2H11v18H7.5A3.5 3.5 0 0 0 4 23V5.5Z" /><path d="M20 5.5A3.5 3.5 0 0 0 16.5 2H13v18h3.5A3.5 3.5 0 0 1 20 23V5.5Z" /></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /></>,
menu: <><path d="M4 7h16M4 12h16M4 17h16" /></>,
quality: <><path d="m12 3 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 3Z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
spark: <><path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
x: <><path d="m6 6 12 12M18 6 6 18" /></>,
};
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{paths[name]}
</svg>
);
}
export function BrandMark({ className = "" }: { className?: string }) {
return (
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="currentColor" />
<path d="M3 15.5h26" stroke="#2dd4bf" strokeWidth="2.5" />
</svg>
);
}
+96 -39
View File
@@ -1,19 +1,34 @@
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { BrandMark, Icon, type IconName } from "./Icons";
const NAV_ITEMS = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/data-quality", label: "Data Quality" },
{ to: "/knowledge", label: "Knowledge" },
{ to: "/automation", label: "Automation" },
{ to: "/audit", label: "Audit" },
const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: string; shortLabel: string; icon: IconName }> }> = [
{
label: "Operate",
items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality" },
],
},
{
label: "Assure",
items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
],
},
];
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
export function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
function handleLogout() {
logout();
@@ -22,38 +37,80 @@ export function Layout() {
return (
<div className="app-shell">
<p className="demo-banner">
Synthetic demo environment — no real customer or vehicle data.
</p>
<header className="app-header">
<span className="brand">MobilityOps</span>
<nav aria-label="Primary">
<ul>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="user-badge">
{user && (
<>
<span>
{user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"}
</span>
<button type="button" onClick={handleLogout}>
Switch role
</button>
</>
)}
<a className="skip-link" href="#main-content">Skip to main content</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>MobilityOps</strong><span>Control centre</span></div>
</div>
</header>
<main id="main-content">
<Outlet />
</main>
<nav aria-label="Primary navigation">
{NAV_GROUPS.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
<ul>
{group.items.map((item) => (
<li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
</div>
</aside>
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
<div className="app-workspace">
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
</button>
<label className="global-search">
<Icon name="search" />
<span className="visually-hidden">Search MobilityOps</span>
<input type="search" placeholder="Search fleet, booking or customer…" />
<kbd>Ctrl K</kbd>
</label>
<div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</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" ? "Operations manager" : "Rental employee"}</small></span>
</div>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
<Icon name="logout" />
</button>
</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>
<nav className="mobile-nav" aria-label="Mobile navigation">
{MOBILE_ITEMS.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{item.shortLabel}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>More</span>
</button>
</nav>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import { Icon, type IconName } from "./Icons";
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
<p className="page-eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
);
}
export function SectionHeading({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="section-heading">
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
</div>
);
}
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) {
return (
<div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" />
<p>{label}</p>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div><strong>We couldn’t load this workspace.</strong><p>{message}</p></div>
</div>
);
}
export function EmptyState({
icon = "check",
title,
detail,
}: {
icon?: IconName;
title: string;
detail: string;
}) {
return (
<div className="state-panel state-empty">
<Icon name={icon} />
<div><strong>{title}</strong><p>{detail}</p></div>
</div>
);
}
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
}