Files
Ekklesia/docs/architecture/decisions/0059-shared-interaction-primitives.md
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

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> 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.