feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives (Stage 1, ADR 0059) #106

Merged
Zayin_Admin merged 1 commits from feat/ui-state-primitives into main 2026-10-15 00:43:53 +00:00
Member

Stage 1 PR 3: the shared UI-state and interaction primitives that later Stage 1 work builds on. Decisions are recorded in ADR 0059.

Public API added to @ekklesia/ui

All additive. Existing props are unchanged, so existing Signage and shell call sites keep working as they are.

Export What
ConfirmationDialog, ConfirmationDialogProps, ConfirmationTone Product-owned confirmation: simple, tone="destructive", and requiredPhrase typed confirmation, plus busy. Severity is chosen by the caller.
ToastProvider, ToastProviderProps, useToasts, ToastApi, ToastOptions, ToastTone Top-center toast system, one per React tree (no package singleton). show({ tone, title, description?, duration? }) → id and dismiss(id).
Skeleton, SkeletonProps, SkeletonGroup, SkeletonGroupProps Shape-known placeholders, inside a busy, labelled region announced once.
PageHeaderBack (new PageHeader prop back) Back above the page's single h1: a link when it has an address, otherwise a button.
ErrorState: new actions, details, detailsLabel, reference, announce Plain message first. Technical detail and a reference sit in a collapsed disclosure.
Dialog: new description, dismissible, initialFocusRef, data-testid Real focus trap and focus restoration. Escape is handled on the dialog itself.
Toast: new onDismiss, dismissLabel, announce Used by the provider, and still usable on its own.

Deliberately not exported: the focus trap, the duration rule, the phrase matcher, and the details sanitizer. They are tested internals, not promised API. No new dependency: the glyphs are text, as status symbols already were.

Shell

surfaces/shell/src/return-target.ts and browser/dashboard-back.ts validate a return target and build PageHeader's Back.

  • Accepted: a string of at most 2 KiB, starting with exactly one /, that names a page the Dashboard actually has.
  • Rejected: whitespace, control characters, backslashes, encoded / \ ., dot-segments, queries, fragments, and other origins.
  • Fallback: without a valid target, Back goes to the page's declared parent. It never uses history.back(), which can leave the Dashboard.
  • Not decided here: how a link carries the target. That stays with the module-navigation contract (ADR 0057).

No shell page is changed to use the new primitives, and nothing is wired to toasts just to demonstrate them.

Native-dialog guard: repository-wide

  • Rule: the root biome.json makes Biome's scope-aware suspicious/noAlert an error. Every package, surface, and template in this repo lints against it. It flags alert( / confirm( / prompt( bare, through window or globalThis, and by computed member. It ignores local functions with those names, methods such as dialog.confirm(), props such as onConfirm, strings, and comments.
  • Test: tests/native-dialogs.test.ts lints prohibited and permitted fixtures with the repository's own config. It also 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 (const c = window.confirm).
  • Superseded: the shell-only regex test from #104 is removed.
  • Module repos have their own lint config, so this repo can't reach them. The module guide now says to enable the rule, and Zayin/Signage#23 tracks it there.

Behaviour change to review

Dialog used to listen for Escape on the document. It now handles Escape on the dialog element and stops propagation there, because focus is trapped inside it. Previously, Escape in the shell's device-revocation dialog inside the phone drawer closed the drawer as well. The existing test now dispatches Escape on the dialog.

Accessibility covered

  • Focus trap and restoration. The trap reads activeElement from the dialog's own root node, so it works inside a module's shadow root.
  • Initial focus: the phrase field when typing is required, Cancel for a destructive action, and Confirm otherwise.
  • Escape handling, including "not now" while busy.
  • aria-modal, aria-labelledby, aria-describedby.
  • The scrim blocks pointer presses and does not dismiss.
  • Polite and assertive live regions are mounted before any toast arrives. Toasts don't nest live regions, pause on hover and focus, have named dismiss buttons, and Escape dismisses the focused toast.
  • aria-busy on skeleton groups and busy confirmations.
  • Every new animation stops under prefers-reduced-motion.
  • Destructive state is shown by symbol and frame, not colour alone.
  • Touch-target minimums on dismiss and summary controls.
  • One h1 per page header.

Tests

  • packages/ui/tests/interaction.test.tsx (new, 46 tests):
    • ConfirmationDialog: open and close, Cancel, Confirm, destructive mode, typed phrase mismatch and match (including Enter and exact-case rules), forgetting the phrase on close, keyboard-only operation, Escape with focus return, busy state, the focus trap both ways, background blocked, Escape stopping at the dialog, and the accessible name and description.
    • Dialog: trap, description, and restoration; non-dismissible Escape.
    • Toasts: live regions, each severity, auto-dismiss, the three-second minimum and length scaling, errors persisting, manual and programmatic dismiss, Escape with focus return, ordering, de-duplication and error-safe limits, pause on hover, all timers cleared on unmount, and no API outside a provider.
    • ErrorState: simple message, collapsed details and reference, actions, and hostile HTML and control-character strings rendered as bounded inert text.
    • Skeleton: busy labelled region, and reduced motion.
    • PageHeader: title, actions, Back as a link and as a button, and modified clicks left to the browser.
  • surfaces/shell/tests/return-target.test.ts (new): accepted targets; rejected non-strings, other origins, traversal, encoded separators, queries, and unknown pages; the fallback; and never using history.back().
  • tests/native-dialogs.test.ts (new): true positives, no false positives, and the residual scan over all browser source in this repository.

Results (all on the pushed commit)

  • TURBO_FORCE=true pnpm verify (no Turbo cache, see #105): passed. ui 58, shell 311, root 162, api 522, core 1161, every package.
  • pnpm infrastructure:config, pnpm deploy:config: passed.
  • pnpm test:package-release: passed. A standalone module consumed train 0.2.5, including the new @ekklesia/ui.
  • pnpm test:package-publication: passed (disposable Gitea).
  • pnpm test:migrations:integration: passed, 401 tests.
  • scripts/smoke-deploy.sh, same-origin and split: both passed. The split run drives the real shell in Chromium, including the device panel's Dialog and the drawer.

Visual and browser check

I rendered the primitives inside a shadow root (as a module artifact mounts) in headless Chromium, using real key events over the DevTools protocol:

  • The dialog opens with focus on the phrase field, and Tab and Shift+Tab cycle only within the dialog.
  • Confirm is disabled until "Foyer-1" is typed.
  • Escape closes the dialog and focus returns to the opener.
  • The error toast lands in the assertive region and the routine toasts in the polite one.
  • The skeleton animation is none under reduced motion.

I also captured screenshots at 1200×820 and 390×800.

Deferred

  • A host-owned toast region fed through the module UI SDK.
  • How a link carries a return target (module navigation contract).
  • Exporting the focus trap.
  • Moving the shell drawer onto it.
  • Replacing the device panel's revoke dialog with ConfirmationDialog (it moves to Settings → Displays).
  • A personal reduced-motion override.
  • Lint in module repositories (Zayin/Signage#23).

🤖 Generated with Claude Code

Stage 1 PR 3: **the shared UI-state and interaction primitives** that later Stage 1 work builds on. Decisions are recorded in [ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md). ## Public API added to `@ekklesia/ui` All additive. Existing props are unchanged, so existing Signage and shell call sites keep working as they are. | Export | What | | --- | --- | | `ConfirmationDialog`, `ConfirmationDialogProps`, `ConfirmationTone` | Product-owned confirmation: simple, `tone="destructive"`, and `requiredPhrase` typed confirmation, plus `busy`. Severity is chosen by the caller. | | `ToastProvider`, `ToastProviderProps`, `useToasts`, `ToastApi`, `ToastOptions`, `ToastTone` | Top-center toast system, one per React tree (no package singleton). `show({ tone, title, description?, duration? }) → id` and `dismiss(id)`. | | `Skeleton`, `SkeletonProps`, `SkeletonGroup`, `SkeletonGroupProps` | Shape-known placeholders, inside a busy, labelled region announced once. | | `PageHeaderBack` (new `PageHeader` prop `back`) | Back above the page's single `h1`: a link when it has an address, otherwise a button. | | `ErrorState`: new `actions`, `details`, `detailsLabel`, `reference`, `announce` | Plain message first. Technical detail and a reference sit in a collapsed disclosure. | | `Dialog`: new `description`, `dismissible`, `initialFocusRef`, `data-testid` | Real focus trap and focus restoration. Escape is handled on the dialog itself. | | `Toast`: new `onDismiss`, `dismissLabel`, `announce` | Used by the provider, and still usable on its own. | **Deliberately not exported:** the focus trap, the duration rule, the phrase matcher, and the details sanitizer. They are tested internals, not promised API. **No new dependency:** the glyphs are text, as status symbols already were. ## Shell `surfaces/shell/src/return-target.ts` and `browser/dashboard-back.ts` validate a return target and build `PageHeader`'s Back. - **Accepted:** a string of at most 2 KiB, starting with exactly one `/`, that names a page the Dashboard actually has. - **Rejected:** whitespace, control characters, backslashes, encoded `/` `\` `.`, dot-segments, queries, fragments, and other origins. - **Fallback:** without a valid target, Back goes to the page's declared parent. It never uses `history.back()`, which can leave the Dashboard. - **Not decided here:** how a link carries the target. That stays with the module-navigation contract (ADR 0057). No shell page is changed to use the new primitives, and nothing is wired to toasts just to demonstrate them. ## Native-dialog guard: repository-wide - **Rule:** the root `biome.json` makes Biome's scope-aware `suspicious/noAlert` an **error**. Every package, surface, and template in this repo lints against it. It flags `alert(` / `confirm(` / `prompt(` bare, through `window` or `globalThis`, and by computed member. It ignores local functions with those names, methods such as `dialog.confirm()`, props such as `onConfirm`, strings, and comments. - **Test:** `tests/native-dialogs.test.ts` lints prohibited and permitted fixtures with the repository's own config. It also 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 (`const c = window.confirm`). - **Superseded:** the shell-only regex test from #104 is removed. - **Module repos** have their own lint config, so this repo can't reach them. The module guide now says to enable the rule, and Zayin/Signage#23 tracks it there. ## Behaviour change to review `Dialog` used to listen for Escape on the **document**. It now handles Escape on the dialog element and stops propagation there, because focus is trapped inside it. Previously, Escape in the shell's device-revocation dialog inside the phone drawer closed the drawer as well. The existing test now dispatches Escape on the dialog. ## Accessibility covered - Focus trap and restoration. The trap reads `activeElement` from the dialog's own root node, so it works inside a module's shadow root. - Initial focus: the phrase field when typing is required, Cancel for a destructive action, and Confirm otherwise. - Escape handling, including "not now" while `busy`. - `aria-modal`, `aria-labelledby`, `aria-describedby`. - The scrim blocks pointer presses and does not dismiss. - Polite and assertive live regions are mounted before any toast arrives. Toasts don't nest live regions, pause on hover and focus, have named dismiss buttons, and Escape dismisses the focused toast. - `aria-busy` on skeleton groups and busy confirmations. - Every new animation stops under `prefers-reduced-motion`. - Destructive state is shown by symbol and frame, not colour alone. - Touch-target minimums on dismiss and summary controls. - One `h1` per page header. ## Tests - **`packages/ui/tests/interaction.test.tsx` (new, 46 tests):** - **ConfirmationDialog:** open and close, Cancel, Confirm, destructive mode, typed phrase mismatch and match (including Enter and exact-case rules), forgetting the phrase on close, keyboard-only operation, Escape with focus return, busy state, the focus trap both ways, background blocked, Escape stopping at the dialog, and the accessible name and description. - **Dialog:** trap, description, and restoration; non-dismissible Escape. - **Toasts:** live regions, each severity, auto-dismiss, the three-second minimum and length scaling, errors persisting, manual and programmatic dismiss, Escape with focus return, ordering, de-duplication and error-safe limits, pause on hover, all timers cleared on unmount, and no API outside a provider. - **ErrorState:** simple message, collapsed details and reference, actions, and hostile HTML and control-character strings rendered as bounded inert text. - **Skeleton:** busy labelled region, and reduced motion. - **PageHeader:** title, actions, Back as a link and as a button, and modified clicks left to the browser. - **`surfaces/shell/tests/return-target.test.ts` (new):** accepted targets; rejected non-strings, other origins, traversal, encoded separators, queries, and unknown pages; the fallback; and never using `history.back()`. - **`tests/native-dialogs.test.ts` (new):** true positives, no false positives, and the residual scan over all browser source in this repository. ### Results (all on the pushed commit) - `TURBO_FORCE=true pnpm verify` (no Turbo cache, see #105): passed. ui 58, shell 311, root 162, api 522, core 1161, every package. - `pnpm infrastructure:config`, `pnpm deploy:config`: passed. - `pnpm test:package-release`: passed. A standalone module consumed train 0.2.5, including the new `@ekklesia/ui`. - `pnpm test:package-publication`: passed (disposable Gitea). - `pnpm test:migrations:integration`: passed, 401 tests. - `scripts/smoke-deploy.sh`, same-origin and `split`: both passed. The split run drives the real shell in Chromium, including the device panel's `Dialog` and the drawer. ### Visual and browser check I rendered the primitives **inside a shadow root** (as a module artifact mounts) in headless Chromium, using real key events over the DevTools protocol: - The dialog opens with focus on the phrase field, and Tab and Shift+Tab cycle only within the dialog. - Confirm is disabled until "Foyer-1" is typed. - Escape closes the dialog and focus returns to the opener. - The error toast lands in the assertive region and the routine toasts in the polite one. - The skeleton animation is `none` under reduced motion. I also captured screenshots at 1200×820 and 390×800. ## Deferred - A host-owned toast region fed through the module UI SDK. - How a link carries a return target (module navigation contract). - Exporting the focus trap. - Moving the shell drawer onto it. - Replacing the device panel's revoke dialog with `ConfirmationDialog` (it moves to Settings → Displays). - A personal reduced-motion override. - Lint in module repositories (Zayin/Signage#23). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 1 commit 2026-10-14 23:30:45 +00:00
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
5a7d44781b
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]>
Zayin_Admin merged commit e3e2a901a7 into main 2026-10-15 00:43:53 +00:00
Zayin_Admin deleted branch feat/ui-state-primitives 2026-10-15 00:43:53 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#106