feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
651efa8829
commit
5a7d44781b
@@ -0,0 +1,196 @@
|
||||
# 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
|
||||
`<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.
|
||||
@@ -67,3 +67,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
|
||||
@@ -592,7 +592,11 @@ registry that constructs is one that can be emitted as CSS without escaping. Tha
|
||||
exactly what makes a future theme editor implementable rather than aspirational: the values it would
|
||||
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
|
||||
elements a real module dashboard cannot be built without, every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
|
||||
`:focus-visible` treatment and status that never depends on colour alone. It now also carries the
|
||||
shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system,
|
||||
`ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint
|
||||
refuses browser-native dialogs repository-wide
|
||||
([ADR 0059](decisions/0059-shared-interaction-primitives.md)). `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
|
||||
@@ -899,6 +899,34 @@ Every other shared package stays rejected inside a module: `@ekklesia/api-client
|
||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||
import, and `pnpm boundaries` says so.
|
||||
|
||||
### Confirmations, toasts, errors, loading, and Back
|
||||
|
||||
`@ekklesia/ui` also carries the interactions DESIGN.md fixes for every screen
|
||||
([ADR 0059](../architecture/decisions/0059-shared-interaction-primitives.md)); use these rather than
|
||||
building your own:
|
||||
|
||||
- **`ConfirmationDialog`** for any action worth confirming. You choose the severity: `tone` stays
|
||||
`default` for a simple confirmation, becomes `"destructive"` for an action that matters, and adds
|
||||
`requiredPhrase` (`"DELETE"`, or the item's own name) only for the most serious ones. Name the
|
||||
action for what it does (`confirmLabel="Delete display"`), name the resource and the consequence in
|
||||
`description`, and set `busy` while the action runs.
|
||||
- **`ToastProvider`** once near the top of your tree, and `useToasts().show({ tone, title })` where
|
||||
something worth confirming happened. Errors stay until dismissed; everything else times out once it
|
||||
has been read.
|
||||
- **`ErrorState`** with a plain `title` and `description`, recovery `actions`, and — collapsed —
|
||||
`details` and a `reference`. Pass a sanitized code or message as `details`, never a caught exception.
|
||||
- **`SkeletonGroup`** labelled with what is loading, containing `Skeleton`s in the shape of the content.
|
||||
- **`PageHeader`** with `back` on a nested page. Your module decides where its own Back goes; a link
|
||||
into the Dashboard's Settings will carry a validated return target once the module navigation
|
||||
contract exists.
|
||||
|
||||
Never call `window.alert`, `window.confirm`, or `window.prompt`. Ekklesia's own lint refuses them;
|
||||
enable the same rule in your module's `biome.json`:
|
||||
|
||||
```json
|
||||
{ "linter": { "rules": { "suspicious": { "noAlert": "error" } } } }
|
||||
```
|
||||
|
||||
### Styling: a stylesheet compiled into your artifact
|
||||
|
||||
Author plain `.css` beside your UI source, import it, and declare it
|
||||
|
||||
Reference in New Issue
Block a user