Skip to main content

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.

GroupTokensUse
Paperpaper, paper-sunk, sheetPage background, recessed areas, raised cards
Inkink, ink-soft, ink-mute, ink-faintText from strongest to faintest
Rulesrule, rule-strongBorders and dividers
Ultramarineultra, ultra-deep, ultra-softAccent: links, focus rings, primary actions
Verifiedverified, verified-ink, verified-softA check passed, a credential in good standing
Voidvoid, void-ink, void-softA check failed, revoked, destructive actions
Cautioncaution, caution-ink, caution-softSuspended, warnings, wrong network
Radiiradius-xs, radius-sm, radius-md, radius-lg (2, 4, 6 and 10 px)Corners
Shadowsshadow-sheet, shadow-lift, shadow-dialogCards, raised panels, dialogs
Motionease-out-quart, ease-out-expo; animate-rise, animate-fade, animate-stamp, animate-spin-slowEntrances, the stamp effect, the slow seal spin on /login
Fontsfont-display, font-sans, font-monoSee 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​

ClassPurpose
.eyebrowThe small uppercase line above a page or section title
.labelForm labels and fact terms
.prose-copyLong-form text, used by the about and legal pages
.microprintA repeating line of tiny text, "CERTICHAIN · VERIFIABLE ACADEMIC RECORD", as on security paper
.guilloche-fieldA fine-line background field behind seals
.stamp-inkA noise mask that makes stamps look inked
.skeletonLoading placeholder with a shimmer
.staggerChildren rise in one after another, 70 ms apart
.seal-drawDraws 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.

TokenFontCSS variableUsed for
font-displayBodoni Moda (optical size axis)--font-bodoniPage titles and large headings
font-sansSchibsted Grotesk--font-schibstedBody text and the interface
font-monoIBM Plex Mono, weights 400 and 500--font-plex-monoHashes, addresses, ids

Each token falls back to system fonts ("Bodoni 72", "Didot", Georgia; system-ui; ui-monospace, Menlo).

Primitives​

All in src/components/ui/.

PrimitiveProps and behaviour
ButtonVariants 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.
BadgeSmall label; tones neutral (default), ink, ultra, verified, void, caution; optional dot
NoticeA 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)
CheckLineOne line of a verification result with a round mark; states pending (spinner), pass, fail, warn, info; optional role
StatusStampA framed uppercase word set slightly askew, like a registrar's stamp; tones verified, void, caution, neutral; animate plays the stamp effect
SealThe credential seal, see below
FieldLabel, 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
LookupFormThe 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
FileDropA drop zone that also opens a file picker
HashValueA monospaced hash or address with an optional CopyButton
CopyButtonCopies a value; its aria-label is "Copy: value" and the result is announced in an aria-live="polite" span
FactList, FactTerm and value pairs
EmptyStateAn empty list or "not available" message with a small seal; framed draws a border
Page, PageHeader, SectionPage width, the eyebrow, title, lead and actions of a page, and sections (sheet card or plain divider)
SpinnerA decorative spinner (aria-hidden); state is announced by the surrounding text
qr-colours.tsQR_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.

Not a security feature

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.

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:

WorkspaceShown whenTools on v0.3Tools on v0.2
Verify (/verify)AlwaysLook up a credential (/verify), Scan a phone (/present)same
Issue (/dashboard)The wallet is an issuerOverview, 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)AdminAdmin
My credentials (/student)Connected and neither issuer nor adminMy 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 reads issuerAccess.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 nav labelled Main. The tool tabs are a nav labelled "{workspace} tools" and appear only when a workspace has more than one tool. The current link has aria-current="page". isCurrent(pathname, href, siblings) treats a page beneath a link as current unless a more specific sibling owns it, and activeArea picks the workspace that owns the current path.
  • On small screens a button labelled Open menu or Close menu (with aria-expanded and aria-controls="mobile-menu") opens a nav labelled 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) in src/components/home/home-copy.ts returns 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 and did:pkh; on v0.2 the list is empty.
  • chaptersFor(version) in src/app/about/chapters.ts returns 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:

RuleWhere 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 textDashboard 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-backdropPhone 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 #mainKeyboard users

Source files​

  • src/app/globals.css, src/app/layout.tsx
  • src/components/ui/*.tsx, src/components/ui/qr-colours.ts
  • src/lib/seal/geometry.ts
  • src/components/layout/Header.tsx, navigation.ts, WalletChip.tsx, BrandMark.tsx, MainLayout.tsx, Footer.tsx
  • src/components/home/home-copy.ts, src/app/about/chapters.ts
  • docs/progress/DECISIONS.md (ADR-0096), docs/progress/DEVIATIONS.md (DEV-007)

Tests​

  • src/lib/seal/geometry.test.ts
  • src/components/layout/navigation.test.ts, src/components/layout/Header.test.tsx
  • src/components/home/home-copy.test.ts
  • src/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