Common pages
tier 1 — the frame
The pages every app has and nobody designs: access denied, not found, the thing that threw,
an expired session, a maintenance window. Each is a shell from
Shell with one .empty-state--page block in it.
Access denied
.auth-layout, because there is no navigation to offer somebody who cannot get
in. Name the role and who can grant it, make asking for it the primary
action, and put the account line in .auth-foot — a 403 is often the
wrong account rather than a missing role.
Your account is signed in, but it holds no role in Northwind Retail. An account lead has to grant you one before anything here is visible.
Signed in as alex.fischer@example.com · Sign in as somebody else
<div class="auth-layout" style="min-height:0; height:560px; padding: var(--space-9)">
<div class="auth-brand">
<span class="brand-logo" style="width:32px; height:32px; background:var(--brand)"></span>
Dispatch Console
</div>
<div class="empty-state empty-state--page">
<svg class="state-art state-art--lg" aria-hidden="true"><use href="#no-access" /></svg>
<span class="empty-state-code">403 Forbidden</span>
<span class="empty-state-title">You do not have access to this workspace</span>
<p>
Your account is signed in, but it holds no role in <strong>Northwind Retail</strong>.
An account lead has to grant you one before anything here is visible.
</p>
<div class="empty-state-actions">
<button class="btn btn-sm btn-primary"><i class="ri-mail-send-line"></i> Request the viewer role</button>
<a class="btn btn-sm" href="#"><i class="ri-arrow-go-back-line"></i> Back to the portal</a>
</div>
</div>
<p class="auth-foot">Signed in as alex.fischer@example.com · <a href="#">Sign in as somebody else</a></p>
</div>
Not found
The one that keeps the frame: the reader is signed in and the nav still works, so
.layout stays and the block goes in .page, where it sits at the
top rather than centring. Name the thing that was not found — the
reference is what the reader will paste into a search or quote to a colleague.
The topbar is the app's ordinary one, unchanged. The trail to the missing record belongs to
the page, so the .breadcrumb goes at the top of
.page, never in .topbar.
<div class="layout" style="height:560px; border:1px solid var(--border); border-radius:var(--radius-panel); overflow:hidden">
<aside class="sidebar">
<div class="brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<div class="brand-text">
<strong>Dispatch Console</strong>
<span class="brand-sub">Northwind Retail</span>
</div>
</div>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link" href="#"><i class="ri-inbox-line"></i><span>Queue</span><span class="nav-count">3</span></a>
<a class="nav-link active" href="#"><i class="ri-chat-3-line"></i><span>Orders</span></a>
<a class="nav-link" href="#"><i class="ri-check-double-line"></i><span>Decided</span></a>
<a class="nav-link" href="#"><i class="ri-settings-3-line"></i><span>Settings</span></a>
</div>
</nav>
</aside>
<div class="content">
<header class="topbar">
<button class="topbar-btn topbar-btn--start" aria-label="Collapse sidebar" data-tip="Collapse the sidebar to an icon rail."><i class="ri-side-bar-line"></i></button>
<div class="topbar-spacer"></div>
<div class="user-widget">
<button class="user-trigger">
<span class="user-avatar"><i class="ri-user-line"></i></span>
<span class="user-info">
<span class="user-name">Alex Fischer</span>
<span class="user-email">alex.fischer@example.com</span>
</span>
</button>
<a class="user-signout" href="#" aria-label="Sign out" data-tip="Sign out of the console."><i class="ri-logout-box-r-line"></i></a>
</div>
</header>
<div class="page">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a class="breadcrumb-item" href="#">Orders</a>
<span class="breadcrumb-item" aria-current="page">ORD-48812</span>
</nav>
<div class="empty-state empty-state--page">
<svg class="state-art state-art--lg" aria-hidden="true"><use href="#not-found" /></svg>
<span class="empty-state-code">404 Not found</span>
<span class="empty-state-title">There is no order ORD-48812</span>
<p>
It may have been merged into another order, or the link may be older than the
90 days orders are kept for. Searching the reference will find it if it was
merged.
</p>
<div class="empty-state-actions">
<a class="btn btn-sm btn-primary" href="#"><i class="ri-search-line"></i> Search for the reference</a>
<a class="btn btn-sm" href="#"><i class="ri-inbox-line"></i> Back to the queue</a>
</div>
</div>
</div>
</div>
</div>
Something went wrong
.empty-state--failed colours the drawing and nothing else. Say whether
retrying is safe, because the reader cannot know whether the write went through, and give
them the reference in .empty-state-detail with a data-copy button
beside it.
This is the one drawing on the page that does not change when you switch theme in
the header, and that is the modifier working: it re-points
--state-accent at --danger-fg, and the danger ramp is deliberately
outside the brand's — a failure must not turn green because the brand did. The other four
pages are not failures, so they keep the brand accent and move with it.
The request failed before it reached the workflow API. Nothing was decided and nothing was lost — trying again is safe.
Reference 8f3c2a71-4d90-4f0e-9c2b-1a77e5b0c344 · 09:41 CEST
Quote the reference and the service desk can find the request in the log.
<div class="auth-layout" style="min-height:0; height:600px; padding: var(--space-9)">
<div class="auth-brand">
<span class="brand-logo" style="width:32px; height:32px; background:var(--brand)"></span>
Dispatch Console
</div>
<div class="empty-state empty-state--page empty-state--failed">
<svg class="state-art state-art--lg" aria-hidden="true"><use href="#server-error" /></svg>
<span class="empty-state-code">500 Server error</span>
<span class="empty-state-title">Something went wrong loading the queue</span>
<p>
The request failed before it reached the workflow API. Nothing was decided and nothing
was lost — trying again is safe.
</p>
<p class="empty-state-detail">Reference 8f3c2a71-4d90-4f0e-9c2b-1a77e5b0c344 · 09:41 CEST</p>
<div class="empty-state-actions">
<button class="btn btn-sm btn-primary"><i class="ri-refresh-line"></i> Try again</button>
<button class="btn btn-sm" data-copy="8f3c2a71-4d90-4f0e-9c2b-1a77e5b0c344">
<i class="ri-file-copy-line" data-copied-hide></i><span data-copied-hide>Copy reference</span>
<i class="ri-check-line" data-copied-show="ok"></i><span data-copied-show="ok">Copied</span>
</button>
<a class="btn btn-sm btn-ghost" href="#"><i class="ri-customer-service-2-line"></i> Service desk</a>
</div>
</div>
<p class="auth-foot">Quote the reference and the service desk can find the request in the log.</p>
</div>
Session expired
For the hard redirect. Where the app is still running and only the circuit dropped, that is
the .status-bar's job instead; say what happened to
the work in progress, or the reader redoes something they did not need to.
You were signed out after 12 hours, which is the workspace policy. The order you were reviewing has not been decided and will still be in the queue.
You will come back to the page you were on.
<div class="auth-layout" style="min-height:0; height:560px; padding: var(--space-9)">
<div class="auth-brand">
<span class="brand-logo" style="width:32px; height:32px; background:var(--brand)"></span>
Dispatch Console
</div>
<div class="empty-state empty-state--page">
<svg class="state-art state-art--lg" aria-hidden="true"><use href="#session-expired" /></svg>
<span class="empty-state-code">401 Session expired</span>
<span class="empty-state-title">Sign in again to carry on</span>
<p>
You were signed out after 12 hours, which is the workspace policy. The order you were
reviewing has not been decided and will still be in the queue.
</p>
<div class="empty-state-actions">
<a class="btn btn-sm btn-primary" href="#"><i class="ri-login-circle-line"></i> Sign in</a>
</div>
</div>
<p class="auth-foot">You will come back to the page you were on.</p>
</div>
Maintenance
The title is the time it comes back, not the word “maintenance”. Give a window and a way to check, and keep the same page for the planned and unplanned cases.
The workflow database is being upgraded. Approvals already submitted are queued and will be decided as soon as the window closes.
Planned window: 20:00–21:00 CEST, Wednesday 9 September.
<div class="auth-layout" style="min-height:0; height:570px; padding: var(--space-9)">
<div class="auth-brand">
<span class="brand-logo" style="width:32px; height:32px; background:var(--brand)"></span>
Dispatch Console
</div>
<div class="empty-state empty-state--page">
<svg class="state-art state-art--lg" aria-hidden="true"><use href="#maintenance" /></svg>
<span class="empty-state-code">503 Maintenance</span>
<span class="empty-state-title">Back at 21:00 CEST</span>
<p>
The workflow database is being upgraded. Approvals already submitted are queued and
will be decided as soon as the window closes.
</p>
<div class="empty-state-actions">
<a class="btn btn-sm btn-primary" href="#"><i class="ri-refresh-line"></i> Check again</a>
<a class="btn btn-sm" href="#"><i class="ri-external-link-line"></i> Status page</a>
</div>
</div>
<p class="auth-foot">Planned window: 20:00–21:00 CEST, Wednesday 9 September.</p>
</div>
Which shell, which drawing
Each page has its own drawing, chosen by what the page is. Reusing one across two of them makes them look like the same page to somebody scanning a screenshot in a ticket. The full set is on State art.
| Page | Shell | Drawing | Primary action |
|---|---|---|---|
| Access denied | .auth-layout | #no-access | Request the role |
| Not found | .layout → .page | #not-found | Search for the reference |
| Server error | .auth-layout | #server-error | Try again |
| Session expired | .auth-layout | #session-expired | Sign in |
| Maintenance | .auth-layout | #maintenance | Check again |
Sign-in itself is not on this list because it is not a state — it is a form, and it belongs in an
.auth-card.
Writing one
Three lines, in this order: what would normally be here, why it is not, and the one action that
changes that. .empty-state-code carries the status code above the title, small and
quotable, and the drawing is .state-art--lg here.
One primary action, and at most two beside it.
.empty-state-actions wraps and centres them and takes the top margin off the
buttons, so a second action does not stagger against the first on a narrow screen.