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]>
198 lines
13 KiB
Markdown
198 lines
13 KiB
Markdown
# 0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-10-15
|
|
- Return-target carrier decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
|
|
- 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.
|