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:
co-authored by
Claude Opus 4.7
parent
e387b9a963
commit
f3c95123fa
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { Brand } from "./Brand";
|
||||
import type { PortalTenant } from "@/lib/portal-data";
|
||||
|
||||
// Full-page 410 lockout. Replaces the entire shell — no rail, no nav,
|
||||
// no chrome. Only "Export remaining data" + "Contact support" + sign-out.
|
||||
export function ArchivedLockout({ tenant }: { tenant: PortalTenant }) {
|
||||
return (
|
||||
<div className="lockout">
|
||||
<div className="lockout-card panel bracket">
|
||||
<div className="panel-pad" style={{ padding: 26 }}>
|
||||
<div className="row between" style={{ alignItems: "flex-start" }}>
|
||||
<Brand sub="Customer portal" />
|
||||
<span
|
||||
className="mono"
|
||||
style={{ fontSize: 10, letterSpacing: "0.13em", color: "var(--ink-3)" }}
|
||||
>
|
||||
HTTP 410 · GONE
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, marginTop: 22 }}>
|
||||
This tenant's data-retention window has closed.
|
||||
</h1>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: 8, fontSize: 13 }}>
|
||||
{tenant.name} ({tenant.legalType}) was archived on{" "}
|
||||
<span className="mono">{tenant.archivedOn ?? "—"}</span> and its
|
||||
retention window closed on{" "}
|
||||
<span className="mono">{tenant.retentionClosed ?? "—"}</span>. The
|
||||
portal no longer surfaces the tenant's findings, evidence, or
|
||||
audit log.
|
||||
</p>
|
||||
<div className="lockout-rule" />
|
||||
<dl className="dl">
|
||||
<dt>Tenant</dt>
|
||||
<dd>
|
||||
{tenant.name} · <span className="mono">{tenant.id}</span>
|
||||
</dd>
|
||||
<dt>Archived</dt>
|
||||
<dd className="mono">{tenant.archivedOn ?? "—"}</dd>
|
||||
<dt>Retention closed</dt>
|
||||
<dd className="mono">{tenant.retentionClosed ?? "—"}</dd>
|
||||
<dt>Support</dt>
|
||||
<dd className="mono">support@breakpilot.eu</dd>
|
||||
</dl>
|
||||
<div className="row" style={{ gap: 8, marginTop: 22 }}>
|
||||
<button type="button" className="btn btn-primary">
|
||||
Export remaining data
|
||||
</button>
|
||||
<Link
|
||||
href="mailto:support@breakpilot.eu?subject=Reactivate archived tenant"
|
||||
className="btn"
|
||||
>
|
||||
Contact support
|
||||
</Link>
|
||||
<span className="spacer" />
|
||||
<Link href="/" className="btn btn-ghost">
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Breakpilot wordmark. The trailing "." is added via the .brand-name::after
|
||||
// pseudo-element in globals.css, in the brand violet.
|
||||
|
||||
export function Brand({
|
||||
sub,
|
||||
variant = "dark",
|
||||
}: {
|
||||
sub?: string;
|
||||
variant?: "dark" | "light";
|
||||
}) {
|
||||
return (
|
||||
<div className="brand">
|
||||
<span
|
||||
className="brand-mark"
|
||||
aria-hidden
|
||||
style={variant === "light" ? { background: "#fff", color: "var(--accent)" } : undefined}
|
||||
>
|
||||
B
|
||||
</span>
|
||||
<div className="col" style={{ gap: 0 }}>
|
||||
<span className="brand-name">Breakpilot</span>
|
||||
{sub ? <span className="brand-sub">{sub}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import type { TenantStatus } from "@/lib/session";
|
||||
|
||||
export type LifelineTenant = {
|
||||
status: TenantStatus;
|
||||
slug: string;
|
||||
plan?: string;
|
||||
seats?: { used: number; total: number };
|
||||
trialDaysLeft?: number;
|
||||
trialEnds?: string;
|
||||
frozenReason?: string;
|
||||
};
|
||||
|
||||
// Top full-width rail. Renders different chrome per `tenant.status` per
|
||||
// PLATFORM_ARCHITECTURE.md §5c.
|
||||
// - active → quiet hairline rail; plan · region · seats
|
||||
// - trial → amber rail; countdown + Upgrade CTA
|
||||
// - frozen → red rail; reason + Re-activate
|
||||
// - demo → SANDBOX rail; Exit demo
|
||||
// - archived → never rendered here; the layout swaps in the 410 lockout
|
||||
export function Lifeline({ tenant }: { tenant: LifelineTenant }) {
|
||||
if (tenant.status === "active") {
|
||||
const seats = tenant.seats ? `${tenant.seats.used}/${tenant.seats.total} seats` : "";
|
||||
return (
|
||||
<div className="lifeline is-active" role="status">
|
||||
<span className="ll-dot" style={{ background: "var(--ok)" }} />
|
||||
<span className="ll-strong">Active</span>
|
||||
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
|
||||
All products operational
|
||||
</span>
|
||||
<span className="ll-spacer" />
|
||||
<span className="muted mono" style={{ fontSize: 10.5 }}>
|
||||
{tenant.plan ?? "—"} · eu-central · {seats}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tenant.status === "trial") {
|
||||
return (
|
||||
<div className="lifeline is-trial" role="status">
|
||||
<span className="ll-dot" style={{ background: "var(--warn)" }} />
|
||||
<span className="ll-strong">
|
||||
<span className="ll-count">{tenant.trialDaysLeft ?? 0}</span> days left on trial
|
||||
</span>
|
||||
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
|
||||
ends <span className="mono">{tenant.trialEnds ?? "—"}</span>
|
||||
</span>
|
||||
<span className="ll-spacer" />
|
||||
<Link href={`/${tenant.slug}/billing`} className="btn btn-sm btn-accent">
|
||||
Upgrade now
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tenant.status === "frozen") {
|
||||
return (
|
||||
<div className="lifeline is-frozen" role="status">
|
||||
<span className="ll-dot" style={{ background: "var(--danger)" }} />
|
||||
<span className="ll-strong">Read-only</span>
|
||||
<span
|
||||
className="ll-muted-detail"
|
||||
style={{ fontSize: 11.5, color: "var(--ink-2)" }}
|
||||
>
|
||||
{tenant.frozenReason ?? "Payment failed."} Writes are disabled.
|
||||
</span>
|
||||
<span className="ll-spacer" />
|
||||
<Link href={`/${tenant.slug}/billing`} className="btn btn-sm btn-accent">
|
||||
Re-activate to continue
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tenant.status === "demo") {
|
||||
return (
|
||||
<div className="lifeline is-demo" role="status">
|
||||
<span className="ll-dot" style={{ background: "var(--ink-4)" }} />
|
||||
<span className="ll-strong mono" style={{ letterSpacing: "0.06em" }}>
|
||||
SANDBOX
|
||||
</span>
|
||||
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
|
||||
Shared demo tenant · data resets nightly · changes aren't saved
|
||||
</span>
|
||||
<span className="ll-spacer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function Monogram({
|
||||
text,
|
||||
size = 36,
|
||||
variant = "default",
|
||||
style,
|
||||
}: {
|
||||
text: string;
|
||||
size?: number;
|
||||
variant?: "default" | "soon" | "tenant" | "fixture";
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const className =
|
||||
variant === "soon"
|
||||
? "monogram soon"
|
||||
: variant === "tenant"
|
||||
? "tenant-mono"
|
||||
: variant === "fixture"
|
||||
? "fixture-mono"
|
||||
: "monogram";
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ width: size, height: size, fontSize: Math.max(10, Math.round(size * 0.36)), ...style }}
|
||||
aria-hidden
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Boxes,
|
||||
Workflow,
|
||||
Building2,
|
||||
Users,
|
||||
CreditCard,
|
||||
ScrollText,
|
||||
Shield,
|
||||
Lock,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import type { SessionWithExtras, OrgRole } from "@/lib/session";
|
||||
import { Brand } from "./Brand";
|
||||
import { Monogram } from "./Monogram";
|
||||
import { canAccess, type RouteKey } from "@/lib/fixtures";
|
||||
|
||||
type Icon = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||
|
||||
type NavEntry = {
|
||||
route: RouteKey;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
/** human label shown when the route is locked for the current user */
|
||||
need: string;
|
||||
};
|
||||
|
||||
type NavGroup = { title: string; items: NavEntry[] };
|
||||
|
||||
function buildGroups(slug: string): NavGroup[] {
|
||||
return [
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ route: "dashboard", href: `/${slug}/dashboard`, label: "Overview", icon: LayoutGrid, need: "—" },
|
||||
{ route: "products", href: `/${slug}/products`, label: "Products", icon: Boxes, need: "USER" },
|
||||
{ route: "workflows", href: `/${slug}/workflows`, label: "Workflows", icon: Workflow, need: "IT_ADMIN" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{ route: "org", href: `/${slug}/settings`, label: "Organization", icon: Building2, need: "IT_ADMIN" },
|
||||
{ route: "team", href: `/${slug}/settings/users`, label: "Team", icon: Users, need: "IT_ADMIN" },
|
||||
{ route: "billing", href: `/${slug}/billing`, label: "Billing", icon: CreditCard, need: "CXO / FINANCE" },
|
||||
{ route: "audit", href: `/${slug}/audit`, label: "Audit log", icon: ScrollText, need: "LEGAL" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
items: [
|
||||
{ route: "sso", href: `/${slug}/settings/integrations`, label: "SSO", icon: Shield, need: "IT_ADMIN" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function NavRail({
|
||||
slug,
|
||||
tenant,
|
||||
session,
|
||||
}: {
|
||||
slug: string;
|
||||
tenant: { name: string; short: string; mono: string; plan?: string; status: string };
|
||||
session: SessionWithExtras;
|
||||
}) {
|
||||
const pathname = usePathname() || "";
|
||||
const roles: OrgRole[] = session.org_roles ?? [];
|
||||
const groups = buildGroups(slug);
|
||||
|
||||
return (
|
||||
<aside className="rail">
|
||||
<div className="rail-head">
|
||||
<Brand sub="Customer portal" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="tenant-switch" aria-label="Tenant">
|
||||
<Monogram text={tenant.mono} size={24} variant="tenant" />
|
||||
<div className="tenant-meta">
|
||||
<div className="tn">{tenant.name}</div>
|
||||
<div className="ts mono">
|
||||
<span className="dot accent" /> {tenant.plan ?? "—"} · {tenant.status}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} style={{ color: "var(--ink-3)" }} />
|
||||
</button>
|
||||
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{groups.map((g) => (
|
||||
<div key={g.title} className="nav-group">
|
||||
<div className="nav-group-title label-micro">{g.title}</div>
|
||||
{g.items.map((it) => {
|
||||
const allowed = canAccess(roles, it.route);
|
||||
const active = pathname.startsWith(it.href);
|
||||
const Icon = it.icon;
|
||||
const className =
|
||||
"nav-item" + (active ? " active" : "") + (!allowed ? " disabled" : "");
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div
|
||||
key={it.route}
|
||||
className={className}
|
||||
role="link"
|
||||
aria-disabled
|
||||
title={`Requires ${it.need}`}
|
||||
>
|
||||
<span className="nav-ico">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
{it.label}
|
||||
<span className="nav-lock">
|
||||
<Lock size={11} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link key={it.route} href={it.href} className={className}>
|
||||
<span className="nav-ico">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="rail-foot">
|
||||
<div className="user-chip">
|
||||
<span className="avatar">
|
||||
{(session.user?.name ?? session.user?.email ?? "?")
|
||||
.split(/[\s@.]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase())
|
||||
.join("")}
|
||||
</span>
|
||||
<div className="user-meta">
|
||||
<div className="un">{session.user?.name ?? session.user?.email ?? "—"}</div>
|
||||
<div className="ue">{roles.join(" · ") || "(no roles)"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode, CSSProperties } from "react";
|
||||
|
||||
// Wraps content in the design's bordered panel. `bracket` adds the
|
||||
// distinctive corner ticks. `title` + `tail` produce the panel-head
|
||||
// (left-aligned title, right-aligned tail content like filters).
|
||||
export function Panel({
|
||||
title,
|
||||
tail,
|
||||
bracket,
|
||||
pad = true,
|
||||
style,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
tail?: ReactNode;
|
||||
bracket?: boolean;
|
||||
pad?: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const cls = ["panel", bracket ? "bracket" : "", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<section className={cls} style={style}>
|
||||
{title || tail ? (
|
||||
<header className="panel-head">
|
||||
{title ? <span className="ph-title">{title}</span> : null}
|
||||
{tail ? <span className="ph-tail">{tail}</span> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className={pad ? "panel-pad" : ""}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Severity } from "@/lib/fixtures";
|
||||
|
||||
export function Sev({ level }: { level: Severity }) {
|
||||
return (
|
||||
<span className={`sev ${level}`}>
|
||||
<span className="bar" />
|
||||
{level.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof document === "undefined") return "light";
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
return attr === "dark" ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(readTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
try {
|
||||
localStorage.setItem("bp.theme", next);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label="Toggle theme"
|
||||
title={mounted ? `Switch to ${theme === "dark" ? "light" : "dark"} theme` : "Toggle theme"}
|
||||
>
|
||||
{mounted && theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export type Crumb = { label: string; href?: string };
|
||||
|
||||
// The single 48px topbar. Crumbs left, then a spacer, then the ⌘K button
|
||||
// and the theme toggle.
|
||||
export function Topbar({ crumbs }: { crumbs: Crumb[] }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="crumbs">
|
||||
{crumbs.map((c, i) => {
|
||||
const last = i === crumbs.length - 1;
|
||||
return (
|
||||
<span key={i} className="row" style={{ gap: 8 }}>
|
||||
{i > 0 ? <span className="c-sep">/</span> : null}
|
||||
{last ? (
|
||||
<span className="c-cur">{c.label}</span>
|
||||
) : (
|
||||
<span className="c-prev">{c.label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="topbar-spacer" />
|
||||
<button type="button" className="cmdk-btn" aria-label="Open command palette">
|
||||
<Search size={13} />
|
||||
<span>Jump…</span>
|
||||
<span className="kbd">⌘K</span>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 5x7 calendar heatmap. Cell value 0..4 → accent alpha ramp.
|
||||
const RAMP = [
|
||||
"var(--paper-2)",
|
||||
"color-mix(in oklch, var(--accent) 18%, var(--paper))",
|
||||
"color-mix(in oklch, var(--accent) 38%, var(--paper))",
|
||||
"color-mix(in oklch, var(--accent) 65%, var(--paper))",
|
||||
"var(--accent)",
|
||||
];
|
||||
|
||||
export function Heatmap({ data, cell = 18, gap = 4 }: { data: number[]; cell?: number; gap?: number }) {
|
||||
// 5 weeks across × 7 days down, but the layout is 7 columns (days) × 5 rows (weeks).
|
||||
// We'll render in DOM order matching the source data: 35 cells, row-major.
|
||||
const cols = 7;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, ${cell}px)`,
|
||||
gap,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{data.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: cell,
|
||||
height: cell,
|
||||
borderRadius: 3,
|
||||
background: RAMP[Math.max(0, Math.min(4, v))] ?? RAMP[0],
|
||||
border: "1px solid var(--rule)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeatLegend() {
|
||||
return (
|
||||
<div className="heatlegend">
|
||||
<span>low</span>
|
||||
{RAMP.map((c, i) => (
|
||||
<span key={i} className="hc" style={{ background: c }} />
|
||||
))}
|
||||
<span>high</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Circular gauge for "controls passing". Two-stop arc with the brand
|
||||
// violet on the filled portion.
|
||||
export function Ring({
|
||||
value,
|
||||
total,
|
||||
size = 56,
|
||||
stroke = 5,
|
||||
color = "var(--accent)",
|
||||
track = "var(--rule-2)",
|
||||
}: {
|
||||
value: number;
|
||||
total: number;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
color?: string;
|
||||
track?: string;
|
||||
}) {
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = total > 0 ? value / total : 0;
|
||||
const dash = c * pct;
|
||||
return (
|
||||
<svg width={size} height={size} aria-hidden>
|
||||
<g transform={`translate(${size / 2}, ${size / 2}) rotate(-90)`}>
|
||||
<circle r={r} fill="none" stroke={track} strokeWidth={stroke} />
|
||||
<circle
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Compact bar chart for KPI rail. Last N days, fixed height.
|
||||
export function Sparkbars({
|
||||
data,
|
||||
width = 96,
|
||||
height = 24,
|
||||
color = "var(--ink-2)",
|
||||
}: {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
if (!data.length) return null;
|
||||
const max = Math.max(...data, 1);
|
||||
const barW = width / data.length;
|
||||
return (
|
||||
<svg width={width} height={height} aria-hidden>
|
||||
{data.map((v, i) => {
|
||||
const h = Math.max(1, (v / max) * (height - 2));
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={i * barW + 0.5}
|
||||
y={height - h}
|
||||
width={Math.max(1, barW - 1.2)}
|
||||
height={h}
|
||||
fill={color}
|
||||
opacity={0.85}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Area sparkline. Used for findings/evidence series on the KPI rail
|
||||
// and the big 30-day flow panel.
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 480,
|
||||
height = 80,
|
||||
stroke = "var(--accent)",
|
||||
fill = "var(--accent-2)",
|
||||
}: {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
fill?: string;
|
||||
}) {
|
||||
if (data.length < 2) return null;
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = Math.max(1, max - min);
|
||||
const stepX = width / (data.length - 1);
|
||||
const points = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - 2 - ((v - min) / range) * (height - 4);
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
|
||||
const last = points[points.length - 1];
|
||||
const first = points[0];
|
||||
const area = `${line} L${last[0].toFixed(2)},${height} L${first[0].toFixed(2)},${height} Z`;
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden>
|
||||
<path d={area} fill={fill} />
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Horizontal stacked bar for severity composition. Uses the four
|
||||
// --sev-* tokens.
|
||||
import type { Severity } from "@/lib/fixtures";
|
||||
|
||||
const ORDER: Severity[] = ["critical", "high", "medium", "low"];
|
||||
const COLOR: Record<Severity, string> = {
|
||||
critical: "var(--sev-critical)",
|
||||
high: "var(--sev-high)",
|
||||
medium: "var(--sev-medium)",
|
||||
low: "var(--sev-low)",
|
||||
};
|
||||
|
||||
export function StackBar({
|
||||
counts,
|
||||
height = 7,
|
||||
}: {
|
||||
counts: Record<Severity, number>;
|
||||
height?: number;
|
||||
}) {
|
||||
const total = ORDER.reduce((acc, k) => acc + (counts[k] ?? 0), 0);
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 3,
|
||||
background: "var(--paper-2)",
|
||||
border: "1px solid var(--rule)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
border: "1px solid var(--rule)",
|
||||
}}
|
||||
>
|
||||
{ORDER.map((k) => {
|
||||
const n = counts[k] ?? 0;
|
||||
if (n === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
title={`${k}: ${n}`}
|
||||
style={{ width: `${(n / total) * 100}%`, background: COLOR[k] }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user