Custodian Portal UX Standard
The custodian portal is a working application — people spend a shift in it reviewing, attesting, and chasing lapsing credentials — not a website. This is the definition of how its pages are laid out, how you move between them, and how actions are named and placed.
Scope is the custodian portal only (frontends/custodian-portal); there is no shared frontend
package, so every component here lives in that app. It builds on “Custody Amber” visual identity,
the Tier-2 provenance rules from Custodian Onboarding, and the
role-based access described there.
Application shell
One shell, PortalLayout: an app bar and a content column.
- App bar — organization identity and sections (Intake, Credentials, People) on the left, global search centered (Custodian Search), account menu and theme on the right. It holds nothing about the current page.
- Content column — a fixed max width on every page. Settings is the one exception (narrower, since it has no list/table and shows one section at a time).
- Footer — the Tier-2 provenance disclosure line, on every page.
Information architecture
One route table drives nav highlighting, breadcrumb roots, and page titles.
| Section | Route | Breadcrumb |
|---|---|---|
| Intake | / | Intake |
/batches/:batchId | Intake › Batch of ‹date› | |
| Credentials | /credentials?tab=overview|catalog|register|schedule | Credentials › Tab |
| People | /people | People |
/people/:holderKey | People › Person’s name | |
| (account menu) | /settings | Settings |
Vocabulary. The portal says people and person wherever it names the individuals a custodian holds records for — holder is the wallet’s and the API’s word, and stays there. The first section is called Intake (documents come in and get reviewed) rather than “Batches” — a place name like People, even though the underlying unit is still a batch.
Page anatomy
Every signed-in page is one PageHeader followed by its content:
Intake › Batch of 12 Sep 2026 ⧉ ← breadcrumb (copies the id)
Batch of 12 Sep 2026 [Add documents] ← title, actions
12 documents: 8 attested, 3 awaiting review. ← subtitle| Element | Rule |
|---|---|
| Title | The page’s noun, one h1 per page — a detail page’s title is the record’s own name, not “Person detail.” |
| Hint | One sentence, true on every visit, read on demand via a small info icon — not always-on prose. |
| Alert | A disclosure/warning as a second on-demand icon next to hint, opening itself briefly on first mount, then folding to on-demand. |
| Subtitle | A fact about this page’s current data (a batch’s progress, a person’s email) — always visible, since it’s scanned on every visit. A page uses hint or subtitle, not both, except a detail page which can carry both. |
| Actions | Right-aligned, at most one primary and two secondary; the Filter/Saved-searches menu sits immediately before them when the page has a filterable list. |
| Tabs | Underline tabs at the foot of the header band, state carried in the URL (?tab=) so it survives reload/share/back. |
| Document title | {Title} · Attest Custodian, so browser tabs and history say where you were. |
| Loading | A skeleton, never a placeholder that changes shape once data loads. |
Breadcrumb
The breadcrumb is the app’s location and is functional, not decorative:
- Every ancestor is a link; the current page is plain text with
aria-current="page". - The first crumb is always the section, so “up” is one click.
- A crumb can carry a sibling menu (e.g. jumping between Credentials’ tabs, or Settings’ sections when a page has no tab strip of its own).
- The current crumb can offer a copy button for the record’s identifier.
- The full trail stays visible at every width — it never collapses to a bare “Back” link, since a crumb’s dropdown can be the only way to navigate (Settings has no side nav).
Actions and naming
| Acts on | Goes in |
|---|---|
| The page’s subject | PageHeader actions |
| The active tab’s list | The end of the tab strip, primary last |
| A row | The row’s menu, or a row button |
| One decision | The dialog’s footer |
Naming. Verb + object, sentence case, never a bare noun — New X creates something that doesn’t exist yet, Add X puts something into an existing thing, and the domain verbs (Attest, Review, Revoke) are used as-is. A button, its dialog title, its pending label, and its success toast share one verb.
Stat tiles
A tile is a label, a value, and an optional hint — no icon, no gradient. A tile that needs attention uses a colored value only while non-zero (a zero never looks urgent); a tile that leads somewhere is a link or button over its whole surface.
Role-aware chrome
A control a role can’t use is not shown; the server still refuses it.
| Control | Needs |
|---|---|
| New batch, Add documents, Attest credential, Add/Edit/Remove person, Invite | canWork (Admin or Operator) |
| New credential type, edit/deactivate a type, Schedule/cancel a re-performance | isAdmin |
| Invite, re-role, or revoke a teammate | isAdmin |
| Export CSV, every read | any role |
Accessibility and motion
One h1 per page; the breadcrumb is a labeled nav; tabs are a real tablist; focus is always
visible. No motion beyond state changes the person caused; reduced motion is respected globally.
Other portals
The issuer portal, admin console, wallet, and verifier portal each have their own shell and headers
and have not adopted this standard. When they do, the route-table + PageHeader + Breadcrumbs
pattern carries over; components are copied per app rather than shared.