Stage 1's later screens and modules need the interactions DESIGN.md fixes once for the product (ADR 0059). @ekklesia/ui gains them, additively: - ConfirmationDialog: simple, destructive, or typed-phrase confirmation, with severity chosen by the caller. Destructive is marked by symbol and frame, not colour alone, and starts on Cancel. A required phrase must match exactly (forgiving only surrounding whitespace and NFC). While busy, Cancel and Escape are disabled. - ToastProvider / useToasts: a top-center toast system per React tree, never a package singleton (ADR 0040). Live regions are mounted before any toast arrives; errors are assertive and persist until dismissed; routine toasts last at least three seconds and longer for longer text. Timers pause on hover and focus; duplicates refresh; errors are never displaced; every timer is cleared on unmount. - ErrorState: recovery actions, plus collapsed technical details and a reference, rendered as bounded inert text. - Skeleton / SkeletonGroup: an aria-busy region labelled once, with every animation stopped under prefers-reduced-motion. - PageHeader `back` (link or button) above the page's one h1. - Dialog: the focus trap ADR 0039 deferred, plus focus restoration and an accessible description. Escape is now handled on the dialog itself, so a drawer it was opened from stays open. The shell validates Back targets (parseReturnTarget, dashboardBack): only a canonical Dashboard page on this origin is accepted, otherwise Back goes to the page's parent. It never uses history.back(). Biome's scope-aware suspicious/noAlert is now an error in the root biome.json. tests/native-dialogs.test.ts proves it catches real dialogs and not lookalikes, and scans for the forms the rule misses. It supersedes the shell-only regex test. Co-Authored-By: Claude Opus 5.5 <[email protected]>
13 KiB
0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs
Context
Every Stage 1 surface still to come — Settings, Displays, module management, Signage as the reference
module — needs the same few interactions DESIGN.md fixes once for the whole product: a product-owned
confirmation for meaningful actions, with typed confirmation for the serious ones (§8); top-center
toasts (§7.4); errors that say what failed and keep technical detail out of the way (§7.3); skeleton
loading that keeps context visible (§7.1); Back plus a page title for nested pages, returning to the
workflow that sent the person there (§2.7, §4.8); and no window.alert, confirm, or prompt
anywhere (§8.1).
@ekklesia/ui (ADR 0039) had the presentation of some of these and deliberately not the behaviour.
ADR 0039 deferred "a focus-trap utility"; the package's Dialog said a focus trap belonged "in one
place, tested, not sprinkled through call sites"; its Toast said there was "deliberately no toast
manager", because a package-level singleton would be shared mutable state across every isolated
artifact on a page (ADR 0040); and ADR 0057 deferred return-to-origin. The only guard against native
dialogs was a regular expression in one shell test.
Each of these is now needed by more than one consumer, which is ADR 0039's bar for adding to the package.
Decision
Placement
The primitives with more than one consumer go in @ekklesia/ui; what depends on the Dashboard's own
routes stays in the shell.
| Where | What |
|---|---|
@ekklesia/ui |
ConfirmationDialog; ToastProvider and useToasts; ErrorState (extended); Skeleton and SkeletonGroup; PageHeader (extended with back); Dialog (now with a focus trap) |
@ekklesia/shell |
parseReturnTarget and dashboardBack: which addresses are safe to go back to |
Root biome.json and tests/native-dialogs.test.ts |
the native-dialog guard |
The public additions are components and their prop types only. The focus trap, the duration rule,
the phrase matcher, and the detail sanitizer are internal: tested, and used by the components, but
not promised to callers. @ekklesia/ui gains no dependency — its few glyphs (←, ✕, the status
symbols) are text, as its status symbols already were, so a module bundle does not inherit an icon
library's licence material through it.
One focus trap, used by every modal surface
useFocusTrap remembers what had focus, moves focus into the surface, wraps Tab and Shift+Tab at its
edges, and returns focus to the remembered control when the surface closes. It reads the focused
element from the surface's own root node, so it works inside a module's shadow root, where the
document would only report the shadow host (ADR 0040). Dialog uses it, so every dialog — including
the shell's existing device-revocation dialog — now traps and restores focus.
Dialog stays rendered in place, never portalled out of an artifact's isolated root. Its scrim
covers the viewport and takes every pointer press, so nothing behind it can be activated, and does
not dismiss the dialog. Escape is now handled on the dialog element and stopped there, rather than on
the whole document, so a drawer or menu the dialog was opened from no longer closes with it. A caller
can make Escape inert while something is in progress (dismissible={false}), and give the dialog an
accessible description.
Confirmation: three severities, chosen by the caller
ConfirmationDialog is one element for a simple confirmation, a destructive one, and a destructive
one that also requires a typed phrase. The consuming feature chooses; the element never upgrades or
downgrades severity.
- It is a
Dialog: named by its title, described by its description, focus trapped and restored. - Destructive is marked by a symbol in the title and on the action, a heavier frame, and the danger button — never by colour alone — and focus starts on Cancel, so a stray Enter is safe.
- Typed confirmation is
requiredPhrase:DELETE, or a resource's own name. Confirm is disabled until the typed value matches exactly and case-sensitively, forgiving only surrounding whitespace and Unicode composition (NFC), which phone keyboards introduce unasked. The field is labelled with the phrase, has autocapitalize, autocorrect, and spellcheck off, says politely whether it matches, and submits on Enter once it does. The typed value is forgotten whenever the dialog closes. - Busy disables both actions, makes Escape inert, and marks the form
aria-busy, so an action in flight cannot be abandoned half-done; the caller closes the dialog when it finishes.
Toasts: a system per React tree, never a package singleton
ToastProvider owns one tree's toasts, timers, and top-center region; useToasts() returns
show(options) → id and dismiss(id). ADR 0040's isolation is why there is no package-level store:
each module artifact and the shell render their own tree, and a store shared through the package
would be mutable state reaching across all of them.
- Severities: success, info, warning, and error (
danger), each with its symbol. - Duration: never under about three seconds (DESIGN.md §7.4), longer for longer text and for warnings, up to a ceiling. An error stays until dismissed unless its caller gives it a duration — a routine validation error, say.
- Calm under load: at most three routine toasts at once, newest on top; an error is listed first and never displaced by a routine toast; an identical toast already showing is refreshed rather than stacked.
- Accessible: the region's polite and assertive live regions exist before any toast arrives, so additions are announced — routine toasts politely, errors assertively — and no toast is a nested live region of its own. Timers pause while the pointer is over the region or focus is in it (WCAG 2.2 §2.2.1). Every toast has a dismiss control named for it; Escape dismisses the focused toast; when the last one goes, focus returns to where it was.
- Clean: every timer is cleared when its toast goes and when the provider unmounts.
The shell does not mount a provider yet, and nothing is wired to toasts merely to show them. A module mounts its own provider today. One host-owned region that modules post into would need a module UI SDK contract, and is deferred.
Errors: plain message first, technical detail on request
ErrorState keeps its existing props and adds actions (recovery and remedies — a caller that knows
the viewer can administer the problem passes those remedies too; the element has no notion of roles),
details (plain text), reference (a diagnostic id), and announce (role="alert" by default, off
for an error that is part of a page's initial content). Details and the reference sit in a closed
<details> disclosure, so they never dominate. details is a string, chosen deliberately: never an
exception, so a stack cannot arrive by accident. It is rendered as text, so markup in it is shown and
never interpreted, with control characters removed and its length bounded (2,000 characters).
Skeletons: shape without pretence
Skeleton (text line, block, circle) is decorative and hidden from assistive technology.
SkeletonGroup is the region it stands in for: role="status", aria-busy, and one label announced
once ("Loading displays") rather than every placeholder. Placeholders pulse gently and stop for a
person whose system asks for reduced motion; there is no percentage, because there is no progress to
report. When the personal reduced-motion preference arrives, it overrides the same single rule.
Back: a validated origin, or the page's own parent
PageHeader takes an optional back: { label, href?, onBack } and renders it above the page's one
h1: a real link when it has an address (so it can be opened in a new tab), a button otherwise.
Breadcrumbs are not part of it (DESIGN.md §4.8). The element knows nothing about routes; the host
decides where Back goes.
In the shell, dashboardBack(history, { returnTarget, fallback, label }) decides. A return target is
input from whoever built the link, so it is hostile until proven otherwise, and ADR 0034 requires any
return location to be a validated, same-origin route decision. parseReturnTarget accepts only a
string of at most 2 KiB that begins with exactly one /, contains no whitespace, control character,
backslash, percent-encoded slash, backslash, or dot, and no dot-segment, query, or fragment, and names
a page the Dashboard actually has. Its answer is a destination, never the raw string, so Back can only
go to the canonical address of a real page. Without a valid target, Back goes to the page's declared
parent. It never calls the browser's history.back(): after a bookmark or a link from outside, the
previous entry is not the Dashboard's.
How a link carries a return target — a query parameter, or something the module navigation contract
defines — is deliberately not decided here (ADR 0057); whatever carries it hands it to
parseReturnTarget first.
No native dialogs, enforced repository-wide
Biome's scope-aware suspicious/noAlert is an error in the root biome.json, which every package,
surface, and template in this repository lints against. It reports alert(, confirm(, and
prompt(, called bare, through window or globalThis, or by computed member, and leaves alone a
local function that shares the name, a method such as dialog.confirm(), a property such as
onConfirm, a string, and a comment. tests/native-dialogs.test.ts proves both directions against
fixtures with the repository's own configuration, and scans browser source for the two forms the rule
cannot see — a call through self, top, parent, or frames, and borrowing the function without
calling it. The shell's earlier regular-expression test is superseded and removed.
A module repository has its own lint configuration and is outside this repository's reach. It should enable the same rule; the module development guide says so.
Alternatives
- A third-party toast or dialog library. Rejected: the behaviour is small, it must work inside isolated roots without portals, and a framework would bring its own styling and a dependency into every module bundle.
- A package-level toast singleton (
toast("Saved")from anywhere). Rejected for ADR 0040's reason: shared mutable state across every artifact on the page. - Native
<dialog>withshowModal(). Not chosen for now: it promotes the dialog to the top layer, outside an artifact's isolated root, and its focus return and inertness differ across the browsers churches use. history.back()as Back's fallback. Rejected: it can leave the Dashboard.- Accepting any same-origin path as a return target. Rejected: an address the Dashboard has no page for is not a workflow to return to, and a narrow grammar is easier to prove safe.
- Typed confirmation that ignores case. Rejected: typing
DELETEis meant to be deliberate. - Enforcing native dialogs at module build time (
@ekklesia/module-ui-build). Not chosen: the build sees a bundle without scope information, so it would either miss shadowed names or report code in dependencies; a scope-aware lint rule in source is the accurate tool. - Exporting the focus trap and helpers. Deferred: they are tested internals until a caller needs them outside these components.
Consequences
- Later Stage 1 pages compose these instead of inventing their own, and modules get the same ones.
Dialog's Escape is handled on the dialog element rather than on the document. A caller that relied on Escape reaching the document while focus was elsewhere sees a change; with focus trapped inside the dialog, that situation no longer arises. The existing test was updated accordingly.@ekklesia/uigrows by two components, a provider and its hook, and three extended props. It remains dependency-free beyond@ekklesia/design-tokens.pnpm lintnow fails on any native dialog in this repository.
Deferred
A host-owned toast region that module UI posts into through the module UI SDK; how a link carries a
return target (with the module navigation contract); exporting the focus trap; migrating the shell's
navigation drawer onto the shared focus trap; replacing the device panel's revocation dialog with a
ConfirmationDialog (it moves to Settings → Displays); a personal reduced-motion override; and the
module repositories' own lint configuration.