The living contract for every surface in the product — foundations, components, and the composed patterns every new screen starts from.
The token layer everything else is built from. Canonical reference: docs/design-language/. These swatches and scales render from the same values the components consume.
Two brand colors — dark green (primary) and burgundy (secondary, used sparingly) — over a neutral gray scale. The five status colors are reserved exclusively for state; never use them for branding, decoration, or hover.
Brand green
green-50
#f0faf4
green-100
#d8f3dc
green-200
#b7e4c7
green-500
#40916c
green-600
#2d6a4f
green-700
#1b4332
green-800
#14532d
green-900
#0f3d29
green-950
#0a2218
Brand burgundy
burgundy-50
#fbf0f2
burgundy-100
#f4dde2
burgundy-200
#e8c7cd
burgundy-600
#9b2d43
burgundy-700
#7f1d2e
burgundy-800
#6b1424
burgundy-900
#4a0e1b
Status colors — reserved
Done, approved, compliant, active
Open, pending, awaiting, expiring
Overdue, rejected, non-compliant
In progress, scheduled, active work
Closed, archived, draft, terminal
Do
Don't
One family, tight scale, maximum weight 600. Hierarchy comes from size, spacing, and color — not from heavier weights.
Page title — Risk register overview
24px · 600 · -0.015emSection title — Risk register overview
18px · 600 · -0.01emCard title — Risk register overview
14px · 600Body — Risk register overview
13.5px · 400 · 1.55lhTable cell — Risk register overview
12.5px · 400Caption / meta — Risk register overview
12px · 400Overline — Risk register overview
10.5px · 600 · caps · 0.12emDo
Don't
4px base grid. Screens wrap in dl-v2 with p-6 and space-y-6 between page-level blocks; inside cards the rhythm tightens to 16px and below.
Two working radii — 8px for controls, 12px for surfaces — kept concentric (a control inside a card visually nests). Depth comes from soft shadows plus a hairline ring, never hard borders.
--dl-radius-sm · 4px
Badges, tiny chips
--dl-radius-control · 8px
Buttons, fields, chips — every control
--dl-radius-card · 12px
Cards, tables, panels — every surface
--dl-radius-2xl · 16px
Large feature surfaces only
--dl-shadow-xs
Primary buttons
--dl-shadow-card
Resting cards and panels
--dl-shadow-pop
Popovers, dropdowns
--dl-shadow-lg
Dialogs, sheets
Fast and purposeful: 150ms for micro-interactions, 300ms ceiling for anything structural. Transitions name their properties explicitly (no transition-all), buttons press with scale(0.96), and dl-v2 respects prefers-reduced-motion.
One button system. Flat brand green is the single primary; everything else is structurally quieter. One primary action per screen.
Two field families with one look: Field* primitives for drawers and form screens (wrapper-div focus ring), and the FIELD_BASE set for table toolbars. Never write a raw <input>, <textarea>, or <select> in a drawer or form screen.
The drawer-form text primitives. The focus ring lives on a wrapper div via focus-within, so the green stroke holds while typing — writing the ring on the <input> itself is the bug these exist to prevent.
import { FieldInput, FieldTextarea } from '@/components/shared/surface-v2/forms';Risk title
Description
| Prop | Type | Default | Notes |
|---|---|---|---|
| …input props | React.ComponentProps<'input' | 'textarea'> | — | Forwarded straight through; ref supported |
Do
Don't
Custom popover dropdown that accepts native <option> children and a native-shaped onChange, so call sites read like a <select>. Per the design language we never render system dropdowns.
import { FieldSelect } from '@/components/shared/surface-v2/forms';Owner
| Prop | Type | Default | Notes |
|---|---|---|---|
| value / onChange | select-shaped | — | onChange receives { target: { value } } |
| placeholder | string | — | |
| children | <option> elements | — | disabled options supported |
FormSection is the white section card with an overline title that groups related fields in drawers; FieldLabel is the quiet 11.5px label that sits above every field.
import { FieldLabel, FormSection } from '@/components/shared/surface-v2/forms';Assessment details
Framework
Scope note
| Prop | Type | Default | Notes |
|---|---|---|---|
| FormSection.title | string | — | Rendered as uppercase overline |
| FieldLabel.required | boolean | — | Adds the red asterisk |
The FIELD_BASE family: h-9 fields with a calm shape-matched focus ring, used in table toolbars and inline filters. Field wraps any of them with label, hint, and error handling. FIELD_BASE itself is exported for custom triggers that must line up with fields (the DatePicker uses it).
import { Input, Select, SearchInput, Field, FIELD_BASE } from '@/components/shared/surface-v2/forms';Select a status to continue.
| Prop | Type | Default | Notes |
|---|---|---|---|
| Field.label | string | — | |
| Field.required | boolean | — | |
| Field.hint | string | — | Right-aligned helper next to the label |
| Field.error | string | — | Danger text under the field |
FilterRow lays out a register's filter toolbar and shows its Clear button automatically when hasFilters is true. FilterChip is the removable applied-filter token. For multi-select filter groups with counts, use FilterBar (Data display) instead.
import { FilterRow, FilterChip } from '@/components/shared/surface-v2/forms';| Prop | Type | Default | Notes |
|---|---|---|---|
| FilterRow.hasFilters | boolean | — | Gates the automatic Clear button |
| FilterRow.onClear | () => void | — | |
| FilterChip.onRemove | () => void | — | Omit to render a static chip |
Date, time, and timezone selection. CalendarDatePicker is the blessed date field for drawers; all of them share the field silhouette so they sit flush next to inputs.
The canonical date field for drawer forms — string in, string out (yyyy-MM-dd), with a clear affordance on the trigger. Known constraint: don’t put it inside an overflow-y-auto container in a dialog; the popover clips.
import { CalendarDatePicker } from '@/components/ui/calendar-date-picker';Due date
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | string | null | — | yyyy-MM-dd |
| onChange | (value: string | null) => void | — | |
| placeholder | string | 'Select date' |
Don't
FIELD_BASE-styled date trigger for toolbar contexts — h-9, lines up exactly with Input and Select. Emits full ISO strings (unlike CalendarDatePicker’s yyyy-MM-dd). No screen consumes it yet; prefer CalendarDatePicker in drawers until this is blessed.
import { DatePicker } from '@/components/shared/surface-v2/date-picker';| Prop | Type | Default | Notes |
|---|---|---|---|
| value | string | null | — | ISO string |
| onChange | (value: string | null) => void | — | |
| placeholder | string | 'Select date' |
24h time picker with a two-column hour/minute popover (5-minute steps). Value is an HH:mm string.
import { TimePickerField } from '@/components/shared/surface-v2/time-picker';Report time
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | string | null | — | HH:mm |
| onChange | (time: string | null) => void | — | |
| placeholder | string | '––:––' |
Searchable IANA timezone picker with a curated zone list. Used alongside TimePickerField in scheduling settings.
import { TimezoneSelect } from '@/components/shared/surface-v2/timezone-select';Timezone
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | string | null | — | IANA zone, e.g. Africa/Lagos |
| onChange | (tz: string | null) => void | — | |
| placeholder | string | '— Select timezone —' |
The register shell — DataTable inside a ring card with FilterBar in the toolbar slot, KPI cards above. This trio is the default anatomy for every list screen.
Flush-edge table in a 12px ring card. Columns declare header, cell, alignment, and a numeric flag (right-aligned tabular figures automatically). Built-in index column, skeleton rows while loading, empty state with optional CTA, and pagination when pageSize is set. This demo is live — search and filter above the table.
import { DataTable, type DataColumn } from '@/components/shared/surface-v2/data-table';| # | Risk | Client | Severity | Status | Score | Due |
|---|---|---|---|---|---|---|
| 1 | Privileged access reviews overdue RSK-0041 | Human Resources Ltd | Critical | OPEN | 20 | 18 Apr 2026 |
| 2 | Quarterly access evidence pending review RSK-0042 | Northwind Systems | High | In Treatment | 16 | 20 Apr 2026 |
| 3 | Patch baseline for executive laptops RSK-0043 | Human Resources Ltd | Medium | Ready for Review | 9 | 24 Apr 2026 |
| 4 | Vendor SLA breach — backup restore times RSK-0044 | Acme Payments | High | OPEN | 15 | 28 Apr 2026 |
| 5 | Data-retention schedule not enforced on shared drives RSK-0045 | Northwind Systems | Low | MITIGATED | 4 | 30 Apr 2026 |
Click a row — onRowClick drives the hover tint and cursor.
| Prop | Type | Default | Notes |
|---|---|---|---|
| data / columns | T[] / DataColumn<T>[] | — | |
| toolbar | ReactNode | — | Renders above the header row inside the card — put FilterBar here |
| onRowClick | (row, i) => void | — | Hover tint and pointer cursor are gated on this prop |
| pageSize | number | — | Enables pagination; omit to show all rows |
| showIndex | boolean | true | Leading # column |
| isLoading | boolean | false | Shimmer rows while data is empty |
| empty / emptyAction | ReactNode | — | Empty-state text and CTA |
| compact | boolean | false | Tighter padding for dense tables |
| getRowKey / getRowClassName | fn | — | Stable keys / selected-row styling |
Do
Don't
Search + multi-select filter dropdowns + applied-filter chips + automatic Clear all. State lives in the caller as a Record<string, string[]>. It's shown live in the DataTable toolbar above; SelectDropdown from the same module is the single-select variant for sort orders and views.
import { FilterBar, type FilterGroup, type FilterValue } from '@/components/shared/surface-v2/filter-bar';| Prop | Type | Default | Notes |
|---|---|---|---|
| groups | FilterGroup[] | — | { key, label, options: { value, label }[] } |
| value / onChange | FilterValue / (key, vals) => void | — | Multi-select per group |
| search / onSearch | string / (val) => void | — | Omit onSearch to hide the search box |
| onClear | () => void | — | Wired to the automatic Clear all button |
The register-screen KPI: overline label, 32px tabular number, toned sub-line, optional href that makes the whole card a link with a hover lift. Lives in dashboard-shared today — candidate for promotion into surface-v2.
import { KpiCard } from '@/components/admin/workspace/dashboard-shared';Total risks
128
Across 12 clients
Open
41
8 added this month
Overdue reviews
6
Needs attention
Mitigated
81
63% of register
| Prop | Type | Default | Notes |
|---|---|---|---|
| label | string | — | |
| value | string | number | — | |
| sub | string | — | Toned caption under the number |
| tone | 'neutral' | 'ok' | 'warning' | 'critical' | 'brand' | 'neutral' | Colors the sub-line only — never the number |
| href | string | null | — | Makes the card a Link |
Do
Don't
Metric card with a brand-green number and a semantic trend line — the trend prop encodes direction and goodness separately (up-bad exists: rising risk counts are up and bad).
import { StatCard } from '@/components/shared/surface-v2/stat-card';Evidence approved
46
+12% vs last quarter
Open findings
17
+3 this week
Avg. closure time
8.5days
-1.2 days
| Prop | Type | Default | Notes |
|---|---|---|---|
| label | string | — | |
| value / unit | string | number / string | — | |
| delta | string | — | e.g. "+12% vs last quarter" |
| trend | 'up-good' | 'up-bad' | 'down-good' | 'down-bad' | 'flat' | 'flat' | |
| icon | ReactNode | — |
One color vocabulary, shared with the dashboard charts: amber = open/pending, blue = in progress, purple = under review, red = needs action, green = done, gray = terminal. Each distinct value gets a distinct color — the badge system resolves it for you.
The generic value-driven badge. Pass any status string — color, dot, and human label are looked up from the master spec (80+ known values, roles included; unknown values fall back to neutral gray). Pass label to override display text.
import { StatusBadge } from '@/components/shared/surface-v2/badges';Workflow
Lifecycle
Roles
Unknown value → neutral fallback
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | string | null | — | Any status string; normalised to UPPER_SNAKE for lookup |
| label | string | — | Override the auto-humanised text |
| tone | 'info' | 'success' | 'danger' | 'neutral' | 'accent' | — | Legacy escape hatch — prefer value |
Do
Don't
RiskLevelBadge renders the severity spectrum, optionally prefixed with the numeric score. RiskStatusBadge wraps StatusBadge with riskDomainStatusLabel so legacy backend states map to the six canonical display labels.
import { RiskLevelBadge, RiskStatusBadge } from '@/components/shared/surface-v2/badges';Severity spectrum
With score
Risk workflow (legacy values normalised)
| Prop | Type | Default | Notes |
|---|---|---|---|
| RiskLevelBadge.level | string | null | — | Critical → Very Low |
| RiskLevelBadge.score | number | null | — | Rendered as "16 · High" when both given |
| RiskStatusBadge.status | string | null | — | Raw backend status |
Domain-specific wrappers: GapStatusBadge collapses all backend gap states to the five canonical labels; TreatmentBadge maps treatment options (TREAT displays as Mitigate); SourceBadge canonicalises origin aliases; ActionBadge labels audit-log CRUD actions.
import { GapStatusBadge, TreatmentBadge, SourceBadge, ActionBadge } from '@/components/shared/surface-v2/badges';Gap workflow
Treatment options
Sources
Audit actions
The older tone-token pill kept for prop-contract parity with surface v1 (2 consumers). Same name, different module — this is the known naming collision. Canonical badge is StatusBadge from badges.tsx; migrate the remaining consumers and retire this file.
import { StatusBadge } from '@/components/shared/surface-v2/status-badge';Don't
System-to-user communication: toned alert surfaces and the shimmer skeleton family that stands in for every page archetype while it loads.
One feedback surface, two uses: rendered inline as a toned callout, and rendered by the toast engine as the card for every surfaceToast.* call in the app — so transient notifications and inline alerts speak the same visual language. Fire toasts through surfaceToast (from @/lib/surface-toast), not by mounting Alert yourself.
import { Alert } from '@/components/shared/surface-v2/feedback';| Prop | Type | Default | Notes |
|---|---|---|---|
| tone | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'info' | |
| title | ReactNode | — | |
| children | ReactNode | — | Optional body text |
Do
Shimmer placeholders for every page archetype. The four full-page compositions (SkeletonTablePage, SkeletonDashboard, SkeletonFormPage, SkeletonDetailPage) go in loading.tsx files and isLoading branches; the building blocks below (SkeletonPageHeader, SkeletonKpiRow, SkeletonTable) compose custom layouts. All accept slowLoad to reveal a 'taking longer than usual' pill.
import { SkeletonTablePage, SkeletonDashboard, SkeletonFormPage, SkeletonDetailPage } from '@/components/shared/surface-v2/skeletons';| Prop | Type | Default | Notes |
|---|---|---|---|
| slowLoad | boolean | false | Full-page variants only |
| cols / rows | number | — | SkeletonTable shape |
| kpiCount | number | 4 | SkeletonKpiRow / page variants |
Do
Confirmation and data-loss protection. Both are wired live — click the triggers. Overlay rules: gap-0 on DialogContent, footers use surface-v2 Button, and every form drawer gets the unsaved guard.
One confirm dialog for all destructive intents. Set a PendingAction in state to open it; title, body, and running labels derive from the type. The async action runs with a busy state, then the dialog closes itself.
import { ConfirmActionDialog, type PendingAction } from '@/components/shared/surface-v2/confirm-action-dialog';| Prop | Type | Default | Notes |
|---|---|---|---|
| pending | PendingAction | null | — | { action: () => Promise<void>, label, type } |
| pending.type | 'archive' | 'delete' | 'remove' | 'revoke' | — | Drives copy and destructive styling |
| onClose | () => void | — | Clear the pending state here |
Do
Don't
Guards controlled Sheets/Dialogs against accidental data loss. Every close vector (click-outside, Esc, ✕) funnels through onOpenChange; when the form is dirty the guard swaps the close for a confirm. Clean forms still dismiss instantly. Wire it into every new form drawer.
import { useUnsavedGuard, DiscardChangesDialog } from '@/components/shared/surface-v2/unsaved-guard';| Prop | Type | Default | Notes |
|---|---|---|---|
| isDirty | boolean | — | Your form-changed flag |
| onClose | () => void | — | The real close (reset state here) |
| → returns | { onOpenChange, requestClose, confirmOpen, setConfirmOpen, confirmDiscard } | — | onOpenChange goes on the Sheet, requestClose on Cancel buttons |
Do
The scaffolding of a screen: page header, section dividers, cards, dashboard panels, and the wizard shell.
Every content screen starts with PageHeader — title, optional description, actions on the right. SectionLabel is the overline-plus-rule divider between page blocks. size='md' renders an h2 for nested contexts.
import { PageHeader, SectionLabel } from '@/components/shared/surface-v2/page';Track, score, and treat risks across all client engagements.
…content…
| Prop | Type | Default | Notes |
|---|---|---|---|
| title | string | — | |
| description | string | — | |
| actions | ReactNode | — | The one primary action lives here |
| size | 'lg' | 'md' | 'lg' | lg = h1 24px · md = h2 18px |
Do
The plain white surface: 12px radius, soft card shadow, hairline ring, p-4. flush removes padding so tables meet the edges. StatCard builds on it internally; no screen imports it directly yet — most screens compose the same recipe inline.
import { Card, CardHeader } from '@/components/shared/surface-v2/card';Default card — p-4 padding.
Header + edge-to-edge body
| Prop | Type | Default | Notes |
|---|---|---|---|
| flush | boolean | — | No inner padding + overflow-hidden — for tables |
| CardHeader.title / subtitle | string | — | |
| CardHeader.actions | ReactNode | — |
Token-native dashboard primitives: Panel is the titled container; inside it, PanelListItem (clickable rows with optional progress), PanelStatRow (label/value), PanelProgressRow, PanelEmpty, and ActivityRow (avatar feed line). Built as the v2 replacement for the legacy dashboard-primitives — ready to adopt.
import { Panel, PanelListItem, PanelStatRow, PanelProgressRow, PanelEmpty, ActivityRow } from '@/components/shared/surface-v2/dashboard';Projects at risk
Sorted by exposure
Nothing else at risk
All other projects are on track.
This week
Recent activity
Amina Joseph approved Access-control evidence
2h ago
Marcus Chen reopened RSK-0042
5h ago
Overall programme completion
71%38 of 54 deliverables done
| Prop | Type | Default | Notes |
|---|---|---|---|
| Panel.title / description / action | string / string / ReactNode | — | |
| PanelListItem.progress | number | — | Adds the completion bar |
| PanelStatRow.tone | 'default' | 'danger' | 'warning' | 'success' | 'default' | |
| ActivityRow.actor / action / target / time | string | — |
Connected horizontal step track (1-indexed activeStep, completed steps become clickable when onStepChange is provided) plus the gray content shell. The v2 wizard shell — ready to adopt in risk creation and onboarding flows.
import { StepTrack, StepPanel, type StepItem } from '@/components/shared/surface-v2/stepper';Context
Controls
Review
Step 2 content — Controls. Completed steps are clickable.
| Prop | Type | Default | Notes |
|---|---|---|---|
| steps | StepItem[] | — | { key, label } |
| activeStep | number | — | 1-indexed |
| onStepChange | (step: number) => void | — | Enables clicking back to completed steps |
The two compositions every new screen starts from, built live from the real primitives. risk-table.tsx is the reference register implementation.
The default anatomy for every list screen: PageHeader with the one primary action, a KpiCard row that deep-links into filtered views, then DataTable with the filter toolbar in its toolbar slot. Loading state is SkeletonTablePage in loading.tsx.
Track, score, and treat risks across all engagements.
Total risks
128
Across 12 clients
Open
41
8 added this month
Overdue reviews
6
Needs attention
| # | Risk | Severity | Status | Due |
|---|---|---|---|---|
| 1 | Privileged access reviews overdue | Critical | OPEN | 18 Apr 2026 |
| 2 | Quarterly access evidence pending | High | In Treatment | 20 Apr 2026 |
| 3 | Patch baseline for executive laptops | Medium | MITIGATED | 24 Apr 2026 |
<div className="dl-v2 space-y-6 p-6"><PageHeader title description actions={<Button>New risk</Button>} />// one primary<div className="grid grid-cols-4 gap-4"><KpiCard label value sub tone href />// links to filtered view</div><DataTabletoolbar={<FilterBar search groups value onChange onClear />}columns={columns} data={rows} pageSize={25}onRowClick={openDetail} emptyAction={<Button>…</Button>}/></div>
Do
Don't
Create/edit flows live in a right-side Sheet: FormSection groups with FieldLabel + Field* primitives, CalendarDatePicker for dates, footer actions right-aligned, and the unsaved guard on every close vector. The panel below is a static miniature of the anatomy — the guard itself is demoed live under Overlays.
New risk
Click-outside and Esc are guarded when dirty.
Details
Title
Owner
Description
const guard = useUnsavedGuard({ isDirty, onClose: () => setOpen(false) });<Sheet open={open} onOpenChange={guard.onOpenChange}><SheetContent side="right" className="dl-v2"><SheetHeader>…</SheetHeader><FormSection title="Details"><FieldLabel required>Title</FieldLabel><FieldInput … />// never raw <input><FieldSelect … /> <CalendarDatePicker … /></FormSection><Button variant="secondary" onClick={guard.requestClose}>Cancel</Button><Button loading={saving} loadingText="Saving…">Save</Button></SheetContent></Sheet><DiscardChangesDialog open={guard.confirmOpen} … />
Do
Don't