feat(portal): M10.2 design system foundations — tokens, shell, Dashboard

Brings in the handoff design system from `Breakpilot Platform.zip`
(`breakpilot/design_handoff_customer_portal/`) as the base for restyling
every customer-area surface.

What's in:

* **Design tokens & layout primitives** — `src/app/globals.css` is the
  handoff `styles.css` in full (OKLCH paper + ink + brand-violet,
  --rule-* hairlines, --sev-* severity ramp, corner-tick bracket
  treatment, ledger table, 32–36px row density, dark mode via
  `[data-theme="dark"]`). Tailwind v4 layered on top via PostCSS for
  utility helpers; the design system itself stays in plain CSS.
* **Geist + Geist Mono** wired through `next/font/google` so the
  monospaced metadata/figures everywhere render at the intended weight.
* **Shell chrome** under `src/components/portal/`:
  `Brand` (Breakpilot. wordmark with the violet trailing dot),
  `Lifeline` (top full-width tenant rail — active / trial / frozen /
  demo variants; archived swaps in `ArchivedLockout`),
  `NavRail` (232px left rail with tenant switcher + workspace/admin/
  settings groups + user chip; locked routes show a lock icon and a
  "Requires X" tooltip rather than vanishing),
  `Topbar` (breadcrumb + ⌘K button placeholder + theme toggle),
  `ThemeToggle` (Sun/Moon, persists to `localStorage["bp.theme"]`,
  no-flash via a head script in the root layout).
* **Dashboard** at `/[slug]/dashboard` rebuilt per handoff §1:
  page-head with Export + Run scan (the latter wrapped in the frozen
  write-guard hovercard surfacing `HTTP 402 · payment required`),
  5-cell bracketed KPI rail (open findings + 14-day sparkbars + 7-day
  delta, critical with severity stack, controls passing with violet
  ring gauge + n/240, evidence area sparkline, last-scan cadence),
  12-col grid: 30-day findings flow + severity stack legend +
  top-5 open findings table on the left, product posture rows +
  scan-activity heatmap (5x7) + recent-activity feed on the right.
  Plain USER role drops the KPI rail and the org-wide panels per spec.
* **Charts** — minimal SVG primitives in `components/portal/charts/`:
  Sparkbars, Sparkline (area + line), Ring, StackBar, Heatmap +
  HeatLegend. All token-driven (`var(--sev-*)`, `var(--accent)`).
* **Fixtures** — `src/lib/fixtures.ts` is a TS port of the handoff's
  `data.js`. Deterministic mulberry32 generators give the same
  realistic DACH/EU compliance data every reload (~5 tenants × 30+ days
  activity / 4–13 findings per product / 9 months invoices / hash-
  chained audit). Source of truth for the design until tenant-registry
  is enriched to carry these fields end-to-end. RBAC table (`canAccess`,
  `landingFor`) ported alongside.
* **Dev session bypass** — `src/lib/get-session.ts` returns a synthetic
  `SessionWithExtras` from one of the 6 fixtures when
  `BP_DEV_FIXTURE=<id>` is set. Lets the portal render the design
  without Keycloak + tenant-registry up. Real Auth.js wiring untouched.

What's NOT in yet (next commits):

* Products / Product launch / Org / Team / Billing / Audit / SSO pages
* Workflows editor (palette + canvas + inspector + drag-wiring)
* Command palette + toast system
* MSW handlers for the tenant data shapes (today the page reads the
  fixture module directly server-side; MSW is for client-side calls)

Run locally:
  pnpm install
  BP_DEV_FIXTURE=admin-acme pnpm dev
  open http://acme.localhost:3000/acme/dashboard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-06-04 13:29:29 +02:00
parent e387b9a963
commit f3c95123fa
25 changed files with 3552 additions and 252 deletions
+61 -26
View File
@@ -1,9 +1,11 @@
import { notFound, redirect } from "next/navigation";
import type { ReactNode } from "react";
import { auth } from "@/auth";
import { Nav } from "@/components/Nav";
import type { SessionWithExtras } from "@/lib/session";
import { fetchTenantBySlug } from "@/lib/tenant-registry";
import { getPortalSession } from "@/lib/get-session";
import { loadTenantForShell } from "@/lib/portal-data";
import { Lifeline } from "@/components/portal/Lifeline";
import { NavRail } from "@/components/portal/NavRail";
import { Topbar } from "@/components/portal/Topbar";
import { ArchivedLockout } from "@/components/portal/ArchivedLockout";
export default async function TenantLayout({
children,
@@ -13,10 +15,10 @@ export default async function TenantLayout({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const tenant = await fetchTenantBySlug(slug);
const tenant = await loadTenantForShell(slug);
if (!tenant) notFound();
const session = (await auth()) as SessionWithExtras | null;
const session = await getPortalSession();
// Tenant mismatch guard — a JWT scoped to tenant A must not be allowed
// to view tenant B. If the slug in the path doesn't match the session
@@ -25,27 +27,60 @@ export default async function TenantLayout({
redirect(`/${session.tenant_slug}/dashboard`);
}
return (
<div style={{ display: "flex", minHeight: "100vh" }}>
{session ? <Nav slug={slug} session={session} /> : null}
<div style={{ flex: 1 }}>
<header
style={{
padding: "12px 24px",
borderBottom: "1px solid #eaeaea",
background: "white",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<strong>{tenant.name}</strong>
<span style={{ fontSize: 12, color: "#666" }}>
{tenant.plan} · {tenant.status}
</span>
</header>
<main style={{ padding: 24 }}>{children}</main>
// Archived tenants get a full-page 410 — no shell, no nav, no chrome.
if (tenant.status === "archived") {
return <ArchivedLockout tenant={tenant} />;
}
// Unauthenticated visitors land on the existing in-page sign-in (each
// route handles its own zero-session affordance).
if (!session) {
return (
<div className="app">
<div className="app-body">
<main className="main">
<div className="content">
<div className="content-inner">{children}</div>
</div>
</main>
</div>
</div>
);
}
return (
<div className="app">
<Lifeline
tenant={{
status: tenant.status,
slug,
plan: tenant.plan,
seats: tenant.seats,
trialDaysLeft: tenant.trialDaysLeft,
trialEnds: tenant.trialEnds,
frozenReason: tenant.frozenReason,
}}
/>
<div className="app-body">
<NavRail
slug={slug}
tenant={{
name: tenant.name,
short: tenant.short,
mono: tenant.mono,
plan: tenant.plan,
status: tenant.status,
}}
session={session}
/>
<main className="main">
<Topbar crumbs={[{ label: tenant.short }]} />
<div className="content">{children}</div>
</main>
</div>
{tenant.status === "demo" ? (
<div className="watermark" aria-hidden />
) : null}
</div>
);
}