Design system
The interface was rebuilt on 2026-10-24 in one step (UI-01, recorded as ADR-0096 and DEV-007). The visual language borrows from banknotes and diplomas: warm paper, ultramarine ink, a fine-line seal per credential, and stamp colours that carry a meaning. Every page is built from the same tokens in src/app/globals.css and the same primitives in src/components/ui/. The redesign changed markup, copy and layout only; no contract, API, environment variable or service behaviour changed, and the browser tests kept their selectors.
Rules
From ADR-0096:
- Colours, radii, shadows, easing curves and fonts are theme tokens. No inline styles except values that change at run time, and no hex colours except the fixed QR print colours.
- Pages are composed from the shared primitives; new pages reuse them.
- Each credential carries a seal drawn from its id. It is a recognition aid only, and the pages say so.
- Navigation is organised as role workspaces with tool tabs under the active one.
- Public copy on the home and about pages comes from a per-version table, so a v0.2 build never claims v0.3 features.
- Accessible names, roles and texts that the browser tests select by are kept unchanged.
Tokens
Tailwind v4 reads the @theme block in src/app/globals.css, so each token is both a CSS variable and a utility class (for example --color-ultra gives text-ultra, bg-ultra, border-ultra). Colours are written in OKLCH.
| Group | Tokens | Use |
|---|---|---|
| Paper | paper, paper-sunk, sheet | Page background, recessed areas, raised cards |
| Ink | ink, ink-soft, ink-mute, ink-faint | Text from strongest to faintest |
| Rules | rule, rule-strong | Borders and dividers |
| Ultramarine | ultra, ultra-deep, ultra-soft | Accent: links, focus rings, primary actions |
| Verified | verified, verified-ink, verified-soft | A check passed, a credential in good standing |
| Void | void, void-ink, void-soft | A check failed, revoked, destructive actions |
| Caution | caution, caution-ink, caution-soft | Suspended, warnings, wrong network |
| Radii | radius-xs, radius-sm, radius-md, radius-lg (2, 4, 6 and 10 px) | Corners |
| Shadows | shadow-sheet, shadow-lift, shadow-dialog | Cards, raised panels, dialogs |
| Motion | ease-out-quart, ease-out-expo; animate-rise, animate-fade, animate-stamp, animate-spin-slow | Entrances, the stamp effect, the slow seal spin on /login |
| Fonts | font-display, font-sans, font-mono | See below |
Base values: paper is oklch(0.975 0.005 85), ink is oklch(0.235 0.045 266), ultra is oklch(0.46 0.2 267), verified is oklch(0.5 0.11 160), void is oklch(0.555 0.19 30), caution is oklch(0.68 0.13 75). The documentation site uses the same palette (blockchain-docs/src/css/custom.css). The web application itself has one light theme; it has no dark mode.
The stylesheet also defines the keyframes rise, fade, stamp, draw and shimmer, and a base :focus-visible style: a 2 px ultra outline offset by 2 px. Under prefers-reduced-motion: reduce, animations and transitions are cut to 1 ms.
Component classes and utilities
| Class | Purpose |
|---|---|
.eyebrow | The small uppercase line above a page or section title |
.label | Form labels and fact terms |
.prose-copy | Long-form text, used by the about and legal pages |
.microprint | A repeating line of tiny text, "CERTICHAIN · VERIFIABLE ACADEMIC RECORD", as on security paper |
.guilloche-field | A fine-line background field behind seals |
.stamp-ink | A noise mask that makes stamps look inked |
.skeleton | Loading placeholder with a shimmer |
.stagger | Children rise in one after another, 70 ms apart |
.seal-draw | Draws a seal's rings and lines in on first appearance |
link (utility) | Inline text links |
tabular (utility) | Tabular figures for numbers in tables |
container-page (utility) | The page column with side gutters |
Fonts
Fonts are loaded in src/app/layout.tsx with next/font/google, which downloads them at build time and serves them from the site, so no font request leaves for a third party at run time. All use display: swap.
| Token | Font | CSS variable | Used for |
|---|---|---|---|
font-display | Bodoni Moda (optical size axis) | --font-bodoni | Page titles and large headings |
font-sans | Schibsted Grotesk | --font-schibsted | Body text and the interface |
font-mono | IBM Plex Mono, weights 400 and 500 | --font-plex-mono | Hashes, addresses, ids |
Each token falls back to system fonts ("Bodoni 72", "Didot", Georgia; system-ui; ui-monospace, Menlo).
Primitives
All in src/components/ui/.
| Primitive | Props and behaviour |
|---|---|
Button | Variants primary (default), accent, secondary, ghost, danger, danger-quiet, link; sizes sm, md (default), lg, icon. type defaults to "button", so a button never submits a form by accident. busy adds a spinner and aria-busy. asChild renders the styles on a child element (Radix Slot), for links that look like buttons. buttonVariants is exported for the same purpose. |
Badge | Small label; tones neutral (default), ink, ultra, verified, void, caution; optional dot |
Notice | A message box; tones neutral, info, success, warning, danger; optional title, action and an explicit role (pages pass role="alert" for failures and role="status" for progress) |
CheckLine | One line of a verification result with a round mark; states pending (spinner), pass, fail, warn, info; optional role |
StatusStamp | A framed uppercase word set slightly askew, like a registrar's stamp; tones verified, void, caution, neutral; animate plays the stamp effect |
Seal | The credential seal, see below |
Field | Label, hint, optional marker and error line for a form control. The error line has id ${htmlFor}-error (fieldErrorId) and role="alert", so controls point to it with aria-describedby and the error is announced |
LookupForm | The large search field on the verify page; submitLabel defaults to Verify; the input has an aria-label, aria-invalid and aria-describedby for its error |
FileDrop | A drop zone that also opens a file picker |
HashValue | A monospaced hash or address with an optional CopyButton |
CopyButton | Copies a value; its aria-label is "Copy: value" and the result is announced in an aria-live="polite" span |
FactList, Fact | Term and value pairs |
EmptyState | An empty list or "not available" message with a small seal; framed draws a border |
Page, PageHeader, Section | Page width, the eyebrow, title, lead and actions of a page, and sections (sheet card or plain divider) |
Spinner | A decorative spinner (aria-hidden); state is announced by the surrounding text |
qr-colours.ts | QR_INK #1c2140 and QR_PAPER #fdfcfa, the only hex colours allowed, because QR codes need fixed contrast for scanners |
Other shared components: ConfirmDialog (src/components/ConfirmDialog.tsx, with a backdrop that has data-testid="confirm-dialog-backdrop"), PausedNotice, RecordTypeLabel (marks issuer-defined record types), RedirectTo, VerificationQr (the /verify?id= QR code on issue cards and inbox rows), credential-status.ts (the badge tone and text for each status), present/PresentationQr (data-testid="presentation-qr"), present/QrScanner, present/RegisterOfflineShell, and vc/VcExportPanel.
The seal
Seal (src/components/ui/Seal.tsx) draws a guilloche rosette from a seed, usually a credential id or a wallet address. sealGeometry(seed, detail) in src/lib/seal/geometry.ts lowercases a hex seed, hashes it into the seed of a small deterministic random generator, and draws bands of wave lines on a 200 by 200 grid (SEAL_SIZE):
detail="full"draws three motifs from the outside in (a fine rope, a wide petal lattice in the accent colour, a small star) with three guide rings.detail="compact"draws two bands and one ring with heavier strokes, for small sizes such as the wallet chip.
The same seed always gives the same seal, and a different seed gives a visibly different one, which helps a person notice that two screens show the same credential. It proves nothing.
The seal is a recognition aid, not a check. Anyone can draw the seal of any id. Pages that show a seal next to a verdict get the verdict from the chain checks, never from the drawing.
Accessibility: with a label prop the seal is role="img" with that label; without one it is aria-hidden. animate adds .seal-draw.
Navigation workspaces
The header (src/components/layout/Header.tsx) shows workspaces, one per role, and the tools of the current workspace as tabs beneath. navigationFor(access, version) in src/components/layout/navigation.ts builds them:
| Workspace | Shown when | Tools on v0.3 | Tools on v0.2 |
|---|---|---|---|
Verify (/verify) | Always | Look up a credential (/verify), Scan a phone (/present) | same |
Issue (/dashboard) | The wallet is an issuer | Overview, Issue a credential (/records/add), Bulk import (/records/import), Students (/students/invite), Record types (/records/types) | Overview, Records (/records), Add a record (/records/add) |
Admin (/admin) | Admin or super admin (v0.3: the guardian) | Admin | Admin |
My credentials (/student) | Connected and neither issuer nor admin | My credentials (/student), On this phone (/wallet), Access (/access) | My records (/student) |
About (/about) is always added after the workspaces.
How the header works:
- A small
useAccess()hook readsissuerAccess.accessOf(address)for the connected wallet and keeps the answer together with the address it was read for, so a stale answer for a previous account is never shown. A failed read is logged ("Could not read wallet roles:") and the header shows the visitor workspaces. - The main navigation is a
navlabelled Main. The tool tabs are anavlabelled "{workspace} tools" and appear only when a workspace has more than one tool. The current link hasaria-current="page".isCurrent(pathname, href, siblings)treats a page beneath a link as current unless a more specific sibling owns it, andactiveAreapicks the workspace that owns the current path. - On small screens a button labelled Open menu or Close menu (with
aria-expandedandaria-controls="mobile-menu") opens anavlabelled Menu. - A pill shows the configured network name ("The network this site reads and writes").
WalletChip(src/components/layout/WalletChip.tsx) shows Connect wallet (a link to/login) when no wallet is connected, and otherwise a compact seal of the address with the shortened address, linking to the wallet's own workspace.
Version-aware copy
Two tables keep public text honest about the build's contract version:
homeCopy(version)insrc/components/home/home-copy.tsreturns the lead, the steps, the audiences, what a check proves, and the standards list. On v0.3 the standards are W3C Verifiable Credentials 2.0, Open Badges 3.0, Bitstring Status List, EIP-712 signatures anddid:pkh; on v0.2 the list is empty.chaptersFor(version)insrc/app/about/chapters.tsreturns the about page chapters, with shared chapters and chapters that only a v0.3 build shows.
Both are read on the server from readContractVersion(), so the HTML is right before any script runs.
Accessibility rules the tests rely on
The browser journeys in e2e/journeys/ find elements the way assistive technology does: by role and accessible name (getByRole("button", { name }), getByRole("status"), getByRole("alert"), getByRole("heading"), getByRole("row"), getByRole("dialog")), by label (getByLabel("Student Name")), and in two places by test id. Changing any of these breaks a journey, so treat them as part of the interface:
| Rule | Where it matters |
|---|---|
| Buttons and links keep their visible names (Issue Record, Switch network, Verify, Connect wallet) | Most journeys click by name |
Progress and results use role="status"; failures use role="alert" (Notice role, Field errors, LookupForm errors, NetworkBanner) | Journeys wait for status and alert texts |
Form controls have labels (Field htmlFor, or aria-label) | The issue form, record types, invitations |
| Tables have rows with readable text | Dashboard and inbox journeys select rows |
Dialogs use role="dialog" | Revoke and confirmation dialogs |
Test ids exist only where no role fits: presentation-qr and confirm-dialog-backdrop | Phone wallet and confirmation journeys |
Decorative graphics are hidden (aria-hidden on spinners, icons and unlabeled seals) | Keeps accessible names clean |
Keyboard focus is always visible (:focus-visible outline, Button focus ring) and a Skip to content link leads to #main | Keyboard users |
Source files
src/app/globals.css,src/app/layout.tsxsrc/components/ui/*.tsx,src/components/ui/qr-colours.tssrc/lib/seal/geometry.tssrc/components/layout/Header.tsx,navigation.ts,WalletChip.tsx,BrandMark.tsx,MainLayout.tsx,Footer.tsxsrc/components/home/home-copy.ts,src/app/about/chapters.tsdocs/progress/DECISIONS.md(ADR-0096),docs/progress/DEVIATIONS.md(DEV-007)
Tests
src/lib/seal/geometry.test.tssrc/components/layout/navigation.test.ts,src/components/layout/Header.test.tsxsrc/components/home/home-copy.test.tssrc/components/ui/FileDrop.test.tsx,src/components/ConfirmDialog.test.tsx- The browser journeys under
e2e/journeys/, which select by role, label and the two test ids