Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 5a7d44781b 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]>
2026-10-14 18:24:07 -05:00
Zayin_Admin 651efa8829 Merge pull request 'feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058)' (#104) from feat/dashboard-shell-navigation into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m15s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 59s
CI / deploy-smoke (split) (push) Successful in 2m32s
Reviewed-on: #104
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 23:05:29 +00:00
ekklesiaandClaude Opus 5.5 cf2f847c8c test(core): pin the shell's one use of browser storage, not ban it
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
CI failed in `identity-verification-boundaries.test.ts`: its guard that a
reset token never reaches browser storage was a blanket ban on the word
`localStorage` anywhere in the shell. The sidebar preference (ADR 0058)
legitimately reads it in the entry point.

The guarantee is unchanged: the reset token stays in React state for
the life of one form. The guard is now precise:

- `localStorage` appears only in `main.tsx`, only to hand it to the
  sidebar preference;
- the shell's only storage write is the sidebar's own key and a
  validated mode;
- the files that ever hold the token (`auth-protocol.ts`,
  `ResetPasswordForm.tsx`, `HostApp.tsx`) touch no storage API at all;
- `sessionStorage` and `document.cookie` stay banned everywhere.

A planted `localStorage.setItem("t", token)` in `auth-protocol.ts` fails
it. The runtime reset-token test in `auth-recovery.test.tsx` now runs
with the production localStorage-backed preference and asserts storage
holds nothing but the sidebar key.

It passed locally only because Turbo restored `@ekklesia/core:test` from
cache: that test reads shell source that Core's task hash does not
include.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:55:07 -05:00
ekklesiaandClaude Opus 5.5 af31724cb4 feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:56 -05:00
Joey f3b7e19651 Merge pull request 'feat(shell): give every Dashboard page a real URL, with modules at /<module-id> (Stage 1, ADR 0057)' (#103) from feat/dashboard-url-routing into main
CI / verify (push) Successful in 1m8s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 47s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m14s
CI / deploy-smoke (same-origin) (push) Successful in 56s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m30s
Reviewed-on: #103
Reviewed-by: Joey <[email protected]>
2026-10-14 22:07:20 +00:00
ekklesiaandClaude Opus 5.5 ba60f70835 feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.

- Core reserves the top-level names the platform actually serves today
  (`account`, `api`, `assets`, `display`, `settings`), and discovery
  refuses a module that declares one with `MODULE_ID_RESERVED`. That
  covers startup, `modules:validate`, and package verification. Tests in
  `apps/api` hold the set to every top-level route the API registers and
  every page the shell interprets.
- The API serves the Dashboard page at `/<module-id>` and below only for
  modules discovered with a Dashboard UI, enabled or not: a disabled
  module's bookmark still opens the Dashboard, which says it is
  unavailable. An arbitrary root path such as
  `/definitely-not-a-real-module` stays the sanitized 404.
- Both reference proxies forward every top-level path shaped like a
  module id and let the API decide; the split Dashboard hostname still
  refuses `/api`, `/display`, and `/assets`. Installing a module needs no
  proxy change.
- `/<module-id>/<page>` is already served and forwarded, so a later
  module sub-page contract needs no routing or deployment change; the
  shell shows "Page not found" there until it exists.
- The deploy smoke checks a module's address before install, while
  installed, while disabled, after removal, and after reinstall, plus
  unknown names and the old `/modules/…` form through the real proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:42:34 -05:00
ekklesiaandClaude Opus 5.5 9afe9376c4 feat(shell): give every Dashboard page a real URL
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both
reference proxies forwarded only `/`, and the open module lived in React
state, so a refresh went back to the first module, nothing could be
bookmarked, and Back left the Dashboard (DESIGN.md §2.7).

ADR 0057 makes the address bar the source of truth:

- `/` is a host-owned Overview, now the landing page; `/modules/<id>`
  mounts that module. An id the server's navigation does not list shows
  "This page isn't available" and loads nothing; any other Dashboard
  address is a host-owned "Page not found".
- `/modules`, `/settings`, and `/account` are reserved whole, from one
  list (`@ekklesia/shell/dashboard-routes`) that the shell's router and
  the API's page routes both read. The API serves the same frozen page,
  ETag, and security headers anywhere inside them; look-alikes and other
  methods stay the sanitized 404.
- A small host-owned history replaces the selected-module state, built in
  the entry point so it stays the only file touching `window`. Navigation
  items are real links; modified clicks are left to the browser. Focus
  moves to the page after an in-place change, and the tab is named after
  the page.
- Sign-in still happens in place with no return value (ADR 0034), so a
  deep link opens its own page after sign-in.
- Both reference Caddyfiles forward the new paths; the deploy smoke now
  reloads a module's address through the real proxy and requires it to
  mount again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:11:02 -05:00
Zayin_Admin 44430b5d59 Merge pull request 'test(api): let a shutdown's closed connections leave pg_stat_activity' (#100) from fix/flaky-shutdown-connection-check into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 32s
CI / migration-smoke (push) Successful in 2m20s
CI / deploy-smoke (same-origin) (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 2m25s
CI / package-publication (push) Successful in 1m2s
Reviewed-on: #100
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 04:41:38 +00:00
Zayin_Admin bef516796e Merge branch 'main' into fix/flaky-shutdown-connection-check
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m24s
CI / image-build (pull_request) Successful in 1m2s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m36s
2026-10-14 04:23:11 +00:00
Zayin_Admin 9bef1d9ef2 Merge pull request 'docs(claude): file an issue for what you find outside the task' (#99) from docs/claude-md-report-findings into main
CI / verify (push) Successful in 1m15s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 2m16s
CI / package-release (push) Successful in 47s
CI / image-build (push) Successful in 30s
CI / deploy-smoke (same-origin) (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 2m30s
CI / package-publication (push) Successful in 53s
Reviewed-on: #99
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 04:23:04 +00:00
ekklesiaandClaude Opus 5.5 f2fc069e66 test(api): let a shutdown's closed connections leave pg_stat_activity
CI / verify (pull_request) Successful in 1m16s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / package-release (pull_request) Successful in 57s
CI / image-build (pull_request) Successful in 1m1s
CI / migration-smoke (pull_request) Successful in 2m30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 55s
CI / deploy-smoke (split) (pull_request) Successful in 2m41s
Four integration tests shut the API down and immediately count the
database's rows in pg_stat_activity, expecting zero. PostgreSQL ends a
backend asynchronously: pool.end() resolves once the client side has
closed, and the server can still list that backend for a moment. On a
loaded runner an immediate count saw one, failing migration-smoke on a
docs-only PR (Ekklesia CI run #238, module-database.integration.ts:764,
"expected 1 to be +0").

Reproduced against postgres:18.4-alpine3.24 limited to 0.05 CPU: after
pool.end() resolved, the backend was still listed in 7 of 300 trials,
and gone about a millisecond later.

The checks now wait up to 3 s for the count to reach zero. A connection
that is genuinely left open is still caught: an idle pooled client
stays listed for pg-pool's 10 s idle timeout, far past the deadline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:13:21 -05:00
ekklesiaandClaude Opus 5.5 0552442c7c docs(claude): file an issue for what you find outside the task
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / package-release (pull_request) Successful in 53s
CI / package-publication (pull_request) Successful in 1m3s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 59s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / migration-smoke (pull_request) Successful in 2m9s
Findings that only live in a chat reply get lost. Agents now open a
descriptive issue in the repository a problem belongs to (after
searching for an existing one), mark it found-by-claude, leave it
unassigned, and list what they filed when they report back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:58:17 -05:00
Zayin_Admin 63fda0f8d6 Merge pull request 'fix(ci): confine the sweep self-test to its own run' (#96) from fix/ci-sweep-self-test-scope into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m11s
CI / package-release (push) Successful in 53s
CI / image-build (push) Successful in 36s
CI / deploy-smoke (same-origin) (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m41s
CI / package-publication (push) Successful in 1m3s
Reviewed-on: #96
Reviewed-by: Zayin_Admin <[email protected]>
2026-10-14 03:55:35 +00:00
ekklesiaandClaude Opus 5.5 94fa7a17a8 fix(ci): confine the sweep self-test to its own run
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m21s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 2m35s
CI / package-publication (pull_request) Successful in 1m3s
scripts/test-ci-sweep.sh proves the stale sweep by running
`ci-sweep.sh --stale --max-age-seconds 0` on the runner's shared Docker
daemon. With a zero threshold that removes every `ekklesia-ci-` stack on
the daemon, not just the test's own: any job running at that moment
loses its containers, networks, and volumes. It reported "CI sweep
checks passed" while doing it. Core's infrastructure-smoke job runs the
self-test on every run, and twice tore down Signage's integration stack
mid-scenario (Zayin/Signage run 288: its first attempt, and the second
one during connectivity-blip).

`ci-sweep.sh --stale` now takes `--prefix P`, which narrows the sweep to
stacks whose names begin with P and must itself begin with the CI
prefix, so it can only narrow a sweep, never widen one. The self-test
confines every stale sweep to its own fake run and proves that another
run's stack survives even the zero threshold. The workflow's own sweeps
are unchanged.

Regression: a bystander stack from another run in the self-test, and a
Docker-free guard that every stale sweep in the self-test is confined
and the workflow's keep the default threshold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:46:38 -05:00
76 changed files with 7202 additions and 280 deletions
+7
View File
@@ -108,6 +108,13 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands
+33 -1
View File
@@ -822,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
(since added; see the Stage 1 paragraph below),
and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -905,6 +906,37 @@ the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
later Stage 1 work.
The Dashboard's **shell** follows DESIGN.md §4
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
sidebar's identity slot is still to come.
`@ekklesia/ui` now carries the interactions every later screen shares
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
repository-wide.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
+12 -4
View File
@@ -363,10 +363,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `./dashboard-host-routes.js`.
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route(
"/",
+65 -15
View File
@@ -1,13 +1,19 @@
import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/**
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* `/api/v1/modules/<module-id>`.
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/
export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
@@ -85,29 +91,73 @@ function fixedAsset(
});
}
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape.
*
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* the sign-in form until `/api/v1/principal` says otherwise.
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
* server change later) — always with the **same** bytes and the same validator, because the path
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall through to the
* application's ordinary sanitized `404`. Like the display host's code, these bytes grant no
* authority: the page renders the sign-in form until `/api/v1/principal` says otherwise.
*/
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
export function createDashboardHostRoutes(
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono();
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
const page = (ifNoneMatch: string | undefined): Response =>
fixedAsset(
c.req.header("if-none-match"),
ifNoneMatch,
snapshot.html,
snapshot.htmlEtag,
"text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS,
),
);
);
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset(
+26 -3
View File
@@ -208,6 +208,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -1349,7 +1372,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
@@ -2230,7 +2253,7 @@ describe.sequential("live API — platform owner and delegated grant management"
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
@@ -2837,7 +2860,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
+217 -3
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import {
ANONYMOUS_PRINCIPAL,
type ModuleRouteHandler,
parseModuleManifest,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
@@ -13,9 +14,13 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
@@ -33,11 +38,23 @@ import {
DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
} from "../src/module-display-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
@@ -78,9 +95,40 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
}
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp(
options: {
readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {},
) {
@@ -100,7 +148,10 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: emptyModuleLifecycle(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(),
dashboard: createInMemoryDashboardStore(),
@@ -193,6 +244,169 @@ describe("GET / (the Dashboard page)", () => {
});
});
describe("Dashboard deep links (ADR 0057)", () => {
const installed = () => [
discoveredModule("signage", { ui: true }),
discoveredModule("calendar", { ui: true }),
discoveredModule("retired", { ui: true }),
// A display-only module has no Dashboard page (ADR 0033).
discoveredModule("lobby-screen", { ui: false }),
];
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
const deepLinks = [
"/settings",
"/settings/",
"/settings/displays/add",
"/account",
"/account/appearance",
"/signage",
"/signage/",
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
"/signage/timelines",
"/signage/timelines/a1b2",
"/calendar",
"/retired",
];
it("serves the shell's own platform pages, from the shell's own list", () => {
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of deepLinks) {
const response = await app.request(path);
expect(response.status, path).toBe(200);
expect(await response.text(), path).toBe(PAGE_HTML);
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control"), path).toBe("no-cache");
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("revalidates a deep link with the page's one validator", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
expect(response.status, path).toBe(304);
expect(await response.text(), path).toBe("");
expectPageSecurityHeaders(response);
}
});
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of [
"/definitely-not-a-real-module",
"/definitely-not-a-real-module/timelines",
// Installed, but with no Dashboard UI.
"/lobby-screen",
// Look-alikes of real pages.
"/signages",
"/signage-old",
"/Signage",
"/settings-old",
"/accounts",
// The former implementation prefix is not a namespace at all.
"/modules",
"/modules/signage",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("serves a module's page only while that module is installed", async () => {
const { app } = buildApp({ discovered: [] });
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
expect((await app.request("/settings")).status).toBe(200);
});
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of [
"/signage/app.js",
"/settings/assets/ekklesia-dashboard/app.js",
"/account/index.html",
]) {
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
}
});
it("answers no other method at a Dashboard page", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("refuses at composition a module page that would shadow a platform namespace", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect(
() =>
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError);
}
});
});
describe("the platform's reserved top-level names (ADR 0057)", () => {
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
const topLevel = new Set(
app.routes
.map((route) => route.path.split("/")[1] ?? "")
.filter((segment) => segment !== "" && segment !== "*"),
);
topLevel.delete("signage");
// This composition publishes no display, so the display host's routes are absent here; they
// are checked through their own path constants below.
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
for (const segment of topLevel) {
expect(isReservedModuleId(segment), segment).toBe(true);
}
});
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
for (const path of [
MODULE_DISPLAY_PAGE_PREFIX,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_HOST_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
]) {
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
}
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
"account",
"api",
"assets",
"display",
"settings",
]);
});
it("include every Dashboard platform page the shell interprets", () => {
for (const page of DASHBOARD_PLATFORM_PAGES) {
expect(RESERVED_MODULE_IDS, page).toContain(page);
}
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp();
+24 -1
View File
@@ -195,6 +195,29 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
}
}
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
@@ -760,7 +783,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder();
+4 -1
View File
@@ -19,7 +19,10 @@
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
"preset": "recommended",
"suspicious": {
"noAlert": "error"
}
}
},
"javascript": {
+14
View File
@@ -9,6 +9,7 @@ import {
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID"
@@ -614,6 +616,18 @@ async function scanModuleDirectory(
});
}
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (ADR 0057), in the same URL space as the
// platform's own top-level namespaces.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) {
return undefined;
+1
View File
@@ -13,6 +13,7 @@ export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
+33
View File
@@ -0,0 +1,33 @@
/**
* Top-level names the platform owns, which therefore can never be a module id (ADR 0057).
*
* Every module with a Dashboard UI is addressed at `/<module-id>`, beside the platform's own
* top-level namespaces in one URL space. A module called `settings` would collide with the
* Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the
* platform serves today, not a guess at what it might want later:
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`,
* `/assets/ekklesia-display/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*
* `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the
* shell interprets, so a new top-level platform route cannot be added without reserving it here.
* Reserving a name is a breaking change for any installation with a module of that id, so the set
* grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
// is its own fixed key and a validated mode.
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = [];
for (const file of files) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
writes.push(
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch(
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
);
} else {
expect(code, file.path).not.toContain("localStorage");
}
}
expect(writes).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
]);
// The files that ever hold the token touch no storage API at all.
for (const file of files.filter((candidate) =>
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
candidate.path.endsWith(name),
),
)) {
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
}
});
});
+48
View File
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
isReservedModuleId,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
RESERVED_MODULE_IDS,
} from "../src/modules/index.js";
interface ModuleFixtureOptions {
@@ -271,6 +273,52 @@ describe("module discovery", () => {
);
});
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
// platform-owned top-level namespace would collide with the API, the display host, a host
// script, or a Dashboard page.
expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]);
expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true);
for (const id of RESERVED_MODULE_IDS) {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, id);
const error = await discoveryError(modulesDirectory);
expect(error.issues, id).toEqual([
expect.objectContaining({
code: "MODULE_ID_RESERVED",
moduleDirectory: id,
moduleId: id,
path: "id",
}),
]);
}
});
it("accepts ids that merely resemble a reserved name", async () => {
const modulesDirectory = await createModulesDirectory();
for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) {
await createModule(modulesDirectory, id);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
"api-docs",
"displays",
"my-account",
"settings-sync",
]);
expect(isReservedModuleId("settings")).toBe(true);
expect(isReservedModuleId("settings-sync")).toBe(false);
});
it("enforces platform and Module SDK compatibility", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "future", {
+14 -1
View File
@@ -42,9 +42,16 @@
{$EKKLESIA_HOSTNAME} {
# Everything the API serves: the Dashboard at /, the API, display pages and their service
# worker, and the Dashboard and display host scripts. Any other path is refused here.
# worker, and the Dashboard and display host scripts.
@ekklesia path / /api/* /display/* /assets/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# Any other path is refused here.
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
# Module realtime is Server-Sent Events: flush every event immediately.
@realtime path /api/v1/module-realtime/*
@@ -63,6 +70,12 @@
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+16
View File
@@ -51,12 +51,28 @@
{$EKKLESIA_DASHBOARD_HOSTNAME} {
@dashboard path / /assets/ekklesia-dashboard/*
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
# The API's own namespaces stay refused on this hostname.
@dashboardPage {
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
not path /api /api/* /display /display/* /assets /assets/*
}
handle @dashboard {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle @dashboardPage {
reverse_proxy api:3000 {
import ekklesia_forwarding
}
}
handle {
respond 404
}
+15 -3
View File
@@ -241,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
proxy after editing it. Installing or removing a module needs no proxy change.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
@@ -263,7 +271,7 @@ The same image serves both; only configuration differs.
| Hostname | Serves | Refuses (`404`) |
| --- | --- | --- |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
@@ -339,7 +347,11 @@ networks:
Then make your proxy do what `Caddyfile.example` does:
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
@@ -405,7 +417,7 @@ deployment:
| # | Item | Where it is met |
| --- | --- | --- |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
module routing is a design this pass should not settle by accident.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has host-owned URL routing; module routing remains a separate, deferred decision.
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
to the host, and adopting it here for a two-item selector would prejudge that design.
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
## Deferred
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
> unchanged.
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
@@ -165,6 +165,10 @@ redirect target.
A future client router may define an internal return-location contract, but it must be a separately
validated, same-origin route decision. This pass does not create one speculatively.
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
> the current URL, which is now a real page address.
### Cookie and origin policy does not broaden for the Dashboard
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
@@ -0,0 +1,237 @@
# 0057: Dashboard URLs are real, host-owned routes
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0034](0034-host-owned-browser-authentication.md),
[0044](0044-self-hosted-reference-deployment.md),
[0045](0045-split-dashboard-and-api-origins.md)
## Context
The Dashboard had exactly one URL. `apps/api` served the page at `/` and nowhere else, both
reference proxies forwarded only `/` to it, and the shell kept "which module is open" in React state.
Opening a module never changed the address bar, so a refresh always returned to the first module,
nothing could be bookmarked or shared, and the browser's Back button left the Dashboard entirely.
ADR 0026 rejected a client router *for that pass* — "a selected-module state is sufficient" — and
ADR 0027 deferred client routing and deep links.
The product contract now requires them. DESIGN.md §2.7 says the URL represents the current place in
the product: a refresh returns to the same page, deep links work, Back and Forward behave
predictably. §5 makes an **Overview** the Dashboard's landing page, and §12.6 says a disabled module's
routes stop behaving as active routes. Everything else Stage 1 builds — the sidebar, Settings, the
account area, platform Displays, a module's own pages in the sidebar, and returning to the workflow a
Settings deep link came from — needs stable addresses to hang from, so this is the first slice.
A route that works in the shell but not on refresh is worse than no route, because it looks like it
works. Refresh is a server and proxy concern before it is a browser one: the API had to serve the
page at the new paths, and the proxies had to forward them.
## Decision
### One top-level URL space, shared by platform pages and module pages
```text
/ the Overview
/settings[/…] Settings
/account[/…] the account area
/<module-id> a module's page: /signage, /calendar, /people
/<module-id>/<page>[/…] reserved for that module's own pages: /signage/timelines
```
A module is a first-class top-level destination, addressed by its id, exactly as Settings and the
Account area are. "Module" is an implementation concept — how the platform packages a product — and
it does not appear in the addresses people see, bookmark, and share. A module id is lowercase
kebab-case and Core-validated, which is what makes it usable as a path segment with no further
encoding.
### Platform-owned top-level names are reserved, and Core enforces it
Sharing one namespace means a module must never be able to take a name the platform uses.
`RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`) is the complete set the platform serves
at the top level **today**, taken from what is actually registered rather than from guesses:
| Name | Owned by |
| --- | --- |
| `api` | the API: identity, principal, health, Core resources, module routes, module UI artifacts, display descriptors, realtime |
| `assets` | the Dashboard's and display host's scripts |
| `display` | the display host's pages and service worker |
| `settings` | the Dashboard's Settings pages |
| `account` | the Dashboard's account area |
Discovery refuses a module that declares one (`MODULE_ID_RESERVED`), so the check covers API
startup, `pnpm modules:validate`, and Module Package verification — before a module is installed,
not only when it is served. Exact names only: `settings-sync` or `displays` are ordinary ids.
The set is held to reality by tests in `apps/api` rather than by review: every top-level segment the
composed API registers is a reserved name or a module page, the display host's and Dashboard
script's path constants all start with a reserved name, and every Dashboard page the shell
interprets is reserved. Adding a new top-level platform route therefore fails a test until its name
is reserved. Reserving a name later is a **breaking change** for any installation with a module of
that id, so the set grows only deliberately, and only for a real route.
The rule lives in Core's discovery rather than in the module manifest grammar
(`@ekklesia/contracts`), because it is the Ekklesia host's URL space, not a property of a module: a
standalone host (ADR 0021) has no Settings page to protect. A module built against the current
public packages is unaffected unless it uses one of these five names.
### The server serves one page, unchanged, at every real Dashboard address — and nowhere else
`createDashboardHostRoutes` answers `GET` (and Hono's implicit `HEAD`) at `/`, at `/settings` and
`/account` and everything below each (`DASHBOARD_PLATFORM_PAGES`, exported by the shell as
`@ekklesia/shell/dashboard-routes`, so the server and the shell's router read one list), and at
`/<module-id>` and everything below it for **each module this process discovered that declares a
Dashboard UI**. Every answer is the same frozen bytes, the same strong `ETag`, and the same security
headers as `/`: the path selects a page inside the shell, never a file on the server. Nothing from
the request is composed into a lookup, no directory is exposed, and the script and source map remain
reachable only at their own literal paths.
The module set is the boot snapshot's discovered inventory, fixed at startup like every other
module-derived route, and it deliberately **includes disabled and blocked modules**. A bookmark to a
module an administrator turned off still opens the Dashboard, which says the module is unavailable;
that is more useful than an address that suddenly looks like it never existed. A module that is not
installed, or that has no Dashboard UI (a display-only module, ADR 0033), has no page.
Everything else stays the sanitized `404`: an arbitrary root path (`/definitely-not-a-real-module`),
a look-alike (`/settings-old`, `/signages`), another method, `/index.html`, and so on. The routes
also refuse to compose if handed a module page that is not a valid module id, is a reserved name, or
appears twice — discovery already guarantees all three, and the routes do not trust it.
Serving `/<module-id>` before sign-in means an anonymous request can learn whether a module with a
Dashboard UI is installed, by its `200` or `404`. That is accepted: it is the same disclosure the
display host already makes at `/display/<module-id>` (ADR 0033), a module id names a product rather
than any data, and the alternative — a page at every module-shaped root path — is exactly the
"arbitrary path becomes a Dashboard page" this decision rejects. The authenticated module inventory
and every module route keep their authorization unchanged.
### The proxies forward module-shaped names and let the API decide
A proxy cannot know which modules an installation has, and a proxy configuration listing them would
have to be edited on every install. Both reference Caddyfiles therefore forward every top-level path
whose first segment has a module id's shape, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$`, and the API
answers it with the page or its sanitized `404`. Anything not shaped like one — `/favicon.ico`,
`/.well-known/…`, an uppercase name — is still refused by the proxy without reaching the API. In the
split topology (ADR 0045) the Dashboard hostname forwards module-shaped names but still refuses
`/api`, `/display`, and `/assets`, and the API hostname still serves no Dashboard page. The
development server falls back to the page for any path with no file.
### The shell's router is host-owned, small, and deliberately not a library
The address bar is the source of truth for which page is open. The shell reads it through a
four-method `HostHistory` (`pathname`, `push`, `replace`, `subscribe`); the browser implementation is
built in the entry point from `window` and handed down, so the entry point remains the only file in
the package that touches a browser global, and every routing behaviour is tested against an in-memory
history whose entries a test can inspect. No state object is ever stored in a history entry — the path
alone is meaningful — so nothing put into history can be read back as authority.
`react-router-dom` is not adopted. Modules are refused it because routing belongs to the host
(ADR 0022, `pnpm boundaries`); the shell's own pages are a handful of host screens plus one mounted
module; and adopting a library now would prejudge how a module's own pages are addressed, which is
a separate decision (see **Deferred**).
### What each path shows
- `/` is the **Overview**. The shell no longer opens the first module for the person: the Overview is
the landing page. Until modules can contribute cards to it, it says plainly that it has nothing to
show yet.
- `/<module-id>` mounts the module whose navigation entry has that id, through the unchanged generic
loader and the unchanged keyed frame, so a stale load still cannot reach a newer page. The server's
navigation is the only authority on what is mountable. An id it does not list — a disabled or
blocked module whose address the server still serves — shows *This page isn't available* and loads
nothing; the page never echoes the address back. This is what makes a disabled module's routes stop
behaving as active routes, with no new server state.
- `/settings…` and `/account…` are the platform's own pages and are never read as module pages. They
have no content yet, so they show *Page not found* for now.
- Everything below `/<module-id>/` belongs to that module's own pages. It is served by the API today
and shows *Page not found* in the shell until the module page contract exists. The shell never
guesses, and never shows a module's first page for an address the module never defined.
- One trailing slash is tolerated and corrected to the canonical spelling in place (`replace`, no new
entry), so what is bookmarked next is the one canonical address.
### Navigation is real links
Every navigation item is an `<a href>` with its canonical address and `aria-current="page"` when it
is open, marked by weight as well as colour. An ordinary click opens the page in place and adds one
history entry; a click with a modifier key or the middle button is left to the browser, so a page can
be opened in a new tab. Clicking the open page's own link adds no entry. The browser's Back and Forward
walk exactly the pages that were open.
### Page changes behave like page loads for assistive technology
After an in-place page change the shell moves focus to the page's `<main>` (named after the page) as
a full load would have; the first page shown after a refresh or sign-in is left alone. The entry point
names the browser tab after the open page ("Overview", the module's display name, or the error page's
title, and "Sign in" while signed out) — never "Ekklesia", in keeping with ADR 0046. The static page's
own `<title>` is unchanged here; it belongs to the branding work.
### Sign-in is unchanged, and still has no return URL
ADR 0034 holds exactly. The sign-in form still replaces the application in place at the current URL,
so a deep link opened while signed out lands on its own page once the person signs in — with no
`return`, `next`, or callback value, no redirect, and no script-driven navigation off the
Dashboard's own paths. The shell only ever moves between its own canonical paths. Emailed
verification and reset links still land on `/` with a query (ADR 0038).
## Alternatives
- **Module pages under a prefix (`/modules/<id>`).** Rejected: it exposes an implementation concept
in every address people see, and makes a module a second-class destination beside Settings. The
collision it avoided is handled instead by reserving the platform's names in Core.
- **Hash routing (`/#/signage`).** Rejected: every address is one page to the server, the proxy, and
any analytics; fragments never reach the server, so no server-side decision about a path is
possible; and DESIGN.md asks for real URLs.
- **Serving the page at any module-shaped root path.** Rejected: `/definitely-not-a-real-module`
would become a Dashboard page, and the server would answer `200` for every mistyped address. It
would remove the installed-module disclosure described above, at the cost of the server no longer
meaning anything by a `200`.
- **Serving only enabled, running modules' pages.** Rejected: a bookmark to a module an
administrator disabled would become a bare `404`, indistinguishable from a typo, instead of the
Dashboard explaining that the module is unavailable.
- **Listing modules in the proxy configuration.** Rejected: every install would need a proxy edit.
- **Reserving more names now** (`login`, `help`, `overview`, …) "just in case". Rejected: a reserved
name refuses a real module, so a name is reserved when the platform gets a route there and not
before.
- **Putting the reserved names in the manifest grammar.** Rejected for now: it is the Ekklesia host's
URL space, not a module property, and would need a public-contract release for no extra safety,
since discovery already refuses the module before it can be installed.
- **`react-router-dom` or another routing library.** Rejected for now; see above.
- **Keeping "open the first module" at `/`.** Rejected: DESIGN.md makes the Overview the landing page,
and an address that silently means "whatever happens to be first" is not a stable address.
## Consequences
- A refresh, a bookmark, and a shared link land on the page that was open; Back and Forward walk the
Dashboard's own pages. Proven at three levels: the shell against an in-memory history; `apps/api`
serving the page at platform pages and installed modules' pages and the sanitized `404` everywhere
else; and the deploy smoke test, through the real proxy, checking platform deep links, unknown
names, a module's address staying served while the module is disabled and disappearing and
returning as it is removed and reinstalled,
and reloading a module's address in a real browser and requiring the same module to mount again.
- **The Dashboard opens on the Overview, not on the first module.** A person who used to land in
Signage now chooses it once from the navigation, and can bookmark `/signage`.
- **A module may not be called `account`, `api`, `assets`, `display`, or `settings`.** No known module
is; one that was would now fail discovery with `MODULE_ID_RESERVED` and must be renamed.
- **Operators with their own proxy must forward module-shaped top-level paths** (the matcher above),
or a refresh on any page other than `/` is the proxy's `404`. The deployment guide says so.
- `@ekklesia/shell` gains a `./dashboard-routes` subpath, and `apps/api` reads it. The surface remains
private; this is the same relationship `./dashboard-assets` already has.
- The navigation landmark is labelled "Dashboard" rather than "Modules", because it now contains the
Overview as well as modules.
## Deferred
Each of these needs its own decision. This ADR reserves the addresses for them and decides none of
them:
- **A module's own pages** — how a module declares the pages that appear under it in the sidebar
(`/signage/playback`, `/signage/timelines`, `/signage/displays`), how it learns and changes its
current sub-path through the module UI SDK, and what an address below `/<module-id>/` means. The
server already serves the page there and the proxies already forward it, so that contract is a
manifest, SDK, and shell change with no further routing or deployment change. It is its own ADR.
- **Return to origin** — how a module or a Settings page links into another part of the Dashboard and
comes back to the exact workflow that sent the person there (DESIGN.md §2.7, §4.8). It must remain
a validated, same-origin route decision, as ADR 0034 requires, and is designed with the module page
contract rather than here.
- The Settings and Account pages themselves, the sidebar redesign, the mobile drawer, Overview cards,
breadcrumbs, and the installation's name in the browser tab.
@@ -0,0 +1,192 @@
# 0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary
- Status: Accepted
- Date: 2026-10-14
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md)
## Context
ADR 0057 gave every Dashboard page a real address, but the shell around those pages was still the
first proof's layout: a top header holding a hard-coded product name and a Sign out button, a fixed
`15rem` column of text buttons, and the transitional device panel above the navigation. It had no
icons, could not collapse, had no account area, and on a phone it was the same two columns squeezed
until the page was unusable.
DESIGN.md §4 fixes what the shell is: a collapsible left sidebar — icons and labels expanded, icons
only collapsed, with accessible names — church identity at the top, the account area anchored at the
bottom, no persistent top bar, and on phones a drawer with the **same** hierarchy (locked decisions
4–8). It left one thing open that this work cannot proceed without: **which icon library**
(§27, "Final free icon library selection"). The maintainer has decided it: Lucide.
## Decision
### Lucide is Ekklesia's icon vocabulary
Lucide (`lucide-react`, ISC-licensed, no runtime dependencies) is the one icon set for first-party
Ekklesia UI: the shell, `@ekklesia/ui` if it ever draws an icon, and first-party modules. Where
Lucide has an appropriate icon, nobody draws their own; a meaning that Lucide genuinely cannot
express is the only reason to design one, and that is a decision to record, not a shortcut.
Icons are **decorative**. Every control that shows one also has a text name — visible, or visually
hidden when the sidebar is collapsed — and every icon is `aria-hidden`. An icon never carries
meaning alone, and its stroke is `currentColor`, so it takes its colour from the design tokens
exactly as text does (ADR 0039). Icons are imported by name, so a bundle contains exactly the icons
it uses.
The shell imports every icon it uses in one file (`surfaces/shell/src/browser/shell-icons.tsx`),
keyed by meaning — `overview`, `module`, `account`, `signOut`, … — so the vocabulary is reviewable
and an icon is chosen once per meaning rather than per call site. `lucide-react` is a dependency of
`@ekklesia/shell` only, pinned exactly (`1.48.0`); no public `@ekklesia/*` package gains it here.
A module that needs icons in its own UI uses `lucide-react` itself, which `pnpm boundaries` already
permits (module `.svg` presentation assets remain rejected).
### Modules have no icon of their own yet
Every module in the navigation is drawn with **one generic module icon** (Lucide `Blocks`). There is
no table mapping known module ids to icons: that would be a second, host-owned answer to a question
the module owns, and every new module would wait on a shell release to look right. A module will
declare its own icon — by Lucide name — through the module navigation contract that also brings its
sub-pages (manifest v9). Nothing in the manifest changes here.
### The shell's layout
On a wide screen (at or above `48em`, which is 768px at the default text size and follows a
person's own text-size setting), the Dashboard is a sidebar and the page:
```text
┌────────────┬──────────────────────────────┐
│ identity ◂│ │
│ Overview │ │
│ GROUP │ the page │
│ Module │ │
│ Module │ │
│ (devices) │ │
│────────────│ │
│ Your account │
└────────────┴──────────────────────────────┘
```
- **Top:** the identity slot and the collapse toggle. The slot is a neutral placeholder (a plain mark
and the product name) and deliberately takes **no inputs**: the branding work (issue #101) owns
what goes there and where it comes from, and replaces this one component rather than undoing a
configuration model invented early. It is not a heading; each page's own title is.
- **Middle:** the navigation, scrolling on its own — the Overview first, then the server's groups and
modules in the server's order (ADR 0027). The shell sorts and filters nothing; a disabled module is
absent because the navigation endpoint does not list it (ADR 0028), and its address still shows
*This page isn't available* (ADR 0057). Below it, while expanded, the transitional device panel
(ADR 0036) until Settings → Displays replaces it.
- **Bottom:** the account control, a product-owned menu button (below).
**Collapsed**, the sidebar is icons only (`4.5rem` rather than `16rem`). Each item keeps its text in
the DOM, visually hidden, as its accessible name, and shows a tooltip on hover **and on keyboard
focus**. The tooltip is a visual duplicate of that name, so it is `aria-hidden` and nothing is
announced twice; it meets WCAG 2.2 §1.4.13 — dismissible with Escape without moving focus, hoverable,
and persistent until focus or the pointer leaves — and is positioned beside the item so a scrolling
sidebar cannot clip it. Group labels stay in the DOM for assistive technology, with a divider
standing in for them visually. The toggle states what it does ("Collapse navigation" / "Expand
navigation") and carries `aria-expanded` and `aria-controls`.
There is **no application top bar**: the page begins at the top of its column.
### Expanded or collapsed is a preference of this browser
The mode is remembered in this browser's `localStorage` under `ekklesia.dashboard.sidebar` (exactly
`expanded` or `collapsed`; anything else is ignored), so it survives a reload. It is a preference of
the device rather than the person: a narrow laptop and a wide desktop reasonably want different
answers, and DESIGN.md's synced personal preferences belong to the account area, which does not exist
yet. Storage can be missing, blocked, or throw; then the mode is remembered for the page only and
nothing breaks.
The shell reads and writes it through a two-method `SidebarPreference` interface built by the entry
point, so a synced or per-person store replaces the implementation without touching a component. The
entry point likewise builds the browser's media query (`HostViewport`) and history (ADR 0057), and
remains the only file in the package that touches `window`.
### The account area
The signed-in person's control at the bottom of the sidebar opens a product-owned menu following the
WAI-ARIA menu button pattern: `aria-haspopup="menu"` and `aria-expanded` on the trigger; opening moves
focus to the first item (ArrowUp opens on the last); arrow keys, Home, and End move between items;
Escape closes it and returns focus to the trigger; Tab or a press outside closes it and leaves focus
where it went. It contains **only actions that work today** — Sign out. Profile, personal preferences,
appearance, and notifications belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages;
until then there is no dead link to the reserved `/account`.
The shell knows the person only as an opaque user id (`/api/v1/principal`, ADR 0034), which is
never displayed, so the control reads "Your account" with a generic avatar rather than a name or
email. Showing the person's name needs a deliberate principal or profile projection, which this
decision does not add.
### Phones: the same navigation in a drawer
Below the breakpoint the sidebar is not rendered. A single menu button ("Open navigation", with
`aria-expanded` and `aria-controls`) sits above the page — the one control there, not a bar — and opens
a drawer containing the **same** identity slot, navigation, current page, device panel, and account
area, always with labels and with large-control touch targets (48px). The drawer is a real modal:
- `role="dialog"`, `aria-modal="true"`, named "Navigation";
- the page behind it is `inert`, and Tab and Shift+Tab are also kept inside it, for a browser that
does not yet honour `inert`;
- opening moves focus to its close button; Escape, the close button, and the backdrop dismiss it and
return focus to the menu button;
- choosing a destination closes it and moves focus to the new page (ADR 0057), including when the
destination is the page already open;
- Back and Forward close it and still work.
### Changing layout never disturbs the page
The page's `<main>` keeps one position in the tree in both layouts, so rotating a tablet or resizing
a window never remounts the open module. The route comes from the URL and the desktop mode from the
preference, so neither is touched by a layout change. Only an open drawer is dropped, and focus that
was inside navigation that has just gone away lands on the page rather than on the document.
### Shell-local, not yet `@ekklesia/ui`
The tooltip, the account menu, and the drawer live in the shell. ADR 0039 adds an element to
`@ekklesia/ui` once two independent consumers need it, and today the shell is the only one; adding
them to the public package would put unproven API in front of every module. Each is written as a
self-contained, generic piece so it can be promoted unchanged when a module needs one. The shell's
own stylesheet (hover, `:focus-visible`, reduced motion) is a token-only, `ek-shell-`-prefixed string
installed into the shell's document, beside `@ekklesia/ui`'s, and cannot reach a module's isolated
root (ADR 0040).
## Alternatives
- **Another icon library** (Heroicons, Phosphor, Tabler, Material Symbols). Rejected by the
maintainer's decision; Lucide's breadth, consistent stroke style, ISC licence, and per-icon imports
fit a calm interface that many modules will extend.
- **Ekklesia's own SVG set.** Rejected: an icon set is a long-term design and maintenance commitment
that buys nothing a mature open set does not already have.
- **A host-owned map of module ids to icons.** Rejected: the module owns its identity, and a map makes
every new module wait on a shell release. The generic icon is honest until modules declare one.
- **A module-manifest `icon` field now.** Deferred to the manifest-v9 navigation contract, so modules
change manifest version once for icons, sub-pages, and the rest of that contract together.
- **Persisting the mode on the server, per person.** Deferred to the account preferences work; the
interface is shaped so that store can replace this one.
- **CSS-only responsive switching.** Rejected: the drawer must be a modal with focus management, which
needs the layout in script, and a media query adapter keeps it testable.
- **A top bar holding the brand and the menu button on phones.** Rejected: DESIGN.md has no persistent
top bar; one button is all the phone layout needs above the page.
- **Native `<dialog>` for the drawer.** Not chosen for now: its focus return and inertness differ
across the browsers a church's volunteers use, and the shell's own handling is explicit and tested.
## Consequences
- The Dashboard has the shell DESIGN.md describes on desktop, tablet, and phone, proven in unit tests
against in-memory history, viewport, and preference, and in the deploy smoke test in a real browser:
at desktop size (the account menu's Sign out) and at phone size (the drawer's focus, Escape, and
choosing a module), and back again.
- Sign out moved from the header into the account menu.
- The hard-coded product name now lives in one placeholder component, waiting for branding; there is
no other person-facing "Ekklesia" in the authenticated shell.
- `@ekklesia/shell` depends on `lucide-react`. No public package changed.
## Deferred
Church branding in the identity slot (issue #101); module-declared icons and module sub-pages in the
sidebar (manifest v9); group icons and the navigation editor; the account pages and synced personal
preferences, including a per-person sidebar mode; showing the person's name or picture; Settings in the
navigation; promoting the tooltip, menu, or drawer to `@ekklesia/ui`.
@@ -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.
+3
View File
@@ -65,3 +65,6 @@ ADRs capture durable choices that affect more than one package or phase.
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
- [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
+10 -2
View File
@@ -343,7 +343,11 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
landing page have since landed, see
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md), and the shell itself is now the
collapsible sidebar, account menu, and phone drawer of
[ADR 0058](decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
@@ -588,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
+13 -1
View File
@@ -233,7 +233,9 @@ password, URL, or stack reaches browser output or logging. The form offers **no
because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038).
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
Sign-out is the account menu's one item, at the bottom of the sidebar or the phone drawer
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). It
posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
During that transition the Dashboard subtree is unmounted, which invokes the generic module
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
@@ -259,6 +261,16 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
Since [ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md) that URL
is a real page address: the API serves the Dashboard page at `/`, at `/settings` and `/account`, and
at `/<module-id>` for every discovered module that declares a Dashboard UI (enabled or not) — each
bare and with everything below it — with the same bytes, validator, and headers as `/`. A deep link
opened while signed out therefore shows the sign-in form at that address and opens its own page once
the person signs in — still with no return value and no redirect. Any other path — a module that is
not installed, a display-only module, a look-alike such as `/settings-old` — remains the sanitized
`404`. Module ids `account`, `api`, `assets`, `display`, and `settings` are reserved for the platform
and refused by discovery (`MODULE_ID_RESERVED`).
### The four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
+4 -1
View File
@@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
schema gained a token column and no identity code hashes or compares a token, that nothing
interpolates a token, link, address, or body into a log line, that a device has no email, password,
or verification concept, and that no browser source imports Better Auth or Core's mail.
or verification concept, that no browser source imports Better Auth or Core's mail, and that the
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
+45 -1
View File
@@ -535,7 +535,12 @@ without executing module code:
}
```
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
Module, capability, and permission identifiers are lowercase and namespaced. A module with a
Dashboard UI is addressed at `/<module-id>` — `/calendar` for this one
([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same
top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a
module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and
package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability
has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
@@ -571,6 +576,17 @@ module's own HTTP routes, and creating relationship-derived grants automatically
own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface.
### Icons in your module's UI
Lucide is Ekklesia's icon vocabulary
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). If your
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
decorative: give every control a text name, visible or visually hidden, and keep the icon
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
module will declare its own navigation icon through the module navigation contract (manifest v9), not
yet.
### Your module's display name
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
@@ -883,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
+191
View File
@@ -0,0 +1,191 @@
import {
type FormEvent,
type ReactElement,
type ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Button } from "./controls.js";
import { Dialog } from "./surfaces.js";
export type ConfirmationTone = "default" | "destructive";
export interface ConfirmationDialogProps {
readonly open: boolean;
/** The question, as the dialog's accessible name: "Delete Foyer-1?" */
readonly title: ReactNode;
/** What will happen, announced with the name: name the resource and the consequence. */
readonly description?: ReactNode;
/** Anything more the person should weigh — whether it can be undone, what else it affects. */
readonly children?: ReactNode;
/** The confirming action, named for what it does: "Delete display", never "OK". */
readonly confirmLabel: string;
readonly cancelLabel?: string;
/**
* `destructive` for an action whose effect matters. It is marked by a symbol and wording as well
* as by colour, and focus starts on Cancel rather than on the action. The consuming feature
* decides the severity; this element never upgrades or downgrades it.
*/
readonly tone?: ConfirmationTone;
/**
* Typed confirmation, for the most serious actions (DESIGN.md §8.2): the exact phrase the person
* must type before Confirm is enabled — `DELETE`, or the resource's own name. Leave it unset for
* every action that does not need it.
*/
readonly requiredPhrase?: string;
/** The label of the typed-confirmation field. Default: "Type <phrase> to confirm". */
readonly requiredPhraseLabel?: ReactNode;
/**
* The confirmed action is in progress: both actions are disabled, Escape does nothing, and the
* dialog is marked busy, so the person cannot leave it half-done. The caller closes it when done.
*/
readonly busy?: boolean;
readonly onConfirm: () => void;
readonly onCancel: () => void;
readonly "data-testid"?: string;
}
/**
* Whether a typed value matches a required phrase.
*
* Exact and case-sensitive, because typing it is meant to be deliberate — `delete` is not `DELETE`.
* Only what a phone keyboard adds without being asked is forgiven: whitespace around the value, and
* a different Unicode composition of the same characters (NFC).
*/
export function matchesRequiredPhrase(value: string, phrase: string): boolean {
return value.trim().normalize("NFC") === phrase.trim().normalize("NFC");
}
/**
* The product-owned confirmation every Ekklesia surface uses instead of `window.confirm` (DESIGN.md
* §8, ADR 0059).
*
* Three severities from one element, chosen by the caller: a simple confirmation; a destructive one;
* and a destructive one that also requires a typed phrase. It is a {@link Dialog}: modal, named by
* its title and described by its description, with focus trapped inside and returned to the control
* that opened it. Cancel is always present and always safe. Enter confirms only once a required
* phrase matches.
*/
export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement | null {
const tone = props.tone ?? "default";
const busy = props.busy ?? false;
const phraseId = useId();
const phraseHintId = useId();
const [typed, setTyped] = useState("");
const phraseRef = useRef<HTMLInputElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const requiresPhrase = props.requiredPhrase !== undefined;
const phraseMatches =
props.requiredPhrase === undefined || matchesRequiredPhrase(typed, props.requiredPhrase);
const canConfirm = phraseMatches && !busy;
// A phrase typed for one resource must never carry over to the next time the dialog opens.
useEffect(() => {
if (!props.open) {
setTyped("");
}
}, [props.open]);
// A required phrase starts at its field; a destructive action starts on the safe choice; a simple
// confirmation starts on the action itself.
const initialFocusRef = requiresPhrase
? phraseRef
: tone === "destructive"
? cancelRef
: confirmRef;
const cancel = (): void => {
if (busy) {
return;
}
setTyped("");
props.onCancel();
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (canConfirm) {
props.onConfirm();
}
};
return (
<Dialog
{...(tone === "destructive" ? { className: "ek-confirmation--destructive" } : {})}
{...(props["data-testid"] === undefined ? {} : { "data-testid": props["data-testid"] })}
{...(props.description === undefined ? {} : { description: props.description })}
dismissible={!busy}
initialFocusRef={initialFocusRef}
onDismiss={cancel}
open={props.open}
title={
tone === "destructive" ? (
<>
<span aria-hidden="true" className="ek-confirmation__symbol" />
{props.title}
</>
) : (
props.title
)
}
>
<form aria-busy={busy || undefined} className="ek-confirmation" onSubmit={submit}>
{props.children}
{props.requiredPhrase === undefined ? undefined : (
<div className="ek-field">
<label className="ek-field__label" htmlFor={phraseId}>
{props.requiredPhraseLabel ?? (
<>
Type <strong className="ek-confirmation__phrase">{props.requiredPhrase}</strong>{" "}
to confirm
</>
)}
</label>
<input
aria-describedby={phraseHintId}
aria-invalid={typed.length > 0 && !phraseMatches ? true : undefined}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="ek-input ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
ref={phraseRef}
spellCheck={false}
type="text"
value={typed}
/>
<span aria-live="polite" className="ek-field__hint" id={phraseHintId}>
{phraseMatches
? "Matches. You can confirm."
: typed.length === 0
? "Confirm stays unavailable until this matches exactly."
: "Does not match yet. It must match exactly, including capitals."}
</span>
</div>
)}
<div className="ek-dialog__actions">
<Button disabled={busy} onClick={cancel} ref={cancelRef}>
{props.cancelLabel ?? "Cancel"}
</Button>
<Button
aria-describedby={requiresPhrase && !phraseMatches ? phraseHintId : undefined}
disabled={!canConfirm}
ref={confirmRef}
type="submit"
variant={tone === "destructive" ? "danger" : "primary"}
>
{tone === "destructive" ? (
<span aria-hidden="true" className="ek-confirmation__symbol" />
) : undefined}
{props.confirmLabel}
</Button>
</div>
</form>
</Dialog>
);
}
+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>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { type KeyboardEvent, type RefObject, useCallback, useEffect, useRef } from "react";
/** Everything a keyboard can land on. Disabled controls and `tabindex="-1"` are skipped. */
const FOCUSABLE = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
/** The focused element as seen from the container's own root: its shadow root inside an isolated
* module artifact (ADR 0040), where the document would only report the shadow host, or the document. */
function activeWithin(container: HTMLElement): Element | null {
const root = container.getRootNode() as Document | ShadowRoot;
return root.activeElement ?? null;
}
function focusableWithin(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(element) => !element.closest("[inert]"),
);
}
export interface FocusTrapOptions {
/** Where focus starts when the trap activates. Without one, the container itself. */
readonly initialFocus?: RefObject<HTMLElement | null>;
}
/**
* Keeps keyboard focus inside a modal surface while it is open, and puts it back afterwards.
*
* - On activation, remembers what had focus and moves focus to `initialFocus` (or the container).
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
* first wraps to the last, so focus cannot walk out into the page behind.
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
* the surface — if that control is still in the document.
*
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
* as well as in a document: it only ever looks inside its own container and at the container's own
* root node, never at a global. This is the one focus trap ADR 0039 deferred until the platform needed
* it (ADR 0059); every modal surface in `@ekklesia/ui` uses it.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
options: FocusTrapOptions = {},
): (event: KeyboardEvent<HTMLElement>) => void {
const { initialFocus } = options;
const restoreTo = useRef<HTMLElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!active || !container) {
return;
}
const previous = activeWithin(container);
restoreTo.current =
previous && previous !== container.ownerDocument.body && "focus" in previous
? (previous as HTMLElement)
: null;
(initialFocus?.current ?? container).focus();
return () => {
const target = restoreTo.current;
restoreTo.current = null;
if (target?.isConnected) {
target.focus();
}
};
}, [active, containerRef, initialFocus]);
return useCallback(
(event: KeyboardEvent<HTMLElement>) => {
const container = containerRef.current;
if (!active || event.key !== "Tab" || !container) {
return;
}
const focusable = focusableWithin(container);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
const current = activeWithin(container);
const outside = !(current instanceof Node) || !container.contains(current);
if (event.shiftKey && (current === first || current === container || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
},
[active, containerRef],
);
}
+18
View File
@@ -43,6 +43,11 @@ export {
type TableRowProps,
type TableSectionProps,
} from "./data.js";
export {
ConfirmationDialog,
type ConfirmationDialogProps,
type ConfirmationTone,
} from "./confirmation.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export {
Badge,
@@ -56,6 +61,10 @@ export {
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Skeleton,
SkeletonGroup,
type SkeletonGroupProps,
type SkeletonProps,
Toast,
type ToastProps,
ToastStack,
@@ -75,8 +84,17 @@ export {
Panel,
type PanelProps,
PageHeader,
type PageHeaderBack,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
} from "./surfaces.js";
export {
type ToastApi,
type ToastOptions,
ToastProvider,
type ToastProviderProps,
type ToastTone,
useToasts,
} from "./toasts.js";
+172 -1
View File
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
width: ${designToken("spacing.xl")};
}
.ek-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-dialog__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-confirmation {
display: grid;
gap: ${designToken("spacing.lg")};
margin: 0;
}
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
.ek-confirmation--destructive {
border-color: ${designToken("status.danger-border")};
border-width: calc(${designToken("border.width")} * 2);
}
.ek-confirmation__symbol::before {
content: ${designToken("status.warning-symbol")};
font-weight: ${designToken("typography.weight-bold")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-confirmation__phrase {
font-family: ${designToken("typography.family-mono")};
white-space: pre-wrap;
}
.ek-toast-region {
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
left: 50%;
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
pointer-events: none;
position: fixed;
top: ${designToken("spacing.lg")};
transform: translateX(-50%);
width: 28rem;
z-index: 50;
}
.ek-toast-region__list {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-toast-region .ek-toast {
animation: ek-toast-enter 160ms ease-out;
pointer-events: auto;
}
.ek-toast {
align-items: start;
display: flex;
gap: ${designToken("spacing.sm")};
}
.ek-toast__content {
display: grid;
flex: 1 1 auto;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-toast__dismiss {
flex: 0 0 auto;
min-width: ${designToken("control.height-sm")};
}
.ek-message__actions {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
justify-content: center;
}
.ek-message__details {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
max-width: 100%;
text-align: start;
width: 100%;
}
.ek-message__details summary {
cursor: pointer;
min-height: ${designToken("control.height-sm")};
padding-block: ${designToken("spacing.2xs")};
}
.ek-message__details-text {
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
margin: ${designToken("spacing.xs")} 0 0;
max-height: 16rem;
overflow: auto;
padding: ${designToken("spacing.sm")};
white-space: pre-wrap;
word-break: break-word;
}
.ek-message__reference {
margin: ${designToken("spacing.xs")} 0 0;
}
.ek-page-header__back {
justify-self: start;
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
text-decoration: none;
}
.ek-skeleton-group {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-skeleton {
animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
display: block;
}
.ek-skeleton--text {
height: 1em;
width: 100%;
}
.ek-skeleton--block {
height: 6rem;
width: 100%;
}
.ek-skeleton--circle {
border-radius: ${designToken("radius.pill")};
height: 2.5rem;
width: 2.5rem;
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ek-skeleton-pulse {
50% {
opacity: 0.55;
}
}
@keyframes ek-toast-enter {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
}
/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
them depends on movement to be understood. */
@media (prefers-reduced-motion: reduce) {
.ek-spinner {
.ek-spinner,
.ek-skeleton,
.ek-toast-region .ek-toast {
animation: none;
}
}
+126 -23
View File
@@ -1,29 +1,101 @@
import {
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
useEffect,
type RefObject,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
import { useFocusTrap } from "./focus-trap.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
/**
* A page's Back action. The caller decides where Back goes: a host that knows its own routes
* validates any return target before handing it over (ADR 0059); this element only renders it.
*/
export interface PageHeaderBack {
/** Visible and accessible. Name the destination where it helps: "Back to Signage". */
readonly label: string;
/** When Back is a real place, its address: rendered as a link, so it can be opened in a new tab
* and is announced as a link. Without one, Back is a button. */
readonly href?: string;
/** Called on an ordinary activation. A click with a modifier key or the middle button on a link
* is left to the browser. */
readonly onBack: () => void;
}
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
/** The Back action of a nested page (DESIGN.md §4.8): Back plus the page title is the baseline;
* breadcrumbs are not part of it. */
readonly back?: PageHeaderBack;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
function BackAction(props: { readonly back: PageHeaderBack }): ReactElement {
const { back } = props;
const content = (
<>
<span aria-hidden="true" className="ek-page-header__back-symbol">
←
</span>
{back.label}
</>
);
const className = "ek-button ek-button--quiet ek-button--sm ek-focusable ek-page-header__back";
if (back.href !== undefined) {
return (
<a
className={className}
href={back.href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
back.onBack();
}}
>
{content}
</a>
);
}
return (
<button className={className} onClick={back.onBack} type="button">
{content}
</button>
);
}
/**
* The consistent top-level identity and primary-action region for an Ekklesia screen.
*
* The title is the page's one `h1`; a section within the page is a `Panel` with its own `h2`. A
* nested page puts its Back action above the title, and page-level actions sit opposite it.
*/
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.back === undefined ? undefined : <BackAction back={props.back} />}
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
export interface DialogProps {
readonly open: boolean;
/** The dialog's accessible name, rendered as its heading. */
readonly title: ReactNode;
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
readonly description?: ReactNode;
readonly onDismiss: () => void;
/**
* Whether Escape dismisses the dialog. Default `true`. A caller turns it off while something is
* in progress that dismissing would leave half-done.
*/
readonly dismissible?: boolean;
/** Where focus starts. Without one, the dialog itself, so its name and description are read. */
readonly initialFocusRef?: RefObject<HTMLElement | null>;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
readonly "data-testid"?: string;
}
/**
@@ -112,43 +195,58 @@ export interface DialogProps {
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered, and it
* takes every pointer event, so nothing behind the dialog can be clicked.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
* caller says that is not safe right now. Escape is handled on the dialog itself and goes no
* further, so a drawer or menu the dialog was opened from does not close with it. Assistive
* technology is told it is modal (`aria-modal`), named by its title, and described by its
* description.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
const dismissible = props.dismissible ?? true;
const trapKeyDown = useFocusTrap(dialogRef, open, {
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
});
if (!open) {
return null;
}
return (
<div className="ek-dialog__scrim">
<div
className="ek-dialog__scrim"
// The scrim is the barrier between the dialog and the page: a press on it reaches nothing
// behind it and does not dismiss the dialog, which only ever closes deliberately.
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
}
}}
>
<div
aria-describedby={props.description === undefined ? undefined : descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
data-testid={props["data-testid"]}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (dismissible) {
onDismiss();
}
return;
}
trapKeyDown(event);
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.description === undefined ? undefined : (
<div className="ek-dialog__description" id={descriptionId}>
{props.description}
</div>
)}
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
+343
View File
@@ -0,0 +1,343 @@
import {
createContext,
type FocusEvent,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Toast } from "./feedback.js";
/** The four severities a toast can carry. `danger` is an error. */
export type ToastTone = "success" | "info" | "warning" | "danger";
export interface ToastOptions {
readonly tone?: ToastTone;
/** What happened, in a few words. Also names the toast's dismiss control. */
readonly title: string;
readonly description?: string;
/**
* How long it stays, in milliseconds, or `"persistent"` to stay until dismissed. Defaults by
* severity: an error stays until dismissed; anything else lasts long enough to read, and never
* less than three seconds (DESIGN.md §7.4).
*/
readonly duration?: number | "persistent";
}
export interface ToastApi {
/** Shows a toast and returns its id. An identical toast already showing is refreshed instead. */
show(options: ToastOptions): string;
dismiss(id: string): void;
}
/** DESIGN.md §7.4's floor: no toast disappears in under about three seconds. */
export const TOAST_MINIMUM_DURATION_MS = 3000;
const DEFAULT_DURATION_MS: Readonly<Record<Exclude<ToastTone, "danger">, number>> = Object.freeze({
info: 4000,
success: 4000,
warning: 6000,
});
const READING_MS_PER_CHARACTER = 50;
const LONGEST_TIMED_DURATION_MS = 12_000;
const DEFAULT_MAX_VISIBLE = 3;
/**
* How long a toast stays: its requested or default duration, but never less than it takes to read.
* Longer messages stay longer, up to a ceiling; an error stays until dismissed unless its caller
* deliberately gives it a duration (a routine validation error, say).
*/
export function toastDuration(options: ToastOptions): number | "persistent" {
const tone = options.tone ?? "info";
const requested =
options.duration ?? (tone === "danger" ? "persistent" : DEFAULT_DURATION_MS[tone]);
if (requested === "persistent") {
return "persistent";
}
const characters = options.title.length + (options.description?.length ?? 0);
const readingTime = Math.min(
LONGEST_TIMED_DURATION_MS,
TOAST_MINIMUM_DURATION_MS + characters * READING_MS_PER_CHARACTER,
);
return Math.max(TOAST_MINIMUM_DURATION_MS, requested, readingTime);
}
interface ToastRecord {
readonly id: string;
readonly tone: ToastTone;
readonly title: string;
readonly description?: string;
readonly duration: number | "persistent";
readonly key: string;
}
/** A running or paused countdown. `remaining` is what is left when it was last started. */
interface ToastTimer {
handle: ReturnType<typeof setTimeout> | undefined;
remaining: number;
started: number;
}
const ToastContext = createContext<ToastApi | null>(null);
/**
* The toast API of the nearest {@link ToastProvider}. Throws outside one, because a toast shown
* nowhere is a silent failure.
*/
export function useToasts(): ToastApi {
const api = useContext(ToastContext);
if (!api) {
throw new Error("useToasts must be used inside a ToastProvider.");
}
return api;
}
export interface ToastProviderProps {
readonly children?: ReactNode;
/** Names the notification region for assistive technology. */
readonly label?: string;
/** How many non-error toasts show at once. Errors are never displaced to make room. */
readonly maxVisible?: number;
}
/**
* The toast system of one React tree (ADR 0059): a top-center region and the queue behind it.
*
* **Per tree, never a package singleton.** A module mounts in its own isolated root (ADR 0040) with
* its own React tree; a module-level store shared through this package would be mutable state
* reaching across every artifact on the page. Each provider owns its toasts, its timers, and its
* region, and is torn down with its tree — every timer with it.
*
* **Calm under load.** At most `maxVisible` routine toasts show; the oldest routine one makes room.
* An error is never displaced by a routine toast, and stays until dismissed by default. Showing a
* toast identical to one already visible refreshes it rather than stacking a copy. Newest is on top.
*
* **Readable and accessible.** Timers pause while the pointer is over the region or focus is inside
* it (WCAG 2.2 §2.2.1). The region's two live regions are mounted before any toast arrives, so
* assistive technology announces what is added: routine toasts politely, errors assertively. Every
* toast has a dismiss control named for it, and Escape on a focused toast dismisses it; when the
* last one goes, focus returns to where it was before it entered the region.
*/
export function ToastProvider(props: ToastProviderProps): ReactElement {
const maxVisible = Math.max(1, props.maxVisible ?? DEFAULT_MAX_VISIBLE);
const [toasts, setToasts] = useState<readonly ToastRecord[]>([]);
// The synchronous source of truth: `show` must know at once whether a toast is a duplicate and
// what its id is, which a state updater (run later, during render) cannot tell it.
const current = useRef<readonly ToastRecord[]>([]);
const commit = useCallback((next: readonly ToastRecord[]) => {
current.current = next;
setToasts(next);
}, []);
const timers = useRef(new Map<string, ToastTimer>());
const paused = useRef(false);
const nextId = useRef(0);
const focusBeforeRegion = useRef<HTMLElement | null>(null);
const regionRef = useRef<HTMLDivElement | null>(null);
const clearTimer = useCallback((id: string) => {
const timer = timers.current.get(id);
if (timer?.handle !== undefined) {
clearTimeout(timer.handle);
}
timers.current.delete(id);
}, []);
const dismiss = useCallback(
(id: string) => {
clearTimer(id);
if (current.current.some((toast) => toast.id === id)) {
commit(current.current.filter((toast) => toast.id !== id));
}
},
[clearTimer, commit],
);
const startTimer = useCallback(
(id: string, remaining: number) => {
const existing = timers.current.get(id);
if (existing?.handle !== undefined) {
clearTimeout(existing.handle);
}
const timer: ToastTimer = { handle: undefined, remaining, started: Date.now() };
if (!paused.current) {
timer.handle = setTimeout(() => dismiss(id), remaining);
}
timers.current.set(id, timer);
},
[dismiss],
);
const show = useCallback(
(options: ToastOptions): string => {
const tone = options.tone ?? "info";
const duration = toastDuration(options);
const key = JSON.stringify([tone, options.title, options.description ?? ""]);
const duplicate = current.current.find((toast) => toast.key === key);
if (duplicate) {
// Showing it again restarts its time rather than stacking a copy.
if (duplicate.duration !== "persistent") {
startTimer(duplicate.id, duplicate.duration);
}
return duplicate.id;
}
nextId.current += 1;
const id = `ek-toast-${nextId.current}`;
const record: ToastRecord = {
duration,
id,
key,
title: options.title,
tone,
...(options.description === undefined ? {} : { description: options.description }),
};
const next = [record, ...current.current];
const routine = next.filter((toast) => toast.tone !== "danger");
const overflow = new Set(routine.slice(maxVisible).map((toast) => toast.id));
for (const removed of overflow) {
clearTimer(removed);
}
commit(next.filter((toast) => !overflow.has(toast.id)));
if (duration !== "persistent") {
startTimer(id, duration);
}
return id;
},
[clearTimer, commit, maxVisible, startTimer],
);
const pause = useCallback(() => {
if (paused.current) {
return;
}
paused.current = true;
const now = Date.now();
for (const timer of timers.current.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
timer.handle = undefined;
timer.remaining = Math.max(0, timer.remaining - (now - timer.started));
}
}
}, []);
const resume = useCallback(() => {
if (!paused.current) {
return;
}
paused.current = false;
for (const [id, timer] of [...timers.current.entries()]) {
startTimer(id, timer.remaining);
}
}, [startTimer]);
// Every timer dies with the provider.
useEffect(() => {
const active = timers.current;
return () => {
for (const timer of active.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
}
}
active.clear();
};
}, []);
const dismissFromRegion = (id: string): void => {
const remaining = current.current.filter((toast) => toast.id !== id);
dismiss(id);
const region = regionRef.current;
const focusWasInside =
region !== null &&
(region.getRootNode() as Document | ShadowRoot).activeElement !== null &&
region.contains((region.getRootNode() as Document | ShadowRoot).activeElement);
if (!focusWasInside) {
return;
}
// Keep a keyboard user in the region while toasts remain; otherwise return them to where they
// were before they entered it, rather than to nowhere.
if (remaining.length > 0) {
queueMicrotask(() => {
region.querySelector<HTMLElement>(".ek-toast__dismiss")?.focus();
});
} else {
const target = focusBeforeRegion.current;
focusBeforeRegion.current = null;
resume();
if (target?.isConnected) {
target.focus();
}
}
};
const api = useMemo<ToastApi>(() => ({ dismiss, show }), [dismiss, show]);
const routine = toasts.filter((toast) => toast.tone !== "danger");
const errors = toasts.filter((toast) => toast.tone === "danger");
const renderToast = (toast: ToastRecord): ReactElement => (
<Toast
announce={false}
data-toast-id={toast.id}
dismissLabel={`Dismiss notification: ${toast.title}`}
key={toast.id}
onDismiss={() => dismissFromRegion(toast.id)}
title={toast.title}
tone={toast.tone}
>
{toast.description}
</Toast>
);
return (
<ToastContext.Provider value={api}>
{props.children}
<section
aria-label={props.label ?? "Notifications"}
className="ek-toast-region"
onBlur={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
resume();
}
}}
onFocus={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
focusBeforeRegion.current =
event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
}
pause();
}}
onKeyDown={(event) => {
if (event.key !== "Escape") {
return;
}
const owner = (event.target as HTMLElement).closest<HTMLElement>("[data-toast-id]");
const id = owner?.dataset.toastId;
if (id) {
event.stopPropagation();
dismissFromRegion(id);
}
}}
onMouseEnter={pause}
onMouseLeave={() => {
const region = regionRef.current;
const root = region?.getRootNode() as Document | ShadowRoot | undefined;
if (!region || !root?.activeElement || !region.contains(root.activeElement)) {
resume();
}
}}
ref={regionRef}
>
{/* Errors are announced assertively and listed first, so nothing routine can hide one. */}
<div aria-live="assertive" className="ek-toast-region__list" role="alert">
{errors.map(renderToast)}
</div>
<div aria-live="polite" className="ek-toast-region__list" role="status">
{routine.map(renderToast)}
</div>
</section>
</ToastContext.Provider>
);
}
+2 -1
View File
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
+797
View File
@@ -0,0 +1,797 @@
import { act, type ReactElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matchesRequiredPhrase } from "../src/confirmation.js";
import { ERROR_DETAILS_MAX_LENGTH, errorDetailsText } from "../src/feedback.js";
import {
ConfirmationDialog,
type ConfirmationDialogProps,
Dialog,
ErrorState,
PageHeader,
Skeleton,
SkeletonGroup,
type ToastApi,
ToastProvider,
useToasts,
} from "../src/index.js";
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
import { TOAST_MINIMUM_DURATION_MS, toastDuration } from "../src/toasts.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function render(node: ReactElement): void {
act(() => root.render(node));
}
function byTestId(id: string): HTMLElement {
const element = container.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!element) {
throw new Error(`missing ${id}`);
}
return element;
}
function dialog(): HTMLElement {
const element = container.querySelector<HTMLElement>('[role="dialog"]');
if (!element) {
throw new Error("no dialog");
}
return element;
}
function buttonNamed(name: string, scope: ParentNode = container): HTMLButtonElement {
const button = [...scope.querySelectorAll("button")].find(
(candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent) === name,
);
if (!button) {
throw new Error(`no button ${name}`);
}
return button;
}
function press(element: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
act(() => {
element.dispatchEvent(event);
});
return event;
}
function type(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
/** A page with a control that opens a confirmation, so opening, focus return, and the background
* can all be observed. */
function ConfirmHarness(props: {
readonly onConfirm?: () => void;
readonly onCancel?: () => void;
readonly dialog?: Partial<ConfirmationDialogProps>;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div>
<button data-testid="background" type="button">
Something behind
</button>
<button data-testid="opener" onClick={() => setOpen(true)} type="button">
Delete display
</button>
<ConfirmationDialog
confirmLabel="Delete display"
description="Foyer-1 stops showing content and must be paired again to come back."
onCancel={() => {
props.onCancel?.();
setOpen(false);
}}
onConfirm={() => {
props.onConfirm?.();
setOpen(false);
}}
open={open}
title="Delete Foyer-1?"
{...props.dialog}
/>
</div>
);
}
function openHarness(options: Parameters<typeof ConfirmHarness>[0] = {}): void {
render(<ConfirmHarness {...options} />);
const opener = byTestId("opener");
act(() => opener.focus());
act(() => opener.click());
}
describe("ConfirmationDialog", () => {
it("is closed until asked, and opens as a named, described modal dialog", () => {
render(<ConfirmHarness />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
const element = dialog();
expect(element.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${element.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete Foyer-1?");
expect(
container.querySelector(`#${element.getAttribute("aria-describedby")}`)?.textContent,
).toBe("Foyer-1 stops showing content and must be paired again to come back.");
});
it("confirms with its named action, and cancels with Cancel, without the other firing", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
openHarness({ onCancel, onConfirm });
act(() => buttonNamed("Cancel", dialog()).click());
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
act(() => buttonNamed("Delete display", dialog()).click());
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("starts a simple confirmation on its action, ready for Enter", () => {
openHarness();
expect(document.activeElement?.textContent).toBe("Delete display");
expect(document.activeElement?.className).toContain("ek-button--primary");
});
it("marks a destructive action by symbol and frame as well as colour, and starts on Cancel", () => {
openHarness({ dialog: { tone: "destructive" } });
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
);
expect(confirm?.className).toContain("ek-button--danger");
expect(confirm?.querySelector(".ek-confirmation__symbol")?.getAttribute("aria-hidden")).toBe(
"true",
);
expect(dialog().className).toContain("ek-confirmation--destructive");
expect(dialog().querySelector("h2 .ek-confirmation__symbol")).not.toBeNull();
// The safe choice has focus, so a stray Enter cancels rather than destroys.
expect(document.activeElement?.textContent).toBe("Cancel");
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-confirmation__symbol::before");
});
it("requires the exact phrase before a typed confirmation can be confirmed", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "Foyer-1", tone: "destructive" }, onConfirm });
const input = dialog().querySelector("input") as HTMLInputElement;
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
) as HTMLButtonElement;
// Focus starts at the field, which is labelled with the phrase it wants.
expect(document.activeElement).toBe(input);
expect(input.labels?.[0]?.textContent).toBe("Type Foyer-1 to confirm");
expect(input.getAttribute("autocapitalize")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
expect(confirm.disabled).toBe(true);
type(input, "foyer-1");
expect(confirm.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).not.toHaveBeenCalled();
type(input, " Foyer-1 ");
expect(confirm.disabled).toBe(false);
expect(input.getAttribute("aria-invalid")).toBeNull();
// Enter in the field submits once it matches.
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("forgets a typed phrase when it closes", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
type(dialog().querySelector("input") as HTMLInputElement, "DELETE");
act(() => buttonNamed("Cancel", dialog()).click());
act(() => byTestId("opener").click());
expect((dialog().querySelector("input") as HTMLInputElement).value).toBe("");
});
it("matches a phrase exactly, forgiving only surrounding whitespace and Unicode composition", () => {
expect(matchesRequiredPhrase("DELETE", "DELETE")).toBe(true);
expect(matchesRequiredPhrase(" DELETE\n", "DELETE")).toBe(true);
expect(matchesRequiredPhrase("delete", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELET", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELETE DELETE", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("Café", "Café")).toBe(true);
});
it("closes on Escape and returns focus to the control that opened it", () => {
const onCancel = vi.fn();
openHarness({ onCancel });
const event = press(dialog(), "Escape");
expect(event.defaultPrevented).toBe(true);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(byTestId("opener"));
});
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
const onCancel = vi.fn();
openHarness({ dialog: { busy: true }, onCancel });
press(dialog(), "Escape");
expect(onCancel).not.toHaveBeenCalled();
expect(buttonNamed("Cancel", dialog()).disabled).toBe(true);
expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true");
});
it("keeps Tab and Shift+Tab inside the dialog", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
const input = dialog().querySelector("input") as HTMLInputElement;
type(input, "DELETE");
const cancel = buttonNamed("Cancel", dialog());
const confirm = buttonNamed("Delete display", dialog());
act(() => confirm.focus());
expect(press(confirm, "Tab").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
expect(press(input, "Tab", { shiftKey: true }).defaultPrevented).toBe(true);
expect(document.activeElement).toBe(confirm);
// Between inner controls, the browser moves focus itself.
act(() => input.focus());
expect(press(input, "Tab").defaultPrevented).toBe(false);
expect(cancel).toBeTruthy();
});
it("blocks the page behind: the scrim takes the pointer and nothing behind is in the tab loop", () => {
openHarness();
const scrim = dialog().parentElement as HTMLElement;
expect(scrim.className).toBe("ek-dialog__scrim");
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
act(() => {
scrim.dispatchEvent(pointer);
});
expect(pointer.defaultPrevented).toBe(true);
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(dialog().contains(byTestId("background"))).toBe(false);
// Tab from the last control wraps inside rather than reaching "Something behind".
const last = buttonNamed("Delete display", dialog());
act(() => last.focus());
press(last, "Tab");
expect(document.activeElement).not.toBe(byTestId("background"));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("stops Escape at the dialog, so a drawer or menu around it stays open", () => {
const outer = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: a test stand-in for a drawer that closes on Escape.
<div
onKeyDown={(event) => {
if (event.key === "Escape") {
outer();
}
}}
>
<ConfirmHarness />
</div>,
);
act(() => byTestId("opener").click());
press(dialog(), "Escape");
expect(outer).not.toHaveBeenCalled();
});
it("is operable with the keyboard alone", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "DELETE" }, onConfirm });
const input = document.activeElement as HTMLInputElement;
type(input, "DELETE");
act(() => {
(input.form as HTMLFormElement).requestSubmit();
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
describe("Dialog", () => {
it("traps focus, describes itself, and returns focus when it closes", () => {
function Harness(): ReactElement {
const [open, setOpen] = useState(false);
return (
<>
<button data-testid="open" onClick={() => setOpen(true)} type="button">
Open
</button>
<Dialog
actions={
<button onClick={() => setOpen(false)} type="button">
Done
</button>
}
description="Details about the dialog."
onDismiss={() => setOpen(false)}
open={open}
title="Information"
/>
</>
);
}
render(<Harness />);
act(() => byTestId("open").focus());
act(() => byTestId("open").click());
expect(document.activeElement).toBe(dialog());
expect(
container.querySelector(`#${dialog().getAttribute("aria-describedby")}`)?.textContent,
).toBe("Details about the dialog.");
const done = buttonNamed("Done");
act(() => done.focus());
press(done, "Tab");
expect(document.activeElement).toBe(done);
act(() => done.click());
expect(document.activeElement).toBe(byTestId("open"));
});
it("ignores Escape when the caller says dismissing is not safe", () => {
const onDismiss = vi.fn();
render(<Dialog dismissible={false} onDismiss={onDismiss} open title="Saving" />);
press(dialog(), "Escape");
expect(onDismiss).not.toHaveBeenCalled();
});
});
function ToastHarness(props: { readonly onApi: (api: ToastApi) => void }): null {
props.onApi(useToasts());
return null;
}
function renderToasts(options: { readonly maxVisible?: number } = {}): ToastApi {
let api: ToastApi | undefined;
render(
<ToastProvider {...options}>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
if (!api) {
throw new Error("no toast api");
}
return api;
}
function toastTitles(): string[] {
return [...container.querySelectorAll(".ek-toast .ek-toast__title")].map(
(title) => title.textContent?.replace(/^\S+\s/u, "") ?? "",
);
}
describe("toasts", () => {
it("renders a top-center region whose live regions exist before any toast arrives", () => {
renderToasts();
const region = container.querySelector(".ek-toast-region");
expect(region?.tagName).toBe("SECTION");
expect(region?.getAttribute("aria-label")).toBe("Notifications");
expect(region?.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull();
expect(region?.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull();
expect(EKKLESIA_UI_STYLESHEET).toMatch(/\.ek-toast-region \{[^}]*position: fixed;[^}]*top:/su);
});
it("shows each severity with its symbol, errors assertively and the rest politely", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
api.show({ title: "Heads up", tone: "info" });
api.show({ title: "Storage is nearly full", tone: "warning" });
api.show({ title: "The display could not be revoked", tone: "danger" });
});
const polite = container.querySelector('[aria-live="polite"]') as HTMLElement;
const assertive = container.querySelector('[aria-live="assertive"]') as HTMLElement;
expect(assertive.textContent).toContain("The display could not be revoked");
expect(polite.textContent).not.toContain("could not be revoked");
for (const tone of ["success", "info", "warning", "danger"]) {
const toast = container.querySelector(`.ek-toast--${tone}`);
expect(toast, tone).not.toBeNull();
// Its own role would nest one live region in another.
expect(toast?.getAttribute("role"), tone).toBeNull();
expect(toast?.querySelector('[aria-hidden="true"]')?.textContent?.trim(), tone).not.toBe("");
}
});
it("dismisses a routine toast on its own once there has been time to read it", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
act(() => {
vi.advanceTimersByTime(TOAST_MINIMUM_DURATION_MS - 1);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(toastTitles()).toEqual([]);
});
it("never times a toast out in under three seconds, and gives longer messages longer", () => {
const tooShort = toastDuration({ duration: 500, title: "Saved" }) as number;
expect(tooShort).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(tooShort).toBeLessThan(TOAST_MINIMUM_DURATION_MS + 1000);
const short = toastDuration({ title: "Saved", tone: "success" }) as number;
const long = toastDuration({
description:
"The timeline was published to every screen in the Sanctuary and Foyer groups, and the next slide starts in a minute.",
title: "Published",
tone: "success",
}) as number;
expect(short).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(long).toBeGreaterThan(short);
expect(toastDuration({ title: "Careful", tone: "warning" })).toBeGreaterThan(short);
});
it("keeps an error until it is dismissed, unless its caller gives it a duration", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
api.show({ duration: 4000, title: "Enter a name", tone: "danger" });
});
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(toastTitles()).toEqual(["The display could not be revoked"]);
expect(toastDuration({ title: "x", tone: "danger" })).toBe("persistent");
});
it("dismisses on its named dismiss control", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
});
act(() => buttonNamed("Dismiss notification: The display could not be revoked").click());
expect(toastTitles()).toEqual([]);
});
it("dismisses programmatically by the id it returned", () => {
vi.useFakeTimers();
const api = renderToasts();
let id = "";
act(() => {
id = api.show({ title: "Uploading", tone: "info" });
});
expect(id).toMatch(/^ek-toast-/u);
act(() => api.dismiss(id));
expect(toastTitles()).toEqual([]);
});
it("dismisses the focused toast on Escape and returns focus where it was", () => {
vi.useFakeTimers();
let api: ToastApi | undefined;
render(
<ToastProvider>
<button data-testid="work" type="button">
Work
</button>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
act(() => {
api?.show({ title: "Saved", tone: "success" });
});
const work = byTestId("work");
act(() => work.focus());
const dismiss = buttonNamed("Dismiss notification: Saved");
act(() => {
dismiss.dispatchEvent(new FocusEvent("focusin", { bubbles: true, relatedTarget: work }));
dismiss.focus();
});
press(dismiss, "Escape");
expect(toastTitles()).toEqual([]);
expect(document.activeElement).toBe(work);
});
it("stacks newest first, refreshes a duplicate, and never lets routine toasts displace an error", () => {
vi.useFakeTimers();
const api = renderToasts({ maxVisible: 2 });
act(() => {
api.show({ title: "Storage is unavailable", tone: "danger" });
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "info" });
api.show({ title: "Three", tone: "info" });
api.show({ title: "Three", tone: "info" });
});
// Errors are listed first; then routine toasts, newest first, at most two of them.
expect(toastTitles()).toEqual(["Storage is unavailable", "Three", "Two"]);
expect(container.querySelectorAll(".ek-toast")).toHaveLength(3);
});
it("pauses while the pointer is over it, so a toast being read does not vanish", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
const region = container.querySelector(".ek-toast-region") as HTMLElement;
act(() => {
region.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
region.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual([]);
});
it("clears every timer when the provider goes away", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "warning" });
});
expect(vi.getTimerCount()).toBe(2);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container);
});
it("is per React tree: outside a provider there is no toast API to reach for", () => {
function Orphan(): null {
useToasts();
return null;
}
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(() => render(<Orphan />)).toThrow("useToasts must be used inside a ToastProvider.");
consoleError.mockRestore();
});
});
describe("ErrorState", () => {
it("leads with a plain message, and shows no disclosure when there is no detail", () => {
render(<ErrorState description="Check the connection, then retry." title="Could not load" />);
const element = container.querySelector(".ek-message--error") as HTMLElement;
expect(element.getAttribute("role")).toBe("alert");
expect(element.querySelector(".ek-message__title")?.textContent).toBe("Could not load");
expect(element.querySelector("details")).toBeNull();
});
it("keeps technical detail and a reference collapsed behind a disclosure", () => {
render(
<ErrorState
details={"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s."}
reference="req-7f3a"
title="The image could not be uploaded"
/>,
);
const details = container.querySelector("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Technical details");
expect(details.querySelector("pre")?.textContent).toBe(
"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s.",
);
expect(details.querySelector("code")?.textContent).toBe("req-7f3a");
});
it("offers recovery and remedy actions beside the message", () => {
const retry = vi.fn();
render(
<ErrorState
actions={
<>
<button onClick={retry} type="button">
Retry
</button>
<a href="/settings">Open storage settings</a>
</>
}
title="Could not load"
/>,
);
act(() => buttonNamed("Retry").click());
expect(retry).toHaveBeenCalledTimes(1);
expect(container.querySelector(".ek-message__actions a")?.getAttribute("href")).toBe(
"/settings",
);
});
it("renders an arbitrary server string as inert, bounded text", () => {
const hostile = `<img src=x onerror="alert(1)"><script>steal()</script>\u0007\u001b[31m${"x".repeat(5000)}`;
render(<ErrorState details={hostile} title="Something went wrong" />);
const pre = container.querySelector("pre") as HTMLElement;
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
expect(pre.textContent?.startsWith('<img src=x onerror="alert(1)">')).toBe(true);
expect(pre.textContent).not.toContain("\u0007");
expect(pre.textContent?.length).toBe(ERROR_DETAILS_MAX_LENGTH + 1);
expect(errorDetailsText(" line one\n\tline two ")).toBe("line one\n\tline two");
});
it("can be part of a page's initial content without being announced", () => {
render(<ErrorState announce={false} title="Not available" />);
expect(container.querySelector(".ek-message--error")?.getAttribute("role")).toBeNull();
});
});
describe("Skeleton", () => {
it("is a busy, labelled region announced once, with every placeholder hidden from it", () => {
render(
<SkeletonGroup label="Loading displays">
<Skeleton shape="circle" width="2rem" />
<Skeleton width="60%" />
<Skeleton shape="block" />
</SkeletonGroup>,
);
const group = container.querySelector(".ek-skeleton-group") as HTMLElement;
expect(group.getAttribute("role")).toBe("status");
expect(group.getAttribute("aria-busy")).toBe("true");
expect(container.querySelector(`#${group.getAttribute("aria-labelledby")}`)?.textContent).toBe(
"Loading displays",
);
const placeholders = [...group.querySelectorAll(".ek-skeleton")] as HTMLElement[];
expect(placeholders).toHaveLength(3);
for (const placeholder of placeholders) {
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
expect(placeholder.textContent).toBe("");
}
expect(placeholders[0]?.style.height).toBe("2rem");
// No percentage, no progress: there is none to report.
expect(group.textContent).toBe("Loading displays");
});
it("stops moving for a person who asked for reduced motion", () => {
const reduced = EKKLESIA_UI_STYLESHEET.slice(
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
);
expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
expect(reduced).toContain("animation: none");
});
});
describe("PageHeader", () => {
it("names the page with its one h1 and keeps page actions beside it", () => {
render(<PageHeader actions={<button type="button">Add display</button>} title="Displays" />);
expect(container.querySelector("h1")?.textContent).toBe("Displays");
expect(container.querySelector(".ek-page-header__actions button")?.textContent).toBe(
"Add display",
);
expect(container.querySelector(".ek-page-header__back")).toBeNull();
});
it("puts a Back link above the title when Back is a real place", () => {
const onBack = vi.fn();
render(
<PageHeader
back={{ href: "/signage", label: "Back to Signage", onBack }}
title="Add display"
/>,
);
const back = container.querySelector(".ek-page-header__back") as HTMLAnchorElement;
expect(back.tagName).toBe("A");
expect(back.getAttribute("href")).toBe("/signage");
expect(back.textContent).toBe("←Back to Signage");
expect(back.querySelector('[aria-hidden="true"]')?.textContent).toBe("←");
const copy = back.parentElement as HTMLElement;
expect([...copy.children].indexOf(back)).toBeLessThan(
[...copy.children].indexOf(copy.querySelector("h1") as Element),
);
const click = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true });
act(() => {
back.dispatchEvent(click);
});
expect(click.defaultPrevented).toBe(true);
expect(onBack).toHaveBeenCalledTimes(1);
const newTab = new MouseEvent("click", {
bubbles: true,
button: 0,
cancelable: true,
ctrlKey: true,
});
act(() => {
back.dispatchEvent(newTab);
});
expect(newTab.defaultPrevented).toBe(false);
expect(onBack).toHaveBeenCalledTimes(1);
});
it("renders Back as a button when it has no address", () => {
const onBack = vi.fn();
render(<PageHeader back={{ label: "Back", onBack }} title="Details" />);
const back = container.querySelector(".ek-page-header__back") as HTMLButtonElement;
expect(back.tagName).toBe("BUTTON");
expect(back.type).toBe("button");
act(() => back.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
});
+12
View File
@@ -407,6 +407,9 @@ importers:
'@ekklesia/ui':
specifier: workspace:*
version: link:../../packages/ui
lucide-react:
specifier: 1.48.0
version: 1.48.0([email protected])
react:
specifier: 19.2.8
version: 19.2.8
@@ -2401,6 +2404,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4369,6 +4377,10 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]([email protected]):
dependencies:
react: 19.2.8
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+14 -2
View File
@@ -5,6 +5,11 @@
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
#
# `--stale --prefix P` narrows the sweep to stacks whose names begin with P, which must itself begin
# with the CI prefix: it can only ever narrow a sweep, never widen one. scripts/test-ci-sweep.sh
# confines its short thresholds with it, because the daemon is shared and another job's live stack
# may be on it right now.
#
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
@@ -31,9 +36,10 @@ source "$repository_root/scripts/lib/ci-project.sh"
compose_label="com.docker.compose.project"
mode=""
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
narrow_prefix=""
usage() {
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] [--prefix P] | --job" >&2
exit 2
}
@@ -48,11 +54,17 @@ while (($# > 0)); do
max_age_seconds="$2"
shift
;;
--prefix)
[[ "${2:-}" == "$EKKLESIA_CI_PREFIX"?* ]] || usage
narrow_prefix="$2"
shift
;;
*) usage ;;
esac
shift
done
[[ -n "$mode" ]] || usage
[[ -z "$narrow_prefix" || "$mode" == stale ]] || usage
if [[ "$mode" == job ]]; then
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
@@ -62,7 +74,7 @@ if [[ "$mode" == job ]]; then
prefix="$(ci_job_prefix)"
cutoff=""
else
prefix="$EKKLESIA_CI_PREFIX"
prefix="${narrow_prefix:-$EKKLESIA_CI_PREFIX}"
cutoff="$(($(date +%s) - max_age_seconds))"
fi
+86 -3
View File
@@ -187,10 +187,14 @@ check(
);
// --- A module UI, imported from the API's origin -------------------------------------------------
const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')";
// The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057).
const moduleButton =
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
let openedModuleId;
if (hasModule) {
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
openedModuleId = moduleId;
await evaluate(`${moduleButton}.click()`);
const mounted = await waitFor(
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
@@ -224,6 +228,20 @@ if (hasModule) {
streams.every((response) => response.status === 200),
`every realtime stream it opened was accepted (${streams.length} opened)`,
);
// A refresh stays on the module's own page: the proxy forwards the deep link, the API serves the
// Dashboard page there, and the shell mounts the same module again (ADR 0057).
const moduleUrl = await evaluate("location.href");
check(
moduleUrl === `${dashboardOrigin}/${moduleId}`,
"opening the module put its own address in the address bar",
);
await navigate(moduleUrl);
const remounted = await waitFor(
`location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
"the module UI to mount again after a refresh",
);
check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`);
}
// A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a
// module unmounting, a navigation) is not.
@@ -266,9 +284,74 @@ if (displayPath === null) {
);
}
// --- Sign out on the Dashboard -------------------------------------------------------------------
// --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 2,
height: 844,
mobile: true,
width: 390,
});
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-logout"), "the sign-out control");
check(
await waitFor(
`${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`,
"the phone navigation trigger",
),
"a phone-sized window shows the navigation trigger instead of the sidebar",
);
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
check(
await waitFor(
`document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`,
"focus inside the opened drawer",
),
"the drawer opens as a modal, with focus inside it and the page inert",
);
await evaluate(
`document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`,
);
check(
await waitFor(
`!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`,
"the drawer to close on Escape",
),
"Escape closes the drawer and returns focus to its trigger",
);
if (openedModuleId) {
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer");
await evaluate(`${selector(`select-${openedModuleId}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`,
"the module chosen from the drawer to open",
),
`choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`,
);
}
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
check(
await waitFor(
`${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${
openedModuleId
? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`
: ""
}`,
"the sidebar after widening the window",
),
"widening the window brings the sidebar back on the same page",
);
// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-account-menu"), "the account control");
await evaluate(`${selector("host-account-menu")}.click()`);
await waitFor(selector("host-logout"), "the sign-out item in the account menu");
await evaluate(`${selector("host-logout")}.click()`);
check(
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
+44
View File
@@ -88,6 +88,27 @@ async function edgeChecks() {
"the Dashboard page names the API's origin, rendered at run time (ADR 0045)",
);
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
// is installed yet at this point; a module's own address is reloaded in a real browser later.
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
check(
deepPage.status === 200 &&
deepPage.headers.get("etag") === page.headers.get("etag") &&
(await deepPage.text()) === html,
`GET ${deepLink} through the proxy is the same Dashboard page`,
);
}
// A top-level name shaped like a module id reaches the API, which knows the installed modules and
// answers anything that is not a Dashboard page with its sanitized 404 — never the page.
for (const unknown of ["/definitely-not-a-real-module", "/settings-old", "/modules/example"]) {
const response = await fetch(`${dashboardOrigin}${unknown}`);
check(
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
`GET ${unknown} through the proxy is the API's sanitized 404, not a Dashboard page`,
);
}
const script = await fetch(`${dashboardOrigin}/assets/ekklesia-dashboard/app.js`);
await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
@@ -152,6 +173,10 @@ async function splitRoutingChecks() {
"the Dashboard hostname refuses /display/*",
);
check(await refusedByProxy(`${apiOrigin}/`), "the API hostname serves nothing at /");
check(
await refusedByProxy(`${apiOrigin}/settings/displays`),
"the API hostname serves no Dashboard page either",
);
check(
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script",
@@ -491,6 +516,25 @@ async function moduleChecks() {
check(installed, `the module inventory lists the \`${moduleId}\` module`);
}
// The module's own Dashboard address (ADR 0057) exists exactly while the module is installed with a
// Dashboard UI — disabled or not: the Dashboard page when it is, the API's sanitized 404 when it is
// not. A disabled module is not running, but its address still opens the Dashboard.
const pageExpectation =
optional("SMOKE_MODULE_PAGE") || (expectation === "absent" ? "absent" : "served");
const modulePage = await fetch(`${dashboardOrigin}/${moduleId}`);
const modulePageBody = await modulePage.text();
if (pageExpectation === "absent") {
check(
modulePage.status === 404 && modulePageBody === '{"code":"NOT_FOUND"}',
`GET /${moduleId} is the sanitized 404 while the module is not installed`,
);
} else {
check(
modulePage.status === 200 && modulePageBody.includes('id="ekklesia-host"'),
`GET /${moduleId} through the proxy is the Dashboard page while the module is installed`,
);
}
if (moduleId === "example") {
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
+6 -2
View File
@@ -543,9 +543,13 @@ recreate_api() {
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
# module_check <expectation> [page]: "absent", or the exact JSON array of values `example` must
# return. `page` is whether its Dashboard address is served (ADR 0057): by default, exactly when the
# module is running; "served" for a module that is installed but disabled, whose address still opens
# the Dashboard so it can say the module is unavailable.
module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_MODULE_PAGE="${2:-}" \
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
}
@@ -729,7 +733,7 @@ if [[ "$topology" == split ]]; then
step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent || fail "the disabled module was still reported as running."
module_check absent served || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \
+18 -5
View File
@@ -4,8 +4,10 @@
#
# bash scripts/test-ci-sweep.sh
#
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, and
# every sweep it runs is confined to those identifiers, so it can never touch a real stack: the
# runner's daemon is shared, and another job's live stack may be on it right now. It removes all of
# its own resources on exit, whatever happens.
set -Eeuo pipefail
@@ -91,10 +93,14 @@ source "$repository_root/scripts/lib/ci-project.sh"
# Fake run identifiers: nothing here can match a real run's stacks.
export GITHUB_RUN_ID="sweeptest${nonce}"
export GITHUB_RUN_ATTEMPT="1"
# Every stack this test sweeps begins with this; nothing it sweeps reaches past it.
own_run="$(ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID}-")"
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
foreign="ekklesia-sweep-control-${nonce}"
for project in "$mine" "$sibling" "$foreign"; do
# Stands in for another run's live stack on the shared daemon — a module's integration job, say.
bystander="$(GITHUB_RUN_ID="bystander${nonce}" EKKLESIA_CI_JOB_KEY=gamma ci_project_name live)"
for project in "$mine" "$sibling" "$foreign" "$bystander"; do
make_stack "$project"
done
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
@@ -115,12 +121,19 @@ check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
"--job without GITHUB_RUN_ID fails"
echo "--- --stale removes only CI stacks older than its threshold"
bash scripts/ci-sweep.sh --stale
bash scripts/ci-sweep.sh --stale --prefix "$own_run"
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
bash scripts/ci-sweep.sh --stale --max-age-seconds 0 --prefix "$own_run"
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
check '! staging_exists "$sibling"' "its staging directory too"
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
check 'project_exists "$bystander"' \
"another run's stack is never removed by a narrowed sweep, even past the threshold"
echo "--- --prefix only ever narrows the sweep"
check '! bash scripts/ci-sweep.sh --stale --prefix "ekklesia-" >/dev/null 2>&1' \
"a prefix outside the CI prefix is refused"
check '! bash scripts/ci-sweep.sh --job --prefix "$own_run" >/dev/null 2>&1' \
"--prefix with --job is refused"
if ((failures > 0)); then
echo "test-ci-sweep: ${failures} check(s) failed." >&2
+5
View File
@@ -16,6 +16,10 @@
"types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js"
},
"./dashboard-routes": {
"types": "./dist/dashboard-routes.d.ts",
"import": "./dist/dashboard-routes.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
@@ -33,6 +37,7 @@
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*",
"lucide-react": "1.48.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
+7 -1
View File
@@ -89,7 +89,13 @@ await copyFile(
if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory });
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page only at its platform pages and installed modules'.
const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory,
});
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log(
apiBaseUrl === ""
+187
View File
@@ -0,0 +1,187 @@
import {
type KeyboardEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useNavTooltip } from "./NavTooltip.js";
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
export interface AccountMenuProps {
readonly presentation: NavigationPresentation;
readonly onSignOut: () => void;
}
interface AccountAction {
readonly id: string;
readonly label: string;
readonly icon: ShellIconName;
readonly testId: string;
readonly run: () => void;
}
/**
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
*
* A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the
* trigger says it opens a menu and whether it is open; opening moves focus to the first item (or
* the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
* went.
*
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
* is no dead link to `/account`. The shell knows the person only by an opaque user id
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
*/
export function AccountMenu(props: AccountMenuProps): ReactElement {
const [open, setOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const menuId = useId();
const triggerId = useId();
const { collapsed } = props.presentation;
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
const close = useCallback((returnFocus: boolean) => {
setOpen(false);
if (returnFocus) {
triggerRef.current?.focus();
}
}, []);
const actions: readonly AccountAction[] = [
{
icon: "signOut",
id: "sign-out",
label: "Sign out",
run: () => {
setOpen(false);
props.onSignOut();
},
testId: "host-logout",
},
];
const openMenu = (focus: "first" | "last"): void => {
setInitialFocus(focus);
setOpen(true);
};
useEffect(() => {
if (!open) {
return;
}
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
}, [initialFocus, open]);
// A press anywhere outside the menu and its trigger dismisses it, leaving focus alone.
useEffect(() => {
const owner = wrapperRef.current?.ownerDocument;
if (!open || !owner) {
return;
}
const dismiss = (event: Event): void => {
if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) {
setOpen(false);
}
};
owner.addEventListener("pointerdown", dismiss);
return () => owner.removeEventListener("pointerdown", dismiss);
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
const index = items.indexOf(event.target as HTMLButtonElement);
const focusAt = (next: number): void => {
event.preventDefault();
items[(next + items.length) % items.length]?.focus();
};
if (event.key === "Escape") {
// Only the menu closes: a drawer around it stays open.
event.preventDefault();
event.stopPropagation();
close(true);
} else if (event.key === "ArrowDown") {
focusAt(index + 1);
} else if (event.key === "ArrowUp") {
focusAt(index - 1);
} else if (event.key === "Home") {
focusAt(0);
} else if (event.key === "End") {
focusAt(items.length - 1);
} else if (event.key === "Tab") {
setOpen(false);
}
};
return (
<div ref={wrapperRef}>
{open ? (
<div
aria-labelledby={triggerId}
className="ek-shell-menu"
data-testid="host-account-menu-list"
id={menuId}
onKeyDown={onMenuKeyDown}
role="menu"
>
{actions.map((action, index) => (
<button
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
data-testid={action.testId}
key={action.id}
onClick={action.run}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
type="button"
>
<ShellIcon name={action.icon} />
{action.label}
</button>
))}
</div>
) : undefined}
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="menu"
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
data-testid="host-account-menu"
id={triggerId}
onClick={() => (open ? close(false) : openMenu("first"))}
ref={triggerRef}
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
type="button"
{...triggerProps}
onKeyDown={(event) => {
triggerProps.onKeyDown(event);
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}}
>
<ShellIcon name="account" />
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
Your account
</span>
</button>
{tooltip}
</div>
);
}
@@ -0,0 +1,108 @@
import { designToken } from "@ekklesia/design-tokens";
import type {
CSSProperties,
FocusEventHandler,
KeyboardEventHandler,
MouseEvent,
MouseEventHandler,
ReactElement,
ReactNode,
} from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardLinkProps {
readonly history: HostHistory;
readonly to: DashboardDestination;
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
/** Called after an in-place navigation, including to the page already open — for a drawer that
* closes once a destination is chosen. Not called for a click the browser handles itself. */
readonly onNavigate?: () => void;
readonly className?: string;
readonly style?: CSSProperties;
readonly onBlur?: FocusEventHandler<HTMLAnchorElement>;
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string;
}
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/**
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
*
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
* place through the shell's history instead of reloading the document; every other click — with a
* modifier key, or the middle button — is left to the browser.
*/
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
const baseStyle =
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle;
return (
<a
aria-current={props.current ? "page" : undefined}
className={
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
}
data-testid={props["data-testid"]}
href={href}
onBlur={props.onBlur}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
props.onNavigate?.();
}}
onFocus={props.onFocus}
onKeyDown={props.onKeyDown}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
>
{props.children}
</a>
);
}
+125 -158
View File
@@ -1,7 +1,15 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
HostAuthError,
type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import {
bootstrapHost,
type DashboardNavigationEntry,
@@ -20,11 +29,15 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ShellLayout } from "./ShellLayout.js";
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract<
@@ -101,75 +114,35 @@ export interface HostAppProps {
* so there is nothing an open redirect could use.
*/
readonly authLanding?: HostAuthLanding;
/**
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
* bookmark, and Back and Forward all land where the person was. The entry point passes the
* browser's; tests pass an in-memory one. Must be a stable reference.
*/
readonly history: HostHistory;
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void;
/**
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
* query; left unset, the wide layout. Must be a stable reference.
*/
readonly viewport?: HostViewport;
/**
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
* passes this browser's storage; left unset, the mode lasts only as long as this component.
*/
readonly sidebarPreference?: SidebarPreference;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
/**
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
* colours (ADR 0039). Nothing here is a brand decision any more: changing
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
* artifact together, because custom properties inherit through the artifacts' isolated roots.
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
*/
const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
background: designToken("background.canvas"),
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
boxSizing: "border-box",
minWidth: "15rem",
overflowY: "auto",
padding: designToken("spacing.xl"),
width: "15rem",
},
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
brandMark: {
background: designToken("accent.base"),
borderRadius: designToken("radius.md"),
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
display: "block",
height: designToken("spacing.xl"),
width: designToken("spacing.xl"),
},
groupLabel: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
letterSpacing: "0.05em",
margin: `0 0 ${designToken("spacing.xs")}`,
textTransform: "uppercase",
},
header: {
alignItems: "center",
background: designToken("background.surface"),
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
},
heading: {
fontSize: designToken("typography.size-lg"),
fontWeight: designToken("typography.weight-bold"),
margin: 0,
},
layout: {
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: designToken("typography.family-sans"),
gridTemplateColumns: "15rem minmax(0, 1fr)",
gridTemplateRows: "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
},
main: {
boxSizing: "border-box",
minWidth: 0,
overflowX: "auto",
padding: designToken("spacing.xl"),
},
message: {
color: designToken("text.secondary"),
display: "grid",
@@ -194,23 +167,10 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleButton(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
}): ReactElement {
return (
<Button
aria-current={props.selected ? "page" : undefined}
aria-pressed={props.selected}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
>
{props.entry.name}
</Button>
);
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: {
@@ -265,21 +225,32 @@ async function resolveBootstrapState(
/**
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
*
* The login form replaces the protected application in place. There is no redirect parameter, URL
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
* simply remains the current URL through sign-in and no external/protocol-relative return value can
* become a navigation sink. Modules receive the same five-field context after authentication; they
* neither render platform login nor receive sign-in/sign-out controls.
* The login form replaces the protected application in place. There is no redirect parameter,
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
* remains the current URL through sign-in — a deep link opens on its own page once the person has
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
* ever moves between its own paths (ADR 0057). Modules receive the same context after
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
*/
export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props;
const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
const viewport = props.viewport ?? fallbackViewport;
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left.
@@ -306,14 +277,16 @@ export function HostApp(props: HostAppProps): ReactElement {
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved);
if (resolved.status === "ready") {
const first = allEntries(resolved.bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
} else {
setSelectedKey(undefined);
}
}, []);
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
// history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
history.replace(dashboardPath(route));
}
}, [history, pathname, route]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -329,7 +302,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt;
const { controller, generation } = beginTransition();
setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) {
return;
@@ -381,7 +353,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" });
try {
await signOutHost(config, controller.signal);
@@ -435,7 +406,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
@@ -451,7 +421,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" });
})
@@ -464,6 +433,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config],
);
const openEntry =
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => {
if (documentTitle !== undefined) {
setDocumentTitle?.(documentTitle);
}
}, [documentTitle, setDocumentTitle]);
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
// canonical respelling of the same page is not a move.
const currentRouteKey = routeKey(route);
useEffect(() => {
if (state.status !== "ready") {
shownRouteRef.current = undefined;
return;
}
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
mainRef.current?.focus();
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") {
@@ -552,74 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement {
}
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
<div data-testid="host-shell" style={shellStyles.layout}>
<header style={shellStyles.header}>
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
</div>
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
Sign out
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
) : undefined}
</nav>
)}
</aside>
<main style={shellStyles.main}>
{selected ? (
<ShellLayout
config={config}
history={history}
mainRef={mainRef}
navigation={bootstrap.navigation}
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
sidebarPreference={sidebarPreference}
viewport={viewport}
>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={selected.descriptor}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected)}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : undefined}
</main>
</div>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</ShellLayout>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { EmptyState, PageHeader } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
/**
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
*
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
*/
export function OverviewPage(): ReactElement {
return (
<div data-testid="host-overview">
<PageHeader title="Overview" />
<EmptyState
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
title="Nothing to show yet"
/>
</div>
);
}
/**
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
* provides no user interface, or the address was mistyped. The server's navigation is the only
* authority on what exists, and it does not say which of these applies, so neither does this page.
* It never echoes the address back.
*/
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-module-unavailable">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
title="This page isn’t available"
/>
</div>
);
}
/** Any other address inside the Dashboard. */
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-not-found">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="There is nothing at this address. Check it, or start again from the Overview."
title="Page not found"
/>
</div>
);
}
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
return (
<DashboardLink
data-testid="host-go-to-overview"
history={props.history}
to={{ kind: "overview" }}
variant="action"
>
Go to Overview
</DashboardLink>
);
}
+122
View File
@@ -0,0 +1,122 @@
import {
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap
* onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */
const HIDE_DELAY_MS = 150;
interface Anchor {
readonly left: number;
readonly top: number;
}
/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */
export interface NavTooltipTriggerProps {
onBlur(event: FocusEvent<HTMLElement>): void;
onFocus(event: FocusEvent<HTMLElement>): void;
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
onMouseEnter(event: MouseEvent<HTMLElement>): void;
onMouseLeave(event: MouseEvent<HTMLElement>): void;
}
/**
* The visible name of an icon-only control in the collapsed sidebar (ADR 0058).
*
* The control's accessible name is its own text, kept in the DOM and visually hidden, so the
* tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the
* name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as
* well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can
* move onto it without it vanishing, and it stays until focus or the pointer leaves.
*
* Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar
* cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already
* shows every label.
*
* Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent
* consumers need it; when a module needs a tooltip, this is the shape to promote.
*/
export function useNavTooltip(
label: string,
enabled: boolean,
): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } {
const [anchor, setAnchor] = useState<Anchor | undefined>(undefined);
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cancelHide = useCallback(() => {
if (hideTimer.current !== undefined) {
clearTimeout(hideTimer.current);
hideTimer.current = undefined;
}
}, []);
const hide = useCallback(() => {
cancelHide();
setAnchor(undefined);
}, [cancelHide]);
const show = useCallback(
(element: HTMLElement) => {
cancelHide();
const rect = element.getBoundingClientRect();
setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 });
},
[cancelHide],
);
useEffect(() => {
if (!enabled) {
hide();
}
}, [enabled, hide]);
useEffect(() => cancelHide, [cancelHide]);
const triggerProps: NavTooltipTriggerProps = {
onBlur: hide,
onFocus: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onKeyDown: (event) => {
if (event.key === "Escape" && anchor !== undefined) {
// Only this tooltip is dismissed; the event goes no further.
event.stopPropagation();
hide();
}
},
onMouseEnter: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onMouseLeave: () => {
cancelHide();
hideTimer.current = setTimeout(hide, HIDE_DELAY_MS);
},
};
const tooltip =
enabled && anchor !== undefined ? (
<span
aria-hidden="true"
className="ek-shell-tooltip"
data-testid="nav-tooltip"
onMouseEnter={cancelHide}
onMouseLeave={hide}
style={{ left: anchor.left, top: anchor.top }}
>
{label}
</span>
) : null;
return { tooltip, triggerProps };
}
+378
View File
@@ -0,0 +1,378 @@
import { designToken } from "@ekklesia/design-tokens";
import {
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js";
import { AccountMenu } from "./AccountMenu.js";
import { DevicePanel } from "./DevicePanel.js";
import type { HostHistory } from "./host-history.js";
import type { HostViewport } from "./host-viewport.js";
import { useNavTooltip } from "./NavTooltip.js";
import {
controlHeight,
NavigationList,
type NavigationPresentation,
ShellBrand,
} from "./ShellNavigation.js";
import { ShellIcon } from "./shell-icons.js";
import { SHELL_STYLESHEET } from "./shell-styles.js";
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation";
const DRAWER_ID = "ekklesia-navigation-drawer";
const DRAWER_NAV_ID = "ekklesia-drawer-navigation";
const SIDEBAR_WIDTH: Record<SidebarMode, string> = { collapsed: "4.5rem", expanded: "16rem" };
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface ShellLayoutProps {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly viewport: HostViewport;
readonly sidebarPreference: SidebarPreference;
readonly mainRef: RefObject<HTMLElement | null>;
readonly pageTitle: string | undefined;
readonly onSignOut: () => void;
readonly children: ReactNode;
}
function SidebarToggle(props: {
readonly mode: SidebarMode;
readonly onToggle: () => void;
}): ReactElement {
const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation";
const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed");
return (
<>
<button
aria-controls={SIDEBAR_NAV_ID}
aria-expanded={props.mode === "expanded"}
aria-label={label}
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="sidebar-toggle"
onClick={props.onToggle}
style={{ minHeight: controlHeight({ collapsed: false, compact: false }) }}
type="button"
{...triggerProps}
>
<ShellIcon name={props.mode === "expanded" ? "collapseSidebar" : "expandSidebar"} />
</button>
{tooltip}
</>
);
}
/**
* The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the
* account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip.
*/
function Sidebar(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly mode: SidebarMode;
readonly onToggle: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const presentation: NavigationPresentation = {
collapsed: props.mode === "collapsed",
compact: false,
};
return (
<aside
className="ek-shell-sidebar"
data-sidebar={props.mode}
data-testid="host-sidebar"
style={{ width: SIDEBAR_WIDTH[props.mode] }}
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={presentation.collapsed} />
<SidebarToggle mode={props.mode} onToggle={props.onToggle} />
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={SIDEBAR_NAV_ID}
navigation={props.navigation}
presentation={presentation}
route={props.route}
/>
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</aside>
);
}
/**
* The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the
* sidebar, in a modal drawer.
*
* It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and
* Shift+Tab kept inside it here as well, for a browser that does not yet honour `inert`. Opening
* moves focus to its close button; Escape, the close button, and the backdrop dismiss it and the
* layout returns focus to the trigger.
*/
function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly onDismiss: () => void;
readonly onNavigate: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const presentation: NavigationPresentation = { collapsed: false, compact: true };
useEffect(() => {
closeRef.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === "Escape") {
event.preventDefault();
props.onDismiss();
return;
}
if (event.key !== "Tab" || !panelRef.current) {
return;
}
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const first = focusable[0];
const last = focusable.at(-1);
const active = panelRef.current.ownerDocument.activeElement;
if (event.shiftKey && (active === first || !panelRef.current.contains(active))) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) {
event.preventDefault();
first?.focus();
}
};
return (
<>
<div
aria-hidden="true"
className="ek-shell-drawer-backdrop"
data-testid="navigation-drawer-backdrop"
onClick={props.onDismiss}
/>
<div
aria-label="Navigation"
aria-modal="true"
className="ek-shell-drawer"
data-testid="navigation-drawer"
id={DRAWER_ID}
onKeyDown={onKeyDown}
ref={panelRef}
role="dialog"
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={false} />
<button
aria-label="Close navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="navigation-drawer-close"
onClick={props.onDismiss}
ref={closeRef}
style={{
minHeight: controlHeight(presentation),
minWidth: controlHeight(presentation),
}}
type="button"
>
<ShellIcon name="closeNavigation" />
</button>
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={DRAWER_NAV_ID}
navigation={props.navigation}
onNavigate={props.onNavigate}
presentation={presentation}
route={props.route}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</div>
</>
);
}
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
/**
* The authenticated Dashboard's frame (ADR 0058): a collapsible sidebar on wide screens, a drawer
* on phones, and the page.
*
* The page's `<main>` keeps one position in the tree whichever layout is showing, so changing
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
* There is no application top bar: on a phone the one control above the page is the menu trigger.
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
const subscribe = useCallback((listener: () => void) => viewport.subscribe(listener), [viewport]);
const readCompact = useCallback(() => viewport.isCompact(), [viewport]);
const compact = useSyncExternalStore(subscribe, readCompact, readCompact);
const [mode, setMode] = useState<SidebarMode>(() => sidebarPreference.read() ?? "expanded");
const [drawerOpen, setDrawerOpen] = useState(false);
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const previousCompact = useRef(compact);
const previousRoute = useRef(routeKey(route));
const toggleMode = (): void => {
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
setMode(next);
sidebarPreference.write(next);
};
const closeDrawer = useCallback((focus: "trigger" | "main") => {
pendingFocus.current = focus;
setDrawerOpen(false);
}, []);
// A layout change drops an open drawer. Focus inside a drawer or sidebar that just went away
// would otherwise fall to the document; it lands on the page instead.
useLayoutEffect(() => {
if (previousCompact.current === compact) {
return;
}
previousCompact.current = compact;
if (drawerOpen) {
closeDrawer("main");
return;
}
const owner = mainRef.current?.ownerDocument;
if (owner && (owner.activeElement === null || owner.activeElement === owner.body)) {
mainRef.current?.focus();
}
}, [closeDrawer, compact, drawerOpen, mainRef]);
// Back and Forward close the drawer too; the shell moves focus to the new page itself.
useEffect(() => {
const key = routeKey(route);
if (previousRoute.current !== key) {
previousRoute.current = key;
setDrawerOpen(false);
}
}, [route]);
useEffect(() => {
if (drawerOpen || pendingFocus.current === undefined) {
return;
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
const pageInert = showDrawer ? true : undefined;
return (
<div
data-layout={compact ? "compact" : "wide"}
data-testid="host-shell"
style={{
display: compact ? "block" : "grid",
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
}}
>
<style>{SHELL_STYLESHEET}</style>
{compact ? undefined : (
<Sidebar
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
onSignOut={props.onSignOut}
onToggle={toggleMode}
route={route}
/>
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
aria-label="Open navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="mobile-navigation-trigger"
onClick={() => setDrawerOpen(true)}
ref={triggerRef}
style={{
minHeight: controlHeight({ collapsed: false, compact: true }),
minWidth: controlHeight({ collapsed: false, compact: true }),
}}
type="button"
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
) : undefined}
<main
aria-label={props.pageTitle}
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
style={{
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
{props.children}
</main>
</div>
{showDrawer ? (
<NavigationDrawer
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
/>
) : undefined}
</div>
);
}
@@ -0,0 +1,208 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
*/
export interface NavigationPresentation {
readonly collapsed: boolean;
readonly compact: boolean;
}
/** The control height for this presentation: the medium control on desktop, the large one in the
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
export function controlHeight(presentation: NavigationPresentation): string {
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
}
/**
* The identity slot at the top of the navigation (ADR 0058).
*
* A neutral placeholder — a plain mark and the product's name — until the installation's own
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
* branding work owns the decision of what goes here and where it comes from, and replaces this
* component's body rather than undoing a configuration model invented early. It is not a heading;
* each page's own title is. Collapsed, only the mark shows.
*/
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
return (
<div className="ek-shell-brand" data-testid="shell-brand">
<span aria-hidden="true" className="ek-shell-brand__mark" />
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
</div>
);
}
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
readonly icon: ShellIconName;
readonly current: boolean;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly testId: string;
}): ReactElement {
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
style={style}
to={props.destination}
{...triggerProps}
>
<ShellIcon name={props.icon} />
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
</DashboardLink>
{tooltip}
</li>
);
}
function ModuleItems(props: {
readonly entries: readonly DashboardNavigationEntry[];
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
return (
<ul
aria-labelledby={props.labelledBy}
className="ek-shell-nav__group"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
return (
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", moduleId }}
history={props.history}
// Every module gets the one generic icon until modules can declare their own
// (ADR 0058); there is no table of known modules.
icon="module"
key={`${moduleId}@${entry.descriptor.version}`}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
testId={`select-${moduleId}`}
/>
);
})}
</ul>
);
}
export interface NavigationListProps {
readonly id: string;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
* server's navigation does not list it (ADR 0027, ADR 0028).
*
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
* visually.
*/
export function NavigationList(props: NavigationListProps): ReactElement {
const { groups, ungrouped } = props.navigation;
const { collapsed } = props.presentation;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const empty = groups.length === 0 && ungrouped.length === 0;
return (
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<NavigationItem
current={props.route.kind === "overview"}
destination={{ kind: "overview" }}
history={props.history}
icon="overview"
label="Overview"
{...onNavigate}
presentation={props.presentation}
testId="select-overview"
/>
</ul>
{empty && !collapsed ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => {
const labelId = `${props.id}-group-${group.id}`;
return (
<section data-testid={`group-${group.id}`} key={group.id}>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={labelId}
>
{group.label}
</h2>
<ModuleItems
entries={group.modules}
history={props.history}
labelledBy={labelId}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
);
})}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? (
<>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={`${props.id}-group-other`}
>
Other
</h2>
</>
) : undefined}
<ModuleItems
entries={ungrouped}
history={props.history}
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
) : undefined}
</nav>
);
}
@@ -0,0 +1,32 @@
import type { PageHeaderBack } from "@ekklesia/ui";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import { parseReturnTarget } from "../return-target.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardBackOptions {
/** Where the person came from, if a link said so. Validated; anything unsafe is ignored. */
readonly returnTarget?: unknown;
/** The page's own parent: where Back goes when there is no valid return target. */
readonly fallback: DashboardDestination;
/** Names the destination: "Back to Signage". Default "Back". */
readonly label?: (destination: DashboardDestination) => string;
}
/**
* A nested Dashboard page's Back action, for `PageHeader` (DESIGN.md §2.7, §4.8; ADR 0059).
*
* Back returns to the workflow that sent the person here when a validated return target says where
* that was; otherwise it goes to the page's declared parent. It never calls the browser's own
* `history.back()`: after a bookmark or a link from outside, the previous entry is not the
* Dashboard's, and Back must not take the person off the Dashboard. The action is a real link to the
* canonical address, so it can also be opened in a new tab.
*/
export function dashboardBack(history: HostHistory, options: DashboardBackOptions): PageHeaderBack {
const destination = parseReturnTarget(options.returnTarget) ?? options.fallback;
const href = dashboardPath(destination);
return {
href,
label: options.label?.(destination) ?? "Back",
onBack: () => history.push(href),
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The shell's one view of the address bar (ADR 0057).
*
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
* in the shell reads the current path and moves between pages through this small interface rather
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
* package that touches a browser global — the browser implementation is handed the window's pieces,
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
* history whose entries a test can inspect.
*
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
* a later decision owns.
*/
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
replace(path: string): void;
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
subscribe(listener: () => void): () => void;
}
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
}
/**
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
* notifies its subscribers after each one; `popstate` covers Back and Forward.
*/
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
pathname: () => view.location.pathname,
push(path) {
if (path === view.location.pathname) {
return;
}
view.history.pushState(null, "", path);
notify();
},
replace(path) {
view.history.replaceState(null, "", path);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
view.addEventListener("popstate", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
view.removeEventListener("popstate", notify);
}
};
},
};
}
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
export interface MemoryHostHistory extends HostHistory {
readonly entries: readonly string[];
readonly index: number;
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
go(delta: number): void;
}
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
const entries: string[] = [initialPath];
let index = 0;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
get entries() {
return [...entries];
},
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
push(path) {
if (path === entries[index]) {
return;
}
entries.splice(index + 1, entries.length, path);
index = entries.length - 1;
notify();
},
replace(path) {
entries[index] = path;
notify();
},
go(delta) {
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
if (next !== index) {
index = next;
notify();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -0,0 +1,63 @@
/**
* Whether the Dashboard is laid out for a phone (ADR 0058).
*
* Below the breakpoint the sidebar becomes a drawer with the same navigation; at and above it the
* collapsible sidebar is shown, tablets included (DESIGN.md §4.6–4.7). The shell reads the answer
* through this interface rather than from `window.matchMedia`, so the entry point stays the only
* file that touches a browser global and every responsive behaviour is testable by resizing an
* in-memory viewport.
*/
export interface HostViewport {
isCompact(): boolean;
subscribe(listener: () => void): () => void;
}
/** `em` in a media query is the browser's initial font size, so the breakpoint follows a person's
* own text-size setting: 48em is 768px at the default 16px. */
export const HOST_COMPACT_MEDIA_QUERY = "(max-width: 47.999em)";
type MediaQueryListLike = Pick<MediaQueryList, "matches"> & {
addEventListener(type: "change", listener: () => void): void;
removeEventListener(type: "change", listener: () => void): void;
};
/** The browser's media query, handed in by the entry point. Without one, the wide layout. */
export function createBrowserHostViewport(
matchMedia: ((query: string) => MediaQueryListLike) | undefined,
): HostViewport {
const mediaQuery = matchMedia?.(HOST_COMPACT_MEDIA_QUERY);
return {
isCompact: () => mediaQuery?.matches ?? false,
subscribe(listener) {
mediaQuery?.addEventListener("change", listener);
return () => mediaQuery?.removeEventListener("change", listener);
},
};
}
export interface MemoryHostViewport extends HostViewport {
setCompact(compact: boolean): void;
}
export function createMemoryHostViewport(compact = false): MemoryHostViewport {
let current = compact;
const listeners = new Set<() => void>();
return {
isCompact: () => current,
setCompact(next) {
if (next === current) {
return;
}
current = next;
for (const listener of [...listeners]) {
listener();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+41 -2
View File
@@ -4,6 +4,9 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
import { createBrowserHostViewport } from "./host-viewport.js";
import { createLocalSidebarPreference } from "./sidebar-preference.js";
/**
* The one place the shell touches a browser global.
@@ -23,6 +26,29 @@ const apiBaseUrl = resolveApiOrigin(
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
const history = createBrowserHostHistory(window);
const setDocumentTitle = (title: string): void => {
document.title = title;
};
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
* mode for this page only. */
const viewport = createBrowserHostViewport(
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
);
const sidebarPreference = createLocalSidebarPreference(
(() => {
try {
return window.localStorage;
} catch {
return undefined;
}
})(),
);
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +79,22 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
<HostApp
authLanding={authLanding}
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
) : (
<HostApp config={config} />
<HostApp
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
);
}
@@ -0,0 +1,58 @@
import {
Blocks,
CircleUserRound,
LayoutDashboard,
type LucideIcon,
LogOut,
Menu,
PanelLeftClose,
PanelLeftOpen,
X,
} from "lucide-react";
import type { ReactElement } from "react";
/**
* Every icon the shell uses, imported from Lucide in this one file (ADR 0058).
*
* Lucide is Ekklesia's icon vocabulary. Importing each icon by name keeps the bundle to exactly the
* icons named here, and keeping them in one place keeps the vocabulary reviewable: an icon is chosen
* for a meaning, once, rather than per call site.
*
* There is deliberately **no per-module entry**. Every module is drawn with the one generic
* {@link SHELL_ICONS.module} until modules can declare their own icon through the module navigation
* contract; a table of known module ids here would be a second, host-owned answer to a question the
* module should own.
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
overview: LayoutDashboard,
signOut: LogOut,
} satisfies Record<string, LucideIcon>);
export type ShellIconName = keyof typeof SHELL_ICONS;
/**
* A decorative icon. Always hidden from assistive technology: every control that shows one also
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
* the surrounding text colour, so icons take their colour from the design tokens like text does.
*/
export function ShellIcon(props: {
readonly name: ShellIconName;
readonly size?: number;
}): ReactElement {
const Icon = SHELL_ICONS[props.name];
return (
<Icon
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
strokeWidth={1.75}
/>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { designToken } from "@ekklesia/design-tokens";
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
* and drawer.
*
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
* into the shell's own document only: each module artifact still mounts in its own isolated root
* (ADR 0040), which this stylesheet cannot reach.
*/
export const SHELL_STYLESHEET = `
.ek-shell-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
position: sticky;
top: 0;
transition: width 160ms ease;
}
.ek-shell-sidebar__top {
align-items: center;
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: space-between;
min-height: ${designToken("control.height-md")};
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top {
flex-direction: column;
}
.ek-shell-sidebar__scroll {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: ${designToken("spacing.xl")};
min-height: 0;
overflow-y: auto;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
position: relative;
}
.ek-shell-brand {
align-items: center;
color: ${designToken("text.primary")};
display: flex;
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-bold")};
gap: ${designToken("spacing.sm")};
min-width: 0;
}
.ek-shell-brand__mark {
background: ${designToken("accent.base")};
border-radius: ${designToken("radius.md")};
box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")};
display: block;
flex: 0 0 auto;
height: ${designToken("spacing.xl")};
width: ${designToken("spacing.xl")};
}
.ek-shell-nav,
.ek-shell-nav__group {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-nav {
gap: ${designToken("spacing.lg")};
}
.ek-shell-nav__group-label {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
letter-spacing: 0.05em;
margin: 0 0 ${designToken("spacing.2xs")};
padding-inline: ${designToken("control.padding-inline")};
text-transform: uppercase;
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
height: auto;
justify-content: flex-start;
text-align: start;
}
.ek-shell-nav-item svg {
flex: 0 0 auto;
}
.ek-shell-nav-item__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
justify-content: center;
padding-inline: 0;
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.sm")};
box-shadow: ${designToken("shadow.md")};
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
position: fixed;
transform: translateY(-50%);
white-space: nowrap;
z-index: 30;
}
.ek-shell-menu {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
bottom: calc(100% + ${designToken("spacing.xs")});
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.2xs")};
left: 0;
margin: 0;
min-width: 12rem;
padding: ${designToken("spacing.xs")};
position: absolute;
z-index: 20;
}
.ek-shell-menu__caption {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
margin: 0;
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
}
.ek-shell-menu__item {
justify-content: flex-start;
width: 100%;
}
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
}
.ek-shell-drawer-backdrop {
background: ${designToken("background.overlay")};
inset: 0;
position: fixed;
z-index: 40;
}
.ek-shell-drawer {
background: ${designToken("background.canvas")};
box-shadow: ${designToken("shadow.lg")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
inset: 0 auto 0 0;
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
position: fixed;
width: 20rem;
z-index: 41;
}
@media (prefers-reduced-motion: reduce) {
.ek-shell-sidebar {
transition: none;
}
}
`;
@@ -0,0 +1,70 @@
/**
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
*
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
* writes it only through this interface, so a later store — synced, or per person — replaces the
* implementation without touching a component.
*/
export type SidebarMode = "expanded" | "collapsed";
export interface SidebarPreference {
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
read(): SidebarMode | undefined;
write(mode: SidebarMode): void;
}
/** One namespaced key. Names no person and holds nothing but the mode. */
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
function isSidebarMode(value: unknown): value is SidebarMode {
return value === "expanded" || value === "collapsed";
}
/**
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
*
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
* ignored rather than trusted.
*/
export function createLocalSidebarPreference(
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
): SidebarPreference {
let remembered: SidebarMode | undefined;
return {
read() {
if (remembered !== undefined) {
return remembered;
}
try {
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
return isSidebarMode(stored) ? stored : undefined;
} catch {
return undefined;
}
},
write(mode) {
remembered = mode;
try {
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
} catch {
// Persistence is a convenience; the in-memory mode above still holds for this page.
}
},
};
}
/** Nothing persisted: for tests, and for a host composed without browser storage. */
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
let mode = initial;
return {
read: () => mode,
write(next) {
mode = next;
},
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* A single top-level segment that is not a platform page names a module's page. The id is only
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/${encodeURIComponent(route.moduleId)}`;
}
+54
View File
@@ -0,0 +1,54 @@
import { type DashboardDestination, parseDashboardPath } from "./dashboard-routes.js";
/** Long enough for any Dashboard address; short enough that nothing else hides in one. */
const MAXIMUM_LENGTH = 2048;
const PLACEHOLDER_ORIGIN = "https://dashboard.invalid";
/**
* Reads a "return to where I came from" value into a Dashboard destination, or refuses it
* (ADR 0059).
*
* A return target is input: it arrives from a link another page or module built, so it is treated
* as hostile until proven otherwise. ADR 0034 requires any return location to be a separately
* validated, same-origin route decision. This is that validation, and it is deliberately narrow:
*
* - a string of at most 2 KiB, beginning with exactly one `/`;
* - no whitespace or control character, no backslash, no percent-encoded slash, backslash, or dot,
* and no dot-segment — nothing a browser would normalize into a different address;
* - no query and no fragment, for now;
* - and it must be a page the Dashboard actually has (`parseDashboardPath` is not `not-found`).
*
* The answer is a destination, never the raw string, so the caller can only ever navigate to the
* canonical spelling of a real page. How a link carries a return target is the module navigation
* contract's decision (ADR 0057); whatever carries it hands the value here first.
*/
export function parseReturnTarget(value: unknown): DashboardDestination | undefined {
if (typeof value !== "string" || value.length === 0 || value.length > MAXIMUM_LENGTH) {
return undefined;
}
if (!value.startsWith("/") || value.startsWith("//")) {
return undefined;
}
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
if (/[\s\u0000-\u001f\u007f\\]/u.test(value) || /%(?:2f|5c|2e)/iu.test(value)) {
return undefined;
}
let url: URL;
try {
url = new URL(value, PLACEHOLDER_ORIGIN);
} catch {
return undefined;
}
if (
url.origin !== PLACEHOLDER_ORIGIN ||
url.search !== "" ||
url.hash !== "" ||
value.includes("?") ||
value.includes("#") ||
url.pathname !== value
) {
return undefined;
}
const route = parseDashboardPath(url.pathname);
return route.kind === "not-found" ? undefined : route;
}
+20 -2
View File
@@ -3,6 +3,11 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import {
createLocalSidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
@@ -94,7 +99,16 @@ function platform(stub: AuthStub = {}): Platform {
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
// The production store over this browser's real storage, so the assertions below cover
// the shell's one use of it (ADR 0058) as well.
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
{...(authLanding ? { authLanding } : {})}
/>,
);
});
}
@@ -456,7 +470,11 @@ describe("reset password", () => {
// A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else.
expect(localStorage.length).toBe(0);
// The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
for (let index = 0; index < localStorage.length; index += 1) {
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
}
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc");
@@ -0,0 +1,94 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});
+10 -1
View File
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
@@ -393,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
});
};
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
await act(async () =>
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
);
// The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management",
+79 -18
View File
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createBrowserHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
@@ -122,12 +123,34 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
};
}
/**
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
* is — `/`, the Overview, unless a test put something else there.
*/
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
path?: string,
): Promise<void> {
if (path !== undefined) {
window.history.replaceState(null, "", path);
}
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
root.render(
<HostApp
config={config}
history={createBrowserHostHistory(window)}
{...(importModule ? { importModule } : {})}
/>,
);
});
}
/** Sign-out lives in the account menu at the bottom of the sidebar (ADR 0058). */
async function openAccountMenu(): Promise<void> {
await act(async () => {
(query("host-account-menu") as HTMLButtonElement).click();
});
}
@@ -268,6 +291,11 @@ describe("Ekklesia shell — bootstrap", () => {
});
describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
@@ -370,7 +398,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("clears the password while post-sign-in principal resolution is still pending", async () => {
@@ -403,7 +431,7 @@ describe("Ekklesia shell — browser authentication", () => {
await principalPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("submits from the password field with the Enter key", async () => {
@@ -433,7 +461,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("re-resolves principal and navigation after login without a page reload", async () => {
@@ -446,7 +474,7 @@ describe("Ekklesia shell — browser authentication", () => {
await submitLogin();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
@@ -503,7 +531,7 @@ describe("Ekklesia shell — browser authentication", () => {
await act(async () => {
(retry as HTMLButtonElement).click();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
@@ -534,11 +562,16 @@ describe("Ekklesia shell — browser authentication", () => {
(retry as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
});
describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
@@ -584,6 +617,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}),
);
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -642,6 +676,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
if (!lateRequest) {
throw new Error("module context was not captured");
}
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -681,6 +716,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
});
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -856,10 +892,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("nav button")].map(
(button) => button.textContent,
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
expect(links).toEqual(["Calendar", "Livestream", "Video"]);
});
it("renders ungrouped modules alongside groups, under a heading", async () => {
@@ -897,14 +931,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
expect(query("group-media")?.textContent).toBe("Media");
});
it("selects the first module in the arranged order and marks it active", async () => {
it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
importModule,
);
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("module-container")).toBeNull();
expect(importModule).not.toHaveBeenCalled();
});
it("marks the open module's link as the current page", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
});
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
@@ -915,6 +964,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
"/video",
);
await act(async () => {
@@ -972,6 +1022,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
});
describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -979,9 +1034,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
const aside = shell.querySelector("aside") as HTMLElement;
const main = shell.querySelector("main") as HTMLElement;
const frame = query("module-container") as HTMLElement;
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("15rem");
// The sidebar sizes its own track (ADR 0058); the page track can shrink to nothing but never
// pushes the sidebar.
expect(shell.style.gridTemplateColumns).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("16rem");
expect(main.style.minWidth).toBe("0");
expect(main.style.overflowX).toBe("auto");
expect(frame.style.minWidth).toBe("0");
@@ -1140,6 +1196,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
});
describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/example");
});
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js";
describe("the browser host history", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/");
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
it("reads the address bar's path and moves it with real history entries", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/example");
expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(lengthBefore + 1);
});
it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window);
history.push("/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
});
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/example/");
const length = window.history.length;
history.replace("/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length);
});
it("notifies after its own changes and after the browser's Back and Forward", () => {
const history = createBrowserHostHistory(window);
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/a");
history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/c");
expect(listener).toHaveBeenCalledTimes(3);
});
it("listens to popstate only while something is subscribed", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
const first = history.subscribe(() => undefined);
const second = history.subscribe(() => undefined);
expect(view.addEventListener).toHaveBeenCalledTimes(1);
first();
expect(view.removeEventListener).not.toHaveBeenCalled();
second();
expect(view.removeEventListener).toHaveBeenCalledTimes(1);
expect(view.removeEventListener.mock.calls[0]?.[1]).toBe(
view.addEventListener.mock.calls[0]?.[1],
);
});
it("stores no state object, so nothing a page put in history can be read back as authority", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
history.push("/a");
history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/b");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
history.push("/a");
history.push("/b");
history.go(-1);
expect(history.pathname()).toBe("/a");
history.push("/c");
expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/c");
});
});
+360
View File
@@ -0,0 +1,360 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives
* the real shell through an in-memory history, so it can assert on the exact entries a person's
* Back and Forward buttons would walk.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
function platform(options: { authenticated?: boolean } = {}) {
let authenticated = options.authenticated ?? true;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/auth/sign-in/email")) {
void init;
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return fetchImplementation;
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly imported: string[];
readonly titles: string[];
}
async function render(
path: string,
options: { authenticated?: boolean; history?: MemoryHostHistory } = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const imported: string[] = [];
const titles: string[] = [];
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => {
imported.push(url);
return artifactFor(url);
}}
setDocumentTitle={(title) => titles.push(title)}
/>,
);
});
return { history, imported, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> {
if (!element) {
throw new Error("missing element");
}
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function go(history: MemoryHostHistory, delta: number): Promise<void> {
await act(async () => {
history.go(delta);
});
}
describe("the Dashboard opens on the page its URL names", () => {
it("lands on the Overview at the root, and mounts no module", async () => {
const { imported, titles } = await render("/");
expect(query("host-overview")).not.toBeNull();
expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview");
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(titles.at(-1)).toBe("Overview");
});
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page");
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
expect(query("host-overview")).toBeNull();
expect(titles.at(-1)).toBe("Calendar");
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
});
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
// The address is never echoed back into the page.
expect(container.textContent).not.toContain("disabled-module");
expect(titles.at(-1)).toBe("This page isn’t available");
expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/");
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { imported, titles } = await render(path);
expect(query("host-not-found"), path).not.toBeNull();
expect(imported, path).toEqual([]);
expect(titles.at(-1), path).toBe("Page not found");
// Nothing in the navigation claims to be the open page.
expect(container.querySelector('[aria-current="page"]'), path).toBeNull();
}
});
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(query("host-not-found")).not.toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1);
});
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(email, "[email protected]");
email.dispatchEvent(new Event("input", { bubbles: true }));
setter?.call(password, "correct password");
password.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
query("host-login")
?.querySelector("form")
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/calendar"]);
});
});
describe("moving between pages", () => {
it("renders navigation as real links to canonical addresses", async () => {
await render("/");
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
expect(link?.getAttribute("href"), testId).toBe(href);
}
expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull();
});
it("opens a page in place on an ordinary click, adding one history entry", async () => {
const { history, imported } = await render("/");
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => {
const { history } = await render("/");
for (const init of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
const event = await click(query("select-calendar"), init);
expect(event.defaultPrevented, JSON.stringify(init)).toBe(false);
}
expect(history.entries).toEqual(["/"]);
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
const { history, imported } = await render("/");
await click(query("select-signage"));
await click(query("select-calendar"));
await go(history, -1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
await go(history, -1);
expect(query("host-overview")).not.toBeNull();
expect(query("module-container")).toBeNull();
await go(history, 1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(imported).toEqual([
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
]);
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});
it("moves focus to the new page's content after a page change, but not on first load", async () => {
await render("/");
const main = container.querySelector("main");
expect(main?.getAttribute("tabindex")).toBe("-1");
expect(document.activeElement).not.toBe(main);
await click(query("select-calendar"));
expect(document.activeElement).toBe(main);
expect(main?.getAttribute("aria-label")).toBe("Calendar");
});
it("names the browser tab after each page as it opens", async () => {
const { titles } = await render("/");
await click(query("select-signage"));
await click(query("select-overview"));
expect(titles).toEqual(["Overview", "Signage", "Overview"]);
});
});
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { dashboardBack } from "../src/browser/dashboard-back.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { parseReturnTarget } from "../src/return-target.js";
describe("parseReturnTarget", () => {
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
});
it("refuses anything that is not a string, or is empty or enormous", () => {
for (const value of [undefined, null, 42, {}, ["/signage"], "", `/${"a".repeat(3000)}`]) {
expect(parseReturnTarget(value), JSON.stringify(value)).toBeUndefined();
}
});
it("refuses every way of naming another origin", () => {
for (const value of [
"https://evil.example/signage",
"http://localhost/signage",
"//evil.example/signage",
"/\\evil.example",
"\\\\evil.example",
"javascript:alert(1)",
"data:text/html,hi",
" /signage",
"/signage\n",
"/sign\tage",
"signage",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
it("refuses traversal and encoded separators that would normalize into something else", () => {
for (const value of [
"/signage/../settings",
"/./signage",
"/signage/%2e%2e/api",
"/%2F%2Fevil.example",
"/%5Cevil.example",
"/signage%2Fx",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
it("refuses a query or fragment, and any path the Dashboard has no page for", () => {
for (const value of [
"/signage?x=1",
"/signage#top",
"/api/v1/principal",
"/display/signage",
"/settings",
"/signage/timelines",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
});
describe("dashboardBack", () => {
it("goes back to a validated origin when one was supplied", () => {
const history = createMemoryHostHistory("/calendar");
const back = dashboardBack(history, {
fallback: { kind: "overview" },
label: (destination) => (destination.kind === "module" ? "Back to Signage" : "Back"),
returnTarget: "/signage",
});
expect(back.href).toBe("/signage");
expect(back.label).toBe("Back to Signage");
back.onBack();
expect(history.entries).toEqual(["/calendar", "/signage"]);
});
it("falls back to the page's own parent when the target is missing or unsafe", () => {
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
const history = createMemoryHostHistory("/calendar");
const back = dashboardBack(history, {
fallback: { kind: "overview" },
returnTarget,
});
expect(back.href, String(returnTarget)).toBe("/");
expect(back.label, String(returnTarget)).toBe("Back");
back.onBack();
expect(history.entries, String(returnTarget)).toEqual(["/calendar", "/"]);
}
});
it("never leaves the Dashboard through the browser's own history", () => {
const history = createMemoryHostHistory("/calendar");
const go = vi.spyOn(history, "go");
dashboardBack(history, { fallback: { kind: "overview" } }).onBack();
expect(go).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import {
createBrowserHostViewport,
createMemoryHostViewport,
HOST_COMPACT_MEDIA_QUERY,
} from "../src/browser/host-viewport.js";
import {
createLocalSidebarPreference,
createMemorySidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
function fakeStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
}),
values,
};
}
describe("the sidebar preference (this browser only, ADR 0058)", () => {
it("survives a reload: what one store writes, a new store over the same storage reads", () => {
const storage = fakeStorage();
createLocalSidebarPreference(storage).write("collapsed");
expect(storage.values.get(SIDEBAR_PREFERENCE_KEY)).toBe("collapsed");
expect(createLocalSidebarPreference(storage).read()).toBe("collapsed");
createLocalSidebarPreference(storage).write("expanded");
expect(createLocalSidebarPreference(storage).read()).toBe("expanded");
});
it("uses one namespaced key that names no person", () => {
expect(SIDEBAR_PREFERENCE_KEY).toBe("ekklesia.dashboard.sidebar");
});
it("ignores a stored value it does not recognise rather than trusting it", () => {
for (const value of ["", "COLLAPSED", "true", "{}", "collapsed "]) {
expect(
createLocalSidebarPreference(fakeStorage({ [SIDEBAR_PREFERENCE_KEY]: value })).read(),
value,
).toBeUndefined();
}
});
it("keeps working, for this page only, when storage is missing or throws", () => {
const throwing = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
for (const storage of [undefined, throwing]) {
const preference = createLocalSidebarPreference(storage);
expect(preference.read()).toBeUndefined();
expect(() => preference.write("collapsed")).not.toThrow();
expect(preference.read()).toBe("collapsed");
}
});
it("has an in-memory store with the same contract, for tests and as the fallback", () => {
const preference = createMemorySidebarPreference("collapsed");
expect(preference.read()).toBe("collapsed");
preference.write("expanded");
expect(preference.read()).toBe("expanded");
expect(createMemorySidebarPreference().read()).toBeUndefined();
});
});
describe("the host viewport", () => {
it("is compact below 48em, the phone layout's breakpoint", () => {
expect(HOST_COMPACT_MEDIA_QUERY).toBe("(max-width: 47.999em)");
});
it("reads and follows a media query list", () => {
let matches = false;
const listeners = new Set<() => void>();
const mediaQuery = {
addEventListener: vi.fn((_type: "change", listener: () => void) => listeners.add(listener)),
get matches() {
return matches;
},
removeEventListener: vi.fn((_type: "change", listener: () => void) =>
listeners.delete(listener),
),
};
const matchMedia = vi.fn(() => mediaQuery);
const viewport = createBrowserHostViewport(matchMedia);
expect(matchMedia).toHaveBeenCalledWith(HOST_COMPACT_MEDIA_QUERY);
const listener = vi.fn();
const unsubscribe = viewport.subscribe(listener);
expect(viewport.isCompact()).toBe(false);
matches = true;
for (const notify of listeners) {
notify();
}
expect(listener).toHaveBeenCalledTimes(1);
expect(viewport.isCompact()).toBe(true);
unsubscribe();
expect(mediaQuery.removeEventListener).toHaveBeenCalledTimes(1);
});
it("falls back to the wide layout where media queries are unavailable", () => {
const viewport = createBrowserHostViewport(undefined);
expect(viewport.isCompact()).toBe(false);
expect(() => viewport.subscribe(() => undefined)()).not.toThrow();
});
it("has an in-memory viewport a test can resize", () => {
const viewport = createMemoryHostViewport(true);
const listener = vi.fn();
viewport.subscribe(listener);
viewport.setCompact(false);
expect(viewport.isCompact()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
viewport.setCompact(false);
expect(listener).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,687 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import { createMemoryHostViewport, type MemoryHostViewport } from "../src/browser/host-viewport.js";
import {
createLocalSidebarPreference,
createMemorySidebarPreference,
type SidebarPreference,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard shell's navigation (ADR 0058): the collapsible desktop sidebar, its account area,
* and the phone drawer. Every test drives the real `HostApp` with in-memory history, viewport, and
* sidebar preference, so routing, resizing, and persistence are all observable.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
window.localStorage.clear();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
// What the server's navigation returns: the eligible modules only. A disabled module (`retired`) is
// simply absent from it (ADR 0028), which is the whole of "hidden from navigation".
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
interface Platform {
readonly fetch: typeof fetch;
readonly calls: string[];
}
function platform(): Platform {
let authenticated = true;
const calls: string[] = [];
const implementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/auth/sign-out")) {
authenticated = false;
return Response.json({ success: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.endsWith("/api/v1/devices")) {
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return { calls, fetch: implementation };
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly viewport: MemoryHostViewport;
readonly preference: SidebarPreference;
readonly platform: Platform;
}
async function render(
options: {
readonly path?: string;
readonly compact?: boolean;
readonly preference?: SidebarPreference;
readonly history?: MemoryHostHistory;
readonly viewport?: MemoryHostViewport;
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(options.path ?? "/");
const viewport = options.viewport ?? createMemoryHostViewport(options.compact ?? false);
const preference = options.preference ?? createMemorySidebarPreference();
const stub = platform();
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: stub.fetch };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => artifactFor(url)}
sidebarPreference={preference}
viewport={viewport}
/>,
);
});
return { history, platform: stub, preference, viewport };
}
async function remount(rendered: Rendered, preference = rendered.preference): Promise<void> {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render({ history: rendered.history, preference, viewport: rendered.viewport });
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function click(element: Element, init: MouseEventInit = {}): Promise<MouseEvent> {
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function key(element: Element, keyName: string, init: KeyboardEventInit = {}) {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: keyName,
...init,
});
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function focus(element: HTMLElement): Promise<void> {
await act(async () => {
element.focus();
});
}
const sidebar = (): HTMLElement => required("host-sidebar");
const navLink = (id: string): HTMLElement => required(`select-${id}`);
describe("desktop sidebar", () => {
it("is expanded by default: icons and labels, Overview first, then the modules in the server's order", async () => {
await render();
expect(sidebar().dataset.sidebar).toBe("expanded");
const links = [...sidebar().querySelectorAll("nav a")];
expect(links.map((link) => link.textContent)).toEqual(["Overview", "Signage", "Calendar"]);
for (const link of links) {
const icon = link.querySelector("svg");
expect(icon?.getAttribute("aria-hidden"), link.textContent ?? "").toBe("true");
}
expect(query("nav-tooltip")).toBeNull();
});
it("draws the Overview with its own icon and every module with the one generic module icon", async () => {
await render();
expect(navLink("overview").querySelector("svg")?.dataset.icon).toBe("overview");
for (const id of ["signage", "calendar"]) {
expect(navLink(id).querySelector("svg")?.dataset.icon, id).toBe("module");
expect(navLink(id).querySelector("svg")?.classList.contains("lucide-blocks"), id).toBe(true);
}
});
it("marks the open page, the Overview or a module, with aria-current", async () => {
await render({ path: "/calendar" });
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
expect(navLink("overview").getAttribute("aria-current")).toBeNull();
await click(navLink("overview"));
expect(navLink("overview").getAttribute("aria-current")).toBe("page");
expect(navLink("calendar").getAttribute("aria-current")).toBeNull();
});
it("does not list a disabled module, whose address still says it is unavailable", async () => {
await render({ path: "/retired" });
expect(query("select-retired")).toBeNull();
expect(query("host-module-unavailable")).not.toBeNull();
expect(container.querySelector('nav [aria-current="page"]')).toBeNull();
});
it("collapses to icons only, through a labelled toggle that says what it controls", async () => {
const { preference } = await render();
const toggle = required("sidebar-toggle");
const nav = sidebar().querySelector("nav");
expect(toggle.tagName).toBe("BUTTON");
expect(toggle.getAttribute("aria-label")).toBe("Collapse navigation");
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(toggle.getAttribute("aria-controls")).toBe(nav?.id);
expect(nav?.id).toBeTruthy();
await click(toggle);
expect(sidebar().dataset.sidebar).toBe("collapsed");
expect(toggle.getAttribute("aria-label")).toBe("Expand navigation");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(preference.read()).toBe("collapsed");
// Icon-only to the eye, still named to assistive technology.
for (const id of ["overview", "signage", "calendar"]) {
const label = navLink(id).querySelector("[data-nav-label]");
expect(label?.classList.contains("ek-shell-visually-hidden"), id).toBe(true);
expect(navLink(id).textContent, id).not.toBe("");
}
await click(toggle);
expect(sidebar().dataset.sidebar).toBe("expanded");
expect(preference.read()).toBe("expanded");
});
it("keeps the chosen mode across a remount, as across a reload", async () => {
const rendered = await render();
await click(required("sidebar-toggle"));
await remount(rendered);
expect(sidebar().dataset.sidebar).toBe("collapsed");
});
it("keeps it in this browser's own storage, so a reload with a fresh page still finds it", async () => {
const rendered = await render({
preference: createLocalSidebarPreference(window.localStorage),
});
await click(required("sidebar-toggle"));
await remount(rendered, createLocalSidebarPreference(window.localStorage));
expect(sidebar().dataset.sidebar).toBe("collapsed");
});
it("names a collapsed item in a tooltip on keyboard focus and on hover, and Escape dismisses it", async () => {
await render({ preference: createMemorySidebarPreference("collapsed") });
await focus(navLink("signage"));
const tooltip = required("nav-tooltip");
expect(tooltip.textContent).toBe("Signage");
// A visual duplicate of the link's own accessible name, so it is not announced twice.
expect(tooltip.getAttribute("aria-hidden")).toBe("true");
await key(navLink("signage"), "Escape");
expect(query("nav-tooltip")).toBeNull();
// Escape hides the tooltip without moving focus.
expect(document.activeElement).toBe(navLink("signage"));
await act(async () => {
navLink("signage").blur();
});
await act(async () => {
navLink("calendar").dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
expect(required("nav-tooltip").textContent).toBe("Calendar");
});
it("shows no tooltip while expanded, where the label is already visible", async () => {
await render();
await focus(navLink("signage"));
expect(query("nav-tooltip")).toBeNull();
});
it("names the collapse toggle and the account control in tooltips when collapsed", async () => {
await render({ preference: createMemorySidebarPreference("collapsed") });
await focus(required("sidebar-toggle"));
expect(required("nav-tooltip").textContent).toBe("Expand navigation");
await focus(required("host-account-menu"));
expect(required("nav-tooltip").textContent).toBe("Your account");
});
it("is the only navigation: no application top bar", async () => {
await render();
expect(container.querySelector('[data-testid="host-shell"] > header')).toBeNull();
expect(query("mobile-navigation-trigger")).toBeNull();
// The neutral identity placeholder sits at the top of the sidebar, and is not a heading: each
// page's own title is.
const brand = required("shell-brand");
expect(sidebar().contains(brand)).toBe(true);
expect(brand.querySelector("h1, h2")).toBeNull();
});
it("orders keyboard focus top to bottom: toggle, Overview, modules, then the account control", async () => {
await render();
const focusable = [
...sidebar().querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
].map((element) => element.dataset.testid);
expect(focusable).toEqual([
"sidebar-toggle",
"select-overview",
"select-signage",
"select-calendar",
"host-account-menu",
]);
});
it("gives every navigation item a pointer and touch target of at least the medium control height", async () => {
await render();
for (const id of ["overview", "signage", "calendar"]) {
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-md)");
}
});
});
describe("the account area", () => {
it("is anchored at the bottom of the sidebar as a product-owned menu button", async () => {
await render();
const trigger = required("host-account-menu");
expect(sidebar().lastElementChild?.contains(trigger)).toBe(true);
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("Your account");
// Only the opaque user id is known to the shell, and it is never shown.
expect(container.textContent).not.toContain("user-1");
expect(query("host-account-menu-list")).toBeNull();
});
it("opens on activation with focus on its first item, and offers only actions that work today", async () => {
await render();
const trigger = required("host-account-menu");
await click(trigger);
const menu = required("host-account-menu-list");
expect(menu.getAttribute("role")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
const items = [...menu.querySelectorAll('[role="menuitem"]')];
expect(items.map((item) => item.textContent)).toEqual(["Sign out"]);
expect(document.activeElement).toBe(items[0]);
expect(menu.querySelector("a")).toBeNull();
});
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
await render();
const trigger = required("host-account-menu");
await focus(trigger);
await key(trigger, "ArrowDown");
expect(document.activeElement).toBe(required("host-logout"));
await key(required("host-logout"), "Escape");
expect(query("host-account-menu-list")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("closes when focus or a pointer leaves it, without signing anyone out", async () => {
const { platform: stub } = await render();
await click(required("host-account-menu"));
await act(async () => {
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
});
expect(query("host-account-menu-list")).toBeNull();
await click(required("host-account-menu"));
await key(required("host-logout"), "Tab");
expect(query("host-account-menu-list")).toBeNull();
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(false);
});
it("signs out from the menu, back to the sign-in form", async () => {
const { platform: stub } = await render({ path: "/calendar" });
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(query("host-login")).not.toBeNull();
expect(query("host-sidebar")).toBeNull();
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(true);
});
it("never uses a browser-native dialog", async () => {
const alert = vi.fn();
const confirm = vi.fn(() => true);
const prompt = vi.fn(() => null);
vi.stubGlobal("alert", alert);
vi.stubGlobal("confirm", confirm);
vi.stubGlobal("prompt", prompt);
try {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await key(navLink("overview"), "Escape");
await click(required("mobile-navigation-trigger"));
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(alert).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
});
describe("phone navigation drawer", () => {
it("replaces the sidebar with one labelled menu trigger, and renders no navigation until opened", async () => {
await render({ compact: true });
expect(query("host-sidebar")).toBeNull();
const trigger = required("mobile-navigation-trigger");
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("aria-label")).toBe("Open navigation");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBe("ekklesia-navigation-drawer");
expect(query("navigation-drawer")).toBeNull();
expect(query("host-overview")).not.toBeNull();
});
it("opens as a modal dialog with the same navigation, focus inside, and the page inert", async () => {
await render({ compact: true, path: "/calendar" });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
const drawer = required("navigation-drawer");
expect(drawer.id).toBe("ekklesia-navigation-drawer");
expect(drawer.getAttribute("role")).toBe("dialog");
expect(drawer.getAttribute("aria-modal")).toBe("true");
expect(drawer.getAttribute("aria-label")).toBe("Navigation");
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(required("navigation-drawer-close"));
expect([...drawer.querySelectorAll("nav a")].map((link) => link.textContent)).toEqual([
"Overview",
"Signage",
"Calendar",
]);
// The same hierarchy and the same current page as on desktop, always with labels.
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
expect(drawer.querySelector(".ek-shell-visually-hidden[data-nav-label]")).toBeNull();
expect(required("host-main-region").hasAttribute("inert")).toBe(true);
expect(drawer.contains(required("host-account-menu"))).toBe(true);
});
it("closes on Escape and returns focus to the trigger", async () => {
await render({ compact: true });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
await key(navLink("signage"), "Escape");
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
});
it("closes from its close button and from the backdrop, returning focus each time", async () => {
await render({ compact: true });
const trigger = required("mobile-navigation-trigger");
await click(trigger);
await click(required("navigation-drawer-close"));
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
await click(trigger);
await click(required("navigation-drawer-backdrop"));
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("traps Tab and Shift+Tab inside itself while open", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
const focusable = [...drawer.querySelectorAll<HTMLElement>("a[href], button:not([disabled])")];
const first = focusable[0] as HTMLElement;
const last = focusable.at(-1) as HTMLElement;
expect(first).toBe(required("navigation-drawer-close"));
expect(last).toBe(required("host-account-menu"));
await focus(last);
const forward = await key(last, "Tab");
expect(forward.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(first);
const backward = await key(first, "Tab", { shiftKey: true });
expect(backward.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(last);
// Tab between two inner items is left to the browser.
await focus(navLink("overview"));
expect((await key(navLink("overview"), "Tab")).defaultPrevented).toBe(false);
});
it("closes when a destination is chosen, opens that page, and puts focus on the page", async () => {
const { history } = await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(navLink("signage"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/", "/signage"]);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(document.activeElement).toBe(required("host-main-region"));
});
it("closes when the page already open is chosen, and still moves focus to the page", async () => {
const { history } = await render({ compact: true, path: "/calendar" });
await click(required("mobile-navigation-trigger"));
await click(navLink("calendar"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/calendar"]);
expect(document.activeElement).toBe(required("host-main-region"));
});
it("closes on Back and Forward, which keep working with the drawer", async () => {
const { history } = await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(navLink("calendar"));
await click(required("mobile-navigation-trigger"));
await act(async () => {
history.go(-1);
});
expect(query("navigation-drawer")).toBeNull();
expect(query("host-overview")).not.toBeNull();
await act(async () => {
history.go(1);
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("signs out from the account menu inside the drawer", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(query("host-login")).not.toBeNull();
});
it("gives drawer items a touch target of at least the large control height", async () => {
await render({ compact: true });
await click(required("mobile-navigation-trigger"));
for (const id of ["overview", "signage", "calendar"]) {
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-lg)");
}
expect(required("mobile-navigation-trigger").style.minHeight).toBe(
"var(--ekklesia-control-height-lg)",
);
});
});
describe("moving between phone and desktop layouts", () => {
it("keeps the open page and the desktop mode, and drops an open drawer cleanly", async () => {
const { viewport, history } = await render({
compact: true,
path: "/signage",
preference: createMemorySidebarPreference("collapsed"),
});
await click(required("mobile-navigation-trigger"));
await focus(navLink("calendar"));
await act(async () => {
viewport.setCompact(false);
});
expect(query("navigation-drawer")).toBeNull();
expect(sidebar().dataset.sidebar).toBe("collapsed");
expect(navLink("signage").getAttribute("aria-current")).toBe("page");
expect(history.entries).toEqual(["/signage"]);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
// Focus was inside the drawer that went away; it lands on the page rather than nowhere.
expect(document.activeElement).toBe(required("host-main-region"));
await act(async () => {
viewport.setCompact(true);
});
expect(query("host-sidebar")).toBeNull();
expect(query("navigation-drawer")).toBeNull();
expect(required("mobile-navigation-trigger").getAttribute("aria-expanded")).toBe("false");
});
it("does not remount the open module when the layout changes", async () => {
const mounts: string[] = [];
const viewport = createMemoryHostViewport(false);
const history = createMemoryHostHistory("/signage");
const stub = platform();
await act(async () => {
root.render(
<HostApp
config={{ apiBaseUrl, fetch: stub.fetch }}
history={history}
importModule={async (url) => {
mounts.push(url);
return artifactFor(url);
}}
sidebarPreference={createMemorySidebarPreference()}
viewport={viewport}
/>,
);
});
await act(async () => {
viewport.setCompact(true);
});
await act(async () => {
viewport.setCompact(false);
});
expect(mounts).toHaveLength(1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
});
});
+1
View File
@@ -11,6 +11,7 @@
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
+28
View File
@@ -128,6 +128,34 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
});
it("never sweep another run's live stack early, not even from the sweep's own self-test", async () => {
// The workflow's stale sweeps keep the default threshold, which is far above any job's run time.
const workflow = await source(".github/workflows/ci.yml");
const workflowSweeps = workflow
.split("\n")
.filter((line) => line.includes("ci-sweep.sh --stale"));
expect(workflowSweeps.length).toBeGreaterThan(0);
for (const line of workflowSweeps) {
expect(line).not.toContain("--max-age-seconds");
}
// The self-test does use short thresholds, so every stale sweep it runs is confined to its own
// fake run. Unconfined, `--max-age-seconds 0` removed every CI stack on the shared daemon —
// including a module repository's integration stack mid-run.
const selfTest = await source("scripts/test-ci-sweep.sh");
expect(selfTest).toMatch(/^own_run="\$\(ci_name_safe "ekklesia-ci-\$\{GITHUB_RUN_ID\}-"\)"$/mu);
expect(selfTest.indexOf('export GITHUB_RUN_ID="sweeptest')).toBeLessThan(
selfTest.indexOf("own_run="),
);
const selfTestSweeps = selfTest
.split("\n")
.filter((line) => /^\s*bash scripts\/ci-sweep\.sh --stale/u.test(line));
expect(selfTestSweeps.length).toBeGreaterThan(0);
for (const line of selfTestSweeps) {
expect(line).toContain('--prefix "$own_run"');
}
});
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
+64
View File
@@ -189,6 +189,12 @@ describe("reference deployment compose files", () => {
.join("\n");
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
// The Dashboard's other pages (ADR 0057): every top-level name shaped like a module id, and
// nothing else, so the API — which knows the installed modules — decides page or 404.
expect(directives).toContain(
`@dashboardPage path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`,
);
expect(directives).toMatch(/handle @dashboardPage \{\s*reverse_proxy api:3000 \{/u);
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
expect(directives).toContain("header_up -CF-Connecting-IP");
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
@@ -200,6 +206,64 @@ describe("reference deployment compose files", () => {
});
});
/** The proxies' Dashboard-page matcher: a module id's grammar (`@ekklesia/contracts`) as the first
* segment, then anything. Checked against real paths below so the regular expression itself is
* tested, not just its spelling. */
const DASHBOARD_PAGE_PATTERN = "^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$";
describe("the proxies' Dashboard-page matcher", () => {
const matcher = new RegExp(DASHBOARD_PAGE_PATTERN, "u");
it("forwards every Dashboard page and module-shaped name", () => {
for (const path of [
"/settings",
"/settings/displays/add",
"/account",
"/signage",
"/signage/",
"/signage/timelines",
"/sunday-production",
"/definitely-not-a-real-module",
]) {
expect(matcher.test(path), path).toBe(true);
}
});
it("forwards nothing that could not be a module id", () => {
for (const path of [
"/",
"/favicon.ico",
"/robots.txt",
"/.well-known/x",
"/Signage",
"/-x",
"/1x",
"//signage",
"/a--b",
]) {
expect(matcher.test(path), path).toBe(false);
}
});
});
describe("split-origin reference proxy", () => {
it("serves the Dashboard's page, its deep links, and its script on the Dashboard hostname, and no API", async () => {
const caddyfile = await text("Caddyfile.split.example");
const directives = caddyfile
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.join("\n");
expect(directives).toContain("@dashboard path / /assets/ekklesia-dashboard/*");
expect(directives).toContain(`path_regexp dashboardPage ${DASHBOARD_PAGE_PATTERN}`);
// The API's own namespaces stay refused on the Dashboard hostname even though they are shaped
// like a module id.
expect(directives).toContain("not path /api /api/* /display /display/* /assets /assets/*");
// The API hostname still serves no Dashboard page at all.
expect(directives).toContain("@api path /api/* /display/* /assets/ekklesia-display/*");
});
});
describe("reference deployment env examples", () => {
it("contain no secret values", async () => {
const secretKeys = new Map([
+19
View File
@@ -0,0 +1,19 @@
// Nothing here is a browser dialog, and nothing here may be reported.
const confirm = (value: string): string => value;
confirm("a local function that happens to share the name");
function prompt(text: string): string {
return text;
}
prompt("a local declaration");
const dialog = { alert: () => 1, confirm: () => true, prompt: () => "" };
dialog.confirm();
dialog.alert();
dialog.prompt();
const props = { onConfirm: () => undefined };
props.onConfirm();
const confirmLabel = "Delete display";
const message = "Call alert( only in a string";
const pattern = /window\.confirm/u;
// window.confirm("a comment is not code");
/* alert("nor is a block comment"); */
export { confirmLabel, message, pattern };
@@ -0,0 +1,4 @@
// Forms Biome's call-based rule does not see; the repository scan must refuse each one.
self.prompt("a");
const borrowed = window.confirm;
export { borrowed };
+9
View File
@@ -0,0 +1,9 @@
// Every line below is a browser-native dialog. The repository's lint must refuse each one.
alert("a");
window.alert("b");
confirm("c");
window.confirm("d");
prompt("e");
window.prompt("f");
globalThis.confirm("g");
window["alert"]("h");
+152
View File
@@ -0,0 +1,152 @@
import { execFile } from "node:child_process";
import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Browser-native dialogs — `alert`, `confirm`, `prompt` — are prohibited in product UI (DESIGN.md
* §8.1, ADR 0059). The guard is repository-wide and two-part:
*
* 1. Biome's scope-aware `suspicious/noAlert`, an error in the root `biome.json`, so `pnpm lint`
* refuses a dialog call anywhere in this repository — the shell, `@ekklesia/ui`, the module
* template — while leaving a local function or a method that shares the name alone.
* 2. A narrow scan of browser source for the two forms that rule cannot see: a call through `self`,
* `top`, `parent`, or `frames`, and borrowing the function without calling it.
*
* This file proves both against fixtures, with the repository's own lint configuration, so the
* guard is shown to catch real usage and to stay quiet about lookalikes.
*/
const execFileAsync = promisify(execFile);
const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtures = path.join(repositoryRoot, "tests", "fixtures", "native-dialogs");
const biome = path.join(repositoryRoot, "node_modules", ".bin", "biome");
/** Where browser code lives in this repository. */
const BROWSER_SOURCE_ROOTS = [
"surfaces",
path.join("packages", "ui", "src"),
path.join("packages", "module-ui-sdk", "src"),
path.join("packages", "module-ui-host", "src"),
path.join("templates", "module", "src"),
];
/** A dialog reached through a window alias, called or not: `self.prompt(`, `= window.confirm;`. */
const DIALOG_THROUGH_A_WINDOW =
/\b(?:window|globalThis|self|top|parent|frames)\s*(?:\.\s*|\[\s*["'`])(?:alert|confirm|prompt)\b/u;
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:\\])\/\/.*$/gmu, "$1");
}
/** Lines that reach a dialog in a way Biome's rule does not report. */
function scan(source: string): number[] {
return withoutComments(source)
.split("\n")
.flatMap((line, index) => {
const code = line.replace(/\/(?:\\.|[^/\n])+\/[a-z]*/gu, "").replace(/"[^"]*"|'[^']*'/gu, "");
return DIALOG_THROUGH_A_WINDOW.test(code) ? [index + 1] : [];
});
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!["node_modules", "dist", ".turbo", "tests", "test"].includes(entry.name)) {
files.push(...(await sourceFiles(full)));
}
} else if (/\.(?:ts|tsx|js|jsx|mjs)$/u.test(entry.name)) {
files.push(full);
}
}
return files;
}
let workspace: string;
/** Lints a fixture with exactly the root configuration's rules, from a directory the root
* configuration's own `files.includes` does not exclude. */
async function lint(
fixture: string,
): Promise<Array<{ readonly line: number; readonly rule: string }>> {
try {
await execFileAsync(biome, ["lint", "--max-diagnostics=100", fixture], {
cwd: workspace,
});
return [];
} catch (error) {
const output = `${(error as { stdout?: string }).stdout ?? ""}${(error as { stderr?: string }).stderr ?? ""}`;
return [...output.matchAll(/^\S*?(\d+):\d+ (lint\/\S+)/gmu)].map((match) => ({
line: Number(match[1]),
rule: match[2] ?? "",
}));
}
}
beforeAll(async () => {
workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-native-dialogs-"));
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
files?: unknown;
};
delete config.files;
await writeFile(path.join(workspace, "biome.json"), JSON.stringify(config));
await cp(fixtures, workspace, { recursive: true });
});
afterAll(async () => {
await rm(workspace, { force: true, recursive: true });
});
describe("the repository's lint refuses browser-native dialogs", () => {
it("makes noAlert an error in the root configuration", async () => {
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
linter?: { rules?: { suspicious?: { noAlert?: unknown } } };
};
expect(config.linter?.rules?.suspicious?.noAlert).toBe("error");
});
it("reports every dialog call", async () => {
const findings = await lint("prohibited.ts");
expect(findings.filter((finding) => finding.rule === "lint/suspicious/noAlert")).toEqual(
[2, 3, 4, 5, 6, 7, 8, 9].map((line) => ({ line, rule: "lint/suspicious/noAlert" })),
);
}, 30_000);
it("reports nothing about a local function, a method, a property, a string, or a comment", async () => {
expect(await lint("permitted.ts")).toEqual([]);
}, 30_000);
});
describe("the repository scan covers what the lint rule cannot see", () => {
it("catches a dialog called through self, or borrowed without being called", async () => {
const source = await readFile(path.join(fixtures, "prohibited-references.ts"), "utf8");
expect(scan(source)).toEqual([2, 3]);
});
it("stays quiet about the permitted fixture", async () => {
const source = await readFile(path.join(fixtures, "permitted.ts"), "utf8");
expect(scan(source)).toEqual([]);
});
it("finds no dialog anywhere in this repository's browser source", async () => {
const offenders: string[] = [];
let scanned = 0;
for (const sourceRoot of BROWSER_SOURCE_ROOTS) {
for (const file of await sourceFiles(path.join(repositoryRoot, sourceRoot))) {
scanned += 1;
for (const line of scan(await readFile(file, "utf8"))) {
offenders.push(`${path.relative(repositoryRoot, file)}:${line}`);
}
}
}
expect(scanned).toBeGreaterThan(20);
expect(offenders).toEqual([]);
});
});