# 0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs - Status: Accepted - Date: 2026-10-15 - Extends: [0034](0034-host-owned-browser-authentication.md), [0039](0039-shared-design-tokens-and-elements.md), [0040](0040-module-stylesheets-and-artifact-isolation.md), [0057](0057-dashboard-urls-are-host-owned-routes.md), [0058](0058-dashboard-shell-navigation-and-lucide-icons.md) ## 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 `
` 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 `` with `showModal()`.** 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 `DELETE` is 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/ui` grows by two components, a provider and its hook, and three extended props. It remains dependency-free beyond `@ekklesia/design-tokens`. - `pnpm lint` now 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.