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

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:
ekklesia
2026-10-14 18:24:07 -05:00
co-authored by Claude Opus 5.5
parent 651efa8829
commit 5a7d44781b
23 changed files with 2538 additions and 59 deletions
+173 -20
View File
@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react";
import { classNames } from "./class-names.js";
/**
@@ -48,6 +48,17 @@ export interface ToastProps {
readonly title: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
/**
* Whether this toast is its own live region. Default `true`, for a toast a caller renders on its
* own. A {@link ToastProvider} renders toasts inside live regions it already owns and turns this
* off, so nothing is announced twice.
*/
readonly announce?: boolean;
/** Renders a dismiss control. */
readonly onDismiss?: () => void;
/** The dismiss control's accessible name. Default "Dismiss notification". */
readonly dismissLabel?: string;
readonly "data-toast-id"?: string;
}
export interface InlineNoticeProps {
@@ -79,27 +90,40 @@ export function InlineNotice(props: InlineNoticeProps): ReactElement {
}
/**
* One notification's presentation — and only its presentation.
* One notification's presentation.
*
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
* package-level singleton would be shared mutable state reaching across every isolated artifact on
* a page. A caller renders these where it wants them, for as long as it wants them.
* The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React
* tree rather than a package singleton: a module-level store would be shared mutable state reaching
* across every isolated artifact on a page. A caller may still render one of these on its own.
*/
export function Toast(props: ToastProps): ReactElement {
const tone = props.tone ?? "info";
const announce = props.announce ?? true;
return (
<div
aria-live={tone === "danger" ? "assertive" : "polite"}
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
role="status"
data-toast-id={props["data-toast-id"]}
role={announce ? "status" : undefined}
>
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
<div className="ek-toast__content">
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
)}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
{props.onDismiss === undefined ? undefined : (
<button
aria-label={props.dismissLabel ?? "Dismiss notification"}
className="ek-button ek-button--quiet ek-button--sm ek-focusable ek-toast__dismiss"
onClick={props.onDismiss}
type="button"
>
<span aria-hidden="true">✕</span>
</button>
)}
</div>
);
@@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement {
}
export interface ErrorStateProps {
/** What failed, in plain words: "The displays could not be loaded." */
readonly title: ReactNode;
/** What the person can do about it, and whether trying again makes sense. */
readonly description?: ReactNode;
/** A retry control, or whatever else the caller can honestly offer. */
/** One recovery control. Kept for existing callers; prefer `actions`. */
readonly action?: ReactNode;
/**
* Recovery and remedy controls — Retry, a link to the setting that fixes it. A caller that knows
* the viewer can administer the problem passes the administrator's remedies here as well; the
* element has no notion of roles.
*/
readonly actions?: ReactNode;
/**
* Technical detail for whoever needs it: an error code, the failing step, a likely cause. Plain
* text, collapsed behind a disclosure, so it never dominates the message. Rendered as text — markup
* in it is shown, never interpreted — with control characters removed and its length bounded.
*/
readonly details?: string;
/** The disclosure's label. Default "Technical details". */
readonly detailsLabel?: string;
/** A diagnostic id that can be quoted to an administrator or found in a log, when it is safe to
* show. Shown inside the details. */
readonly reference?: string;
/**
* Whether the error is announced when it appears (`role="alert"`). Default `true`. Turn it off for
* an error that is part of a page's initial content rather than a change the person caused.
*/
readonly announce?: boolean;
readonly className?: string;
}
/** Technical detail is bounded so an enormous server message cannot bury the page. */
export const ERROR_DETAILS_MAX_LENGTH = 2000;
/**
* The failure a person sees.
* Makes an arbitrary string safe to show as technical detail: strips control characters (keeping
* line breaks and tabs), trims, and bounds its length. React already renders it as text, so markup
* cannot execute; this keeps it legible.
*/
export function errorDetailsText(value: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: removing control characters is the point.
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").trim();
return cleaned.length > ERROR_DETAILS_MAX_LENGTH
? `${cleaned.slice(0, ERROR_DETAILS_MAX_LENGTH)}…`
: cleaned;
}
/**
* The failure a person sees (DESIGN.md §7.3): what failed, what they can do, and — only if they want
* it — the technical detail.
*
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
* component is where that discipline becomes visible to a user.
* The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack:
* `details` takes a string, chosen deliberately — a sanitized error code, the step that failed —
* and stays collapsed until opened.
*/
export function ErrorState(props: ErrorStateProps): ReactElement {
const details = props.details === undefined ? "" : errorDetailsText(props.details);
const reference = props.reference === undefined ? "" : errorDetailsText(props.reference);
const announce = props.announce ?? true;
return (
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
<div
className={classNames("ek-message", "ek-message--error", props.className)}
role={announce ? "alert" : undefined}
>
<p className="ek-message__title">{props.title}</p>
{props.description === undefined ? undefined : (
<p className="ek-message__body">{props.description}</p>
)}
{props.action}
{props.action === undefined && props.actions === undefined ? undefined : (
<div className="ek-message__actions">
{props.action}
{props.actions}
</div>
)}
{details === "" && reference === "" ? undefined : (
<details className="ek-message__details">
<summary className="ek-focusable">{props.detailsLabel ?? "Technical details"}</summary>
{details === "" ? undefined : <pre className="ek-message__details-text">{details}</pre>}
{reference === "" ? undefined : (
<p className="ek-message__reference">
Reference: <code>{reference}</code>
</p>
)}
</details>
)}
</div>
);
}
export interface SkeletonProps {
/** `text` is one line of text; `block` an image, chart, or card; `circle` an avatar. */
readonly shape?: "text" | "block" | "circle";
/** Any CSS length. Text defaults to the full width; a circle is as wide as it is tall. */
readonly width?: string;
readonly height?: string;
readonly className?: string;
}
/**
* A placeholder in the shape of content that is on its way (DESIGN.md §7.1), for content whose
* shape is known. Decorative: hidden from assistive technology, which hears its
* {@link SkeletonGroup}'s label instead. It pulses gently, and not at all for a person who asked
* their system for reduced motion. There is no progress in it, because there is none to report.
*/
export function Skeleton(props: SkeletonProps): ReactElement {
const shape = props.shape ?? "text";
const style: CSSProperties = {};
if (props.width !== undefined) {
style.width = props.width;
}
if (props.height !== undefined) {
style.height = props.height;
}
if (shape === "circle" && props.width !== undefined && props.height === undefined) {
style.height = props.width;
}
return (
<span
aria-hidden="true"
className={classNames("ek-skeleton", `ek-skeleton--${shape}`, props.className)}
style={style}
/>
);
}
export interface SkeletonGroupProps {
/** What is loading, announced once: "Loading displays". */
readonly label: string;
readonly children?: ReactNode;
readonly className?: string;
}
/**
* The loading region a set of {@link Skeleton}s stands in for. It is `aria-busy` and announces its
* label once, politely, rather than every placeholder; when the real content replaces the group,
* the busy region is gone with it.
*/
export function SkeletonGroup(props: SkeletonGroupProps): ReactElement {
const labelId = useId();
return (
<div
aria-busy="true"
aria-labelledby={labelId}
aria-live="polite"
className={classNames("ek-skeleton-group", props.className)}
role="status"
>
<span className="ek-visually-hidden" id={labelId}>
{props.label}
</span>
{props.children}
</div>
);
}