Almost everything in this documentation is a primitive — a button, a card, an overlay. This is the frame those sit in, and it is the single most-used piece of design in the product: sixteen routes are built from it, from Today to Settings to the CRM. If a new operating surface does not start here, it will not look like Morna.
apps/web/components/design/WorkspacePage.tsx, not in @morna/ui. That is deliberate and worth knowing: they are the application’s page grammar, composed from tokens, not a distributable primitive. Every specimen below is that module — imported and rendered, not copied — so this page cannot quietly drift from the sixteen routes it documents.The frame
Three components, always in this order: WorkspacePage sets the measure, WorkspaceHeader names the surface and carries its actions, and WorkspaceSection divides the body. There is no card, no panel, no box — the page is a column of sections separated by hairlines, and depth is used only where something is genuinely lifted off the page.
Width is a prop with three values, and picking one is a real decision: standard (1180px) for most work, narrow (860px) when the page is mostly prose or settings and a long line would hurt, data (1280px) when a table genuinely needs the room.
Operations
Receipts
Every recorded action, with the evidence attached to it.
This week
14 recordedDanny's Deli · £1,240
| Prop | Type | Notes |
|---|---|---|
WorkspacePage · width | "standard" | "narrow" | "data" | 1180 · 860 · 1280px — standard by default |
WorkspaceHeader · title | ReactNode | required; 32/38px, weight 600, tracking -.03em |
WorkspaceHeader · eyebrow | ReactNode | the parent surface, when this page has one |
WorkspaceHeader · description | ReactNode | capped at 64ch — the header is not a place to explain |
WorkspaceHeader · actions | ReactNode | right-aligned, bottom-baselined with the title |
WorkspaceSection · title / id | ReactNode / string | id makes the section linkable |
WorkspaceSection · meta | ReactNode | a count or state, tabular-nums, right of the rule |
WorkspaceSection · action | ReactNode | replaces meta when the section needs a control instead |
WorkspaceSection · compact | boolean | 24px top margin instead of 32 — for stacked short sections |
WorkspaceContext | — | a thin 44px strip under the header for the page's current filter/state |
Rows & lists
RowList plus OperationalRow is how nearly all workspace content renders. The row is one grid: an optional owner initial, a title that can carry a state chip, an optional description and meta line, and an end slot for controls. It renders as a <div>, an <a>, or a <button> depending on whether you pass href or onClick — so a row is never a div pretending to be clickable.
Three sizes, and they mean different weights of thing: compact (52px) for reference lists, standard (68px) for work, and decision (88px) where the owner is being asked for something. The state chip takes a tone — neutral, attention, success, danger — which maps straight onto the --sem-* ramp and is always preceded by its own dot, so the tone is never the only carrier.
Danny's Deli · 22 days late
:focus-visible puts a 3px inset bar down the row’s leading edge rather than a ring around 68px of nothing, and hover tints only the title — the accent marks the thing you would open, not the whole strip.onClick/href makes the row itself a <button> or <a>, so an interactive end would nest one control inside another: invalid HTML, a hydration error, and a target no keyboard user can resolve. All sixteen shipped routes honor this — a row with a WorkspaceButton in end is never itself clickable. The component does not enforce it, so the discipline is yours.Buttons & notices
WorkspaceButton is the page-grammar control (36px min, radius 8, hairline border), and it is deliberately not @morna/ui’s Button — this one is a plain <button> passthrough that inherits every native attribute, so a workspace page never fights a primitive to get type, disabled or aria-* onto an element. WorkspaceLink is the same face on a Link.
WorkspaceNotice is the page’s one-line answer to something that just happened. Its danger prop does two things at once, which is the point: it recolors the line and switches the live region from role="status" to role="alert". A failure a sighted owner sees immediately is a failure a screen reader announces immediately.
Empty & loading
The page grammar carries its own two waiting states, and they honor the doctrine on honest waiting rather than restating it. WorkspaceEmpty is a plain sentence in the section’s own measure — no illustration, no mascot, no centered nothing. WorkspaceSkeletonRows renders row-shaped placeholders that are capped at three no matter what you pass, because a screenful of shimmer is a claim about how much is coming that the page cannot actually make yet.
The specimen above passes count={9} and renders three rows. That is not a typo in the demo — it is the cap doing its job, live.
The decision review
DecisionReview is the heaviest thing in the module and the only part that takes over the page: it is what renders when the owner opens something waiting on their yes. It scrolls itself into view and takes focus on mount (as an article tabIndex=-1, not a modal — the page underneath stays), and it renders a fixed fact list rather than free prose.
Those facts are the design. Recipient, exact operation, consequence, why now, operating thread, scope, and if it fails — and the last three are hardcoded sentences, not props, precisely so no call site can quietly omit them. Scope always says one recorded action with no follow-on implied; the failure line always distinguishes a proven pre-effect failure (still decidable) from a possibly-started effect (held for reconciliation). When a fact is genuinely missing the component says so — “No complete Move-to-goal chain is recorded for this older action” — rather than dropping the row.
The specimen below is the real component, behind a control on purpose: because it takes focus and scrolls to itself the moment it mounts, rendering it inline would hijack this page on load. Open it and watch it claim the page — that is the behaviour, not a demo affordance. Note the trace row: nothing is passed, so it states the absence rather than hiding it.
| Prop | Type | Notes |
|---|---|---|
title | string | the verdict being decided |
recipient / operation / consequence | string? | rendered as facts when known, omitted when not — never guessed |
why / trace | string? | each falls back to an explicit sentence naming the absence |
payload | string? | the exact words that will be sent, verbatim, pre-wrapped |
lockedReason | string? | an honest line replacing the approve control — display only, holds no capability |
busy | boolean | disables every control while a decision is in flight |
onApprove / onHold / onEdit / onClose | () => void | Promise<void> | approve is primary; hold is ghost; close never decides anything |
VerifiedResult is the counterpart to the review: where a decision asks, this reports. It is the one place the grammar raises its voice — the outcome is set at the header’s own 32px, under a small Verified result label in --sem-success. It is for a landed, evidenced outcome, one per section. A projection or an estimate does not get this treatment.
Choosing a part
| Prop | Type | Notes |
|---|---|---|
WorkspacePage | the page frame | always the outermost element of a route |
WorkspaceHeader | name the surface | once per page, at the top, never repeated |
WorkspaceContext | the page's current state | a filter, a period, a connection — one thin strip |
WorkspaceSection | divide the body | as many as the page needs; each can be linked by id |
RowList + OperationalRow | list anything | the default; reach for a table only past ~5 columns |
VerifiedResult | one landed outcome | the section-level answer, with its label above it |
DecisionReview | the owner's yes | one at a time, in place, focus-managed |
WorkspaceNotice | what just happened | one line; danger switches it to an alert region |
WorkspaceEmpty | nothing here yet | a sentence — never an illustration |
WorkspaceSkeletonRows | a read is in flight | capped at 3; never on a section that isn't fetching |