From e03d38fcabe32c80812ca32048cec0d80620d7fe Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 27 Aug 2026 06:59:44 -0500 Subject: [PATCH 1/7] feat: give every Ekklesia screen one set of design tokens and elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules have owned their own UI since ADR 0021, but the platform has never said what that UI should look like. Every screen here — the Dashboard shell, the display host, the module template — invented its own hex colours, font stack, spacing, and radii, and they already disagreed. A module author had no vocabulary for "the ordinary panel background", so the only available answer was to invent one, and two modules would invent two. Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic tokens. A token declares a name, label, description, and default value; its category and `--ekklesia-*` identifier are derived from the name, so it can never claim one category while being named for another. Construction rejects duplicates, malformed names, unknown categories, empty or overlong text, and any value that is not a bounded literal — no declaration terminator, rule block, comment, at-rule, `url(`, `!important`, escape, control character, or markup. A registry that constructs can therefore be emitted as CSS without escaping, because nothing needing escaping can be in it. That validation is also what makes a future theme editor implementable rather than aspirational. Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and list primitives, badge, toast, and empty/error/loading states — every value a token reference, with real `:focus-visible` treatment and status that never depends on colour alone. Deliberately small: an element earns its place when two consumers need it. The stylesheet is a string rather than a `.css` file so the identical bytes install into a document or an isolated artifact root, and so hover, disabled, and focus-visible are real CSS rather than JavaScript approximations of them. `pnpm boundaries` opens exactly two shared roles to modules, by name — every other role stays rejected — and pins both design packages to the bottom of the presentation stack. Both are browser-safe by construction, proven by a real compile of module-shaped source in which `process.env` must fail to typecheck. The shell installs the defaults at its document root for the Dashboard and the display; custom properties inherit into module artifacts, so a module reads `var(--ekklesia-…)` with nothing to import at run time and nothing for a host to forget to pass. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 5 + .../0039-shared-design-tokens-and-elements.md | 156 +++++ docs/architecture/decisions/README.md | 1 + docs/architecture/repository-boundaries.md | 25 +- docs/module-development/README.md | 66 ++- packages/design-tokens/package.json | 32 + packages/design-tokens/src/index.ts | 3 + packages/design-tokens/src/install.ts | 72 +++ packages/design-tokens/src/registry.ts | 550 +++++++++++++++++ packages/design-tokens/src/token.ts | 282 +++++++++ .../tests/browser-safety.test.ts | 52 ++ packages/design-tokens/tests/registry.test.ts | 137 +++++ packages/design-tokens/tests/token.test.ts | 158 +++++ packages/design-tokens/tsconfig.build.json | 13 + packages/design-tokens/tsconfig.json | 8 + packages/design-tokens/tsconfig.test.json | 8 + packages/module-ui-sdk/src/host-context.ts | 13 +- packages/ui/package.json | 42 ++ packages/ui/src/EkklesiaUiStyles.tsx | 20 + packages/ui/src/class-names.ts | 11 + packages/ui/src/controls.tsx | 152 +++++ packages/ui/src/data.tsx | 91 +++ packages/ui/src/feedback.tsx | 161 +++++ packages/ui/src/index.ts | 76 +++ packages/ui/src/styles.ts | 556 ++++++++++++++++++ packages/ui/src/surfaces.tsx | 211 +++++++ packages/ui/tests/browser-boundary.test.ts | 46 ++ .../ui/tests/browser-boundary/node-global.tsx | 9 + .../ui/tests/browser-boundary/supported.tsx | 21 + .../tsconfig.node-global.json | 5 + .../browser-boundary/tsconfig.supported.json | 5 + packages/ui/tests/elements.test.tsx | 258 ++++++++ packages/ui/tests/tokens.test.ts | 84 +++ packages/ui/tsconfig.build.json | 13 + packages/ui/tsconfig.json | 8 + packages/ui/tsconfig.test.json | 9 + packages/ui/vitest.config.ts | 9 + pnpm-lock.yaml | 64 ++ scripts/check-boundaries.ts | 37 +- surfaces/shell/package.json | 2 + surfaces/shell/src/browser/HostApp.tsx | 105 ++-- surfaces/shell/src/browser/main.tsx | 22 +- surfaces/shell/src/display/main.tsx | 49 +- surfaces/shell/tests/modularity.test.ts | 12 +- tests/boundaries.test.ts | 37 +- .../packages/api-client/package.json | 11 + .../packages/api-client/src/index.ts | 1 + .../packages/design-tokens/package.json | 14 + .../packages/design-tokens/src/index.ts | 3 + .../packages/ui/package.json | 3 + .../packages/ui/src/index.ts | 3 + .../modules/calendar/package.json | 2 +- .../modules/calendar/src/index.ts | 3 + .../packages/surface-sdk/package.json | 11 + .../packages/surface-sdk/src/index.ts | 1 + .../modules/calendar/src/index.ts | 3 - .../packages/ui/src/index.ts | 1 - .../modules/calendar/package.json | 13 + .../modules/calendar/src/index.ts | 1 + .../modules/calendar/src/ui/index.tsx | 5 + .../packages/design-tokens/package.json | 11 + .../packages/design-tokens/src/index.ts | 3 + .../packages/ui/package.json | 14 + .../packages/ui/src/index.ts | 3 + 64 files changed, 3697 insertions(+), 105 deletions(-) create mode 100644 docs/architecture/decisions/0039-shared-design-tokens-and-elements.md create mode 100644 packages/design-tokens/package.json create mode 100644 packages/design-tokens/src/index.ts create mode 100644 packages/design-tokens/src/install.ts create mode 100644 packages/design-tokens/src/registry.ts create mode 100644 packages/design-tokens/src/token.ts create mode 100644 packages/design-tokens/tests/browser-safety.test.ts create mode 100644 packages/design-tokens/tests/registry.test.ts create mode 100644 packages/design-tokens/tests/token.test.ts create mode 100644 packages/design-tokens/tsconfig.build.json create mode 100644 packages/design-tokens/tsconfig.json create mode 100644 packages/design-tokens/tsconfig.test.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/EkklesiaUiStyles.tsx create mode 100644 packages/ui/src/class-names.ts create mode 100644 packages/ui/src/controls.tsx create mode 100644 packages/ui/src/data.tsx create mode 100644 packages/ui/src/feedback.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/styles.ts create mode 100644 packages/ui/src/surfaces.tsx create mode 100644 packages/ui/tests/browser-boundary.test.ts create mode 100644 packages/ui/tests/browser-boundary/node-global.tsx create mode 100644 packages/ui/tests/browser-boundary/supported.tsx create mode 100644 packages/ui/tests/browser-boundary/tsconfig.node-global.json create mode 100644 packages/ui/tests/browser-boundary/tsconfig.supported.json create mode 100644 packages/ui/tests/elements.test.tsx create mode 100644 packages/ui/tests/tokens.test.ts create mode 100644 packages/ui/tsconfig.build.json create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsconfig.test.json create mode 100644 packages/ui/vitest.config.ts create mode 100644 tests/fixtures/boundaries/design-package-depends-upward/packages/api-client/package.json create mode 100644 tests/fixtures/boundaries/design-package-depends-upward/packages/api-client/src/index.ts create mode 100644 tests/fixtures/boundaries/design-package-depends-upward/packages/design-tokens/package.json create mode 100644 tests/fixtures/boundaries/design-package-depends-upward/packages/design-tokens/src/index.ts rename tests/fixtures/boundaries/{module-imports-ui-package => design-package-depends-upward}/packages/ui/package.json (69%) create mode 100644 tests/fixtures/boundaries/design-package-depends-upward/packages/ui/src/index.ts rename tests/fixtures/boundaries/{module-imports-ui-package => module-imports-surface-sdk}/modules/calendar/package.json (78%) create mode 100644 tests/fixtures/boundaries/module-imports-surface-sdk/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-surface-sdk/packages/surface-sdk/package.json create mode 100644 tests/fixtures/boundaries/module-imports-surface-sdk/packages/surface-sdk/src/index.ts delete mode 100644 tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/src/index.ts delete mode 100644 tests/fixtures/boundaries/module-imports-ui-package/packages/ui/src/index.ts create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/modules/calendar/src/ui/index.tsx create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/packages/design-tokens/package.json create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/packages/design-tokens/src/index.ts create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/packages/ui/package.json create mode 100644 tests/fixtures/boundaries/module-uses-design-packages/packages/ui/src/index.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1833ffc..8aa8986 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,11 @@ pnpm infrastructure:config the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no bundler configuration, and styling is React inline style objects only for now. Both roles use the same UI SDK context with no presentation-mode flag. +- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and + `@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named + allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`) + stays rejected inside a module. Reference registered semantic tokens; never define an + `--ekklesia-*` custom property of your own. - Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never `@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own API by relative path — never by constructing `/api/v1/modules//...`, which is Ekklesia's diff --git a/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md new file mode 100644 index 0000000..1e11c1b --- /dev/null +++ b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md @@ -0,0 +1,156 @@ +# 0039: Shared design tokens and foundational elements + +- Status: Accepted +- Date: 2026-08-27 +- Supersedes: none +- Related: [ADR 0021](0021-modules-as-hostable-products.md), + [ADR 0022](0022-module-ui-authoring-and-host-contract.md), + [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md), + [ADR 0026](0026-minimal-ekklesia-module-ui-host.md), + [ADR 0033](0033-display-kiosk-host-contract.md) + +## Context + +ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose +React as the way to author it. Since then the platform has been able to *mount* a module's screen but +has said nothing at all about what that screen should look like. The consequences are now concrete: + +- Every screen in this repository — the Dashboard shell, the display host, the module template — + hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each + other. +- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour", + so the only available answer is to invent one. Two modules will invent two. +- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design + system, component library, or theme/design-token contract." +- A theme editor is a real product requirement for a church platform, and it is only implementable + if the values it would edit are enumerable with human-readable names — which is a property of the + *contract*, not something that can be retrofitted onto a pile of literals. + +The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared +design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with +it Node's global declarations — into a browser program. + +## Decision + +Add two shared browser-safe packages and open exactly two boundary roles to module UI. + +### `@ekklesia/design-tokens` — the semantic token registry + +A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one +validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`, +`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be, +because the point is that a value can change without a consumer's meaning changing with it. + +Each token declares a name, a human-readable label, a description, and a default value. Its +**category and CSS custom-property name are derived** from the name rather than declared, so a token +can never claim one category while being named for another, and there is exactly one spelling of +`--ekklesia-`. That is what makes the registry enumerable: a future theme editor reads +`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and +needs no second source of truth. + +Construction validates every rule rather than leaving it to review: an invalid or single-segment +name, an unknown category, a malformed derived identifier, an empty or overlong label or description, +a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a +declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash +escape, a control character, or markup, and is length-bounded. A registry that constructs is +therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping +can be in it. + +The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a +caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the +package refuses everywhere else. + +### `@ekklesia/ui` — the foundational elements + +A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a +`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without: +button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and +list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible` +focus treatment on every interactive element. + +Each element is a thin, accessible shell over the real HTML element rather than a re-implementation +of it, so native attributes, form association, and assistive-technology semantics pass straight +through. **This is deliberately not a large catalogue**: an element earns its place when two +independent consumers need the same one. + +**Status is never signalled by colour alone.** Every status family carries a symbol token beside its +colours, and the elements render it. A status that is only red is invisible to a person who cannot +distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1). + +### The stylesheet is a string, not a `.css` file + +These elements are mounted in two very different places: a document (the Dashboard shell, a +standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a +bundler on both sides and a served asset on one of them. A string is installed into whichever root +the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and +`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference +between a focus treatment that works for a keyboard user and one that only looks like it does. + +`` renders that string into whichever root its React tree lives in. There is +deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated +artifact's styles into the host document is precisely the leak the platform must not have. + +### Modules consume tokens; they never invent global ones + +A module may reference any registered token and may derive **private, differently-prefixed aliases** +from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that +would add a global theme variable outside the registry, which a theme editor could never enumerate +and another module could unknowingly depend on. The typed `designToken(name)` reference makes an +unregistered name a compile error and a runtime failure. + +### The boundary opens by name, not by category + +`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a +named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`, +`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk` +remain excluded from surfaces for the reason ADR 0024 measured. + +The two new packages are pinned at the bottom of the presentation stack by their own rule: +`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens` +and dev-time tooling. A design package that could reach an API client would put that client inside +every module artifact that renders a button. + +### The host installs the theme once, at its root + +`surfaces/shell` installs the default token values at the document for both the Dashboard and the +display application. CSS custom properties inherit through a shadow boundary, so every module +artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host +reaching into the artifact or the artifact importing the host. + +## Consequences + +- Two packages exist where none did; both are browser-safe by construction and proven so by a real + compile of module-shaped source under the exact project a module uses for UI, where `process.env` + must fail to typecheck. +- The Dashboard shell and display host no longer hardcode a colour, and a module can match them. +- A theme editor becomes implementable without a contract change: the values it would edit are + already enumerable, labelled, described, categorised, and validated. +- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with + fixtures proving both the acceptance and the continued rejection of everything else. +- Every module artifact bundles its own copy of the elements, exactly as it already bundles React + (ADR 0023). That cost is accepted for now and is optimisable later without a contract change, + because the contract is still a mount function. + +## Alternatives + +- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review + surface, and most of it would be speculative. The set here is what a real module dashboard needs. +- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and + a host that renders nothing still has to satisfy it. Design values are not something a host + supplies, and merging them would make the mount ABI carry a theme. +- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map + makes every module re-implement styling from data, and makes the tokens a runtime negotiation + rather than inherited CSS. Custom properties already inherit into artifacts for free. +- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every + consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets + are a separate question — the module's build already exists to compile them — and are decided by + their own ADR. +- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs + persistence, an authorization story, per-installation overrides, and a preview surface, none of + which exist. + +## Deferred + +A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second +theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a +documentation site, and a focus-trap utility. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 7d9e901..94dede6 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -47,3 +47,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted +- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index fcfb45b..00f7062 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe | --- | --- | --- | | `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app | | `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries | -| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries | +| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries | | `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework | | `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts | | `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level | @@ -22,8 +22,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components, Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework - (including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role - packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package, + (including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role + beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package, a role modules could already depend on — owns the entire module UI build ([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its real target. @@ -31,6 +31,21 @@ These rules map platform ownership to filesystem and package boundaries. The exe import Core, an app, a surface, a builder, or another module. It also may not import `@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)). +- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`) + and `ui` (`@ekklesia/ui`), added by + [ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a + general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`, + `surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are + browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on + it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real + compile under the exact browser project a module uses. Both are also pinned at the bottom of the + presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation + layer, an API/auth client, or the backend contracts barrel, because a design package that could + would put all of it inside every module artifact that renders a button. +- A module **references** registered global semantic tokens and may derive private, + differently-prefixed aliases from them inside its own artifact. It may never define an + `--ekklesia-*` custom property: that would invent a global theme variable outside the registry, + which a future theme editor could not enumerate and another module could unknowingly depend on. - `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it Node's global declarations — into browser code. Core still owns the containing @@ -96,6 +111,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe direct table imports and foreign keys. Modules may not import another module's Drizzle tables, columns, relations, schema entrypoint, or migration internals. +The design packages are covered the same way: one fixture proves a module may depend on both, one +proves every other shared role stays rejected, and one proves neither design package may depend +upward. + The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard rejects every currently machine-checkable repository rule even before production packages fill every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 3952f61..3e8da61 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -281,9 +281,15 @@ artifact is the role selection. | `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | -Navigation, host theme and design tokens, module configuration, organization context, and -diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a -speculative field frozen into a public contract is harder to remove than to add. +Navigation, module configuration, organization context, and diagnostics reporting are **not** in the +contract. They need designs that do not exist, and a speculative field frozen into a public contract +is harder to remove than to add. + +The host **theme** is deliberately not a context member either, and that is not a gap: design tokens +reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import +`@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own +root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that +can forget to pass one. ### Never build your own API URL @@ -413,6 +419,58 @@ loads several React copies, and artifacts are large (the template's is roughly 5 an operator can review it). That is accepted for now and can be optimized later **without changing the contract**, because the contract is a mount function rather than a component. +### Design tokens and shared elements + +Your UI may import two shared design packages, and only those two +([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)): + +| Package | What it is | +| --- | --- | +| `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them | +| `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens | + +```tsx +import { designToken } from "@ekklesia/design-tokens"; +import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui"; + +export function Slides({ context }: { readonly context: ModuleUiHostContext }) { + return ( + <> + + Add slide} title="Slides"> +

Nothing scheduled yet.

+ Paused +
+ + ); +} +``` + +Render `` once, near the top of your tree. It puts the element stylesheet into +whichever root your artifact is mounted in, so you never have to know whether that is a document or +an isolated root. + +**Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*; +`#9f1239` says what it looks like today. An installation that changes its theme changes the first +and not the second. `designToken` only accepts registered names, so a typo is a compile error. + +**Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables, +and inventing one would create a global a future theme editor could not enumerate and another module +could unknowingly depend on. You may derive as many *private* aliases as you like inside your own +artifact, under your own prefix: + +```tsx +
+``` + +The host installs the default token values at its own root, and CSS custom properties inherit +through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at +run time and nothing for the host to hand you. + +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. + ### Styling: inline style objects only, for now Style with React's `style` prop: @@ -437,7 +495,7 @@ the build pipeline** — adding the pipeline required no boundary relaxation at | --- | --- | | `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` | | `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` | -| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages | +| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` | You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because `@ekklesia/module-ui-build` owns the bundler entirely. diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json new file mode 100644 index 0000000..76a5b2a --- /dev/null +++ b/packages/design-tokens/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ekklesia/design-tokens", + "version": "0.1.0", + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "lint": "biome lint .", + "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@ekklesia/typescript-config": "workspace:*", + "@types/node": "24.13.3", + "happy-dom": "20.0.11", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "ekklesia": { + "layer": "design-tokens" + } +} diff --git a/packages/design-tokens/src/index.ts b/packages/design-tokens/src/index.ts new file mode 100644 index 0000000..350ab32 --- /dev/null +++ b/packages/design-tokens/src/index.ts @@ -0,0 +1,3 @@ +export * from "./install.js"; +export * from "./registry.js"; +export * from "./token.js"; diff --git a/packages/design-tokens/src/install.ts b/packages/design-tokens/src/install.ts new file mode 100644 index 0000000..ddc0281 --- /dev/null +++ b/packages/design-tokens/src/install.ts @@ -0,0 +1,72 @@ +import { ekklesiaDesignTokenStylesheet } from "./registry.js"; + +/** + * The attribute that marks a token stylesheet this package installed. Used to make installation + * idempotent, and to let a host remove exactly what it added. + */ +export const DESIGN_TOKEN_STYLE_ATTRIBUTE = "data-ekklesia-design-tokens"; + +/** + * A root that can own a ``, "globalThis.__ekklesiaDisplayHostLoaded = true;\n", + "globalThis.__ekklesiaDisplayWorkerLoaded = true;\n", ); const temporaryDirectories: string[] = []; @@ -477,7 +479,11 @@ describe("module display host asset publication", () => { }); it("snapshots injected assets and rejects a validator that does not describe its script", async () => { - const source = createDisplayHostAssets("display host", "export const host = 1;\n"); + const source = createDisplayHostAssets( + "display host", + "export const host = 1;\n", + "export const worker = 1;\n", + ); const mutable = { ...source }; const { app } = await install({ assets: mutable, display: true }); mutable.html = "mutated secret"; @@ -498,4 +504,55 @@ describe("module display host asset publication", () => { expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404); }); + + it("serves the offline service worker from the path whose directory is its scope", async () => { + const { app } = await install({ display: true }); + + const response = await app.request(DISPLAY_SERVICE_WORKER_PATH); + + // `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing + // else. The Dashboard is never controlled by it (ADR 0043). + expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js"); + expect(response.status).toBe(200); + expect(await response.text()).toBe(displayHostAssets.serviceWorker); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + // `no-cache` with a strong validator, exactly like the host script: a browser re-checks the + // worker on its own schedule, which is how a screen avoids being trapped on an obsolete one. + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it("revalidates the service worker with its own validator, never the host script's", async () => { + const { app } = await install({ display: true }); + + const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, { + headers: { "if-none-match": displayHostAssets.serviceWorkerEtag }, + }); + expect(notModified.status).toBe(304); + + const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, { + headers: { "if-none-match": displayHostAssets.scriptEtag }, + }); + expect(wrongValidator.status).toBe(200); + }); + + it("does not publish the service worker when no eligible display uses it", async () => { + const { app } = await install({ ui: true, withAssets: false }); + + expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404); + }); + + it("cannot be reached through a neighbouring or request-selected path", async () => { + const { app } = await install({ display: true }); + + for (const candidate of [ + `${DISPLAY_SERVICE_WORKER_PATH}.map`, + "/display/sw.js/", + "/sw.js", + "/assets/ekklesia-display/sw.js", + ]) { + expect((await app.request(candidate)).status, candidate).toBe(404); + } + }); }); diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 7680e5e..69f7623 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -278,6 +278,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { displayHostAssets: createDisplayHostAssets( '
', "export const displayHost = true;\n", + "export const displayWorker = true;\n", ), }), moduleLifecycle: { diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index d8e13ea..67237bc 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -240,6 +240,7 @@ async function install( displayHostAssets: createDisplayHostAssets( '
', "export {};\n", + "export {};\n", ), } : {}), diff --git a/docs/architecture/decisions/0043-display-offline-code-fallback.md b/docs/architecture/decisions/0043-display-offline-code-fallback.md new file mode 100644 index 0000000..a8ac563 --- /dev/null +++ b/docs/architecture/decisions/0043-display-offline-code-fallback.md @@ -0,0 +1,173 @@ +# 0043: The display caches its shell and declared code, and nothing else + +- Status: Accepted +- Date: 2026-08-27 +- Supersedes: none +- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md), + [ADR 0031](0031-direct-object-delivery.md), + [ADR 0032](0032-native-module-realtime-transport.md), + [ADR 0033](0033-display-kiosk-host-contract.md), + [ADR 0036](0036-managed-device-identity-and-pairing.md), + [ADR 0041](0041-module-browser-contributions.md) + +## Context + +A display is the one surface in this platform that is expected to outlive its network. It is a +television on a wall in a building whose uplink is a consumer router, nobody is standing in front of +it, and the person who could press a button is not in the building on a Tuesday. + +Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a +janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia +message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and +does something about it. Nothing about that page recovers on its own, because there is no Ekklesia +code running on it to recover. + +The temptation is to solve this by caching more: the descriptor, the principal, the module's data, +so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached +descriptor is a cached permission decision, and a screen replaying one is a revoked device still +showing content it is no longer entitled to. + +## Decision + +Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the +display. + +### What is cached + +- The display page (`/display/`). +- The fixed display host script. +- The versioned browser artifacts the display actually loaded: its module's display artifact, and + every contributed renderer it mounted (ADR 0041). + +All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041). +All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about +what it is. + +### What is never cached, under any condition + +The display descriptor, `/api/v1/principal`, every other platform and module API response, the +realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a +signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would +outlive. + +This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains +an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting +is that a new kind of code is simply not cached, which is a bug rather than a vulnerability. + +### The consequence, stated rather than papered over + +**A display cannot render module content while its network is down.** Mounting requires a descriptor; +a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore +stays revoked, and cached code can never manufacture current authorization — the two properties this +pass must not trade away. + +What the cache buys is real and worth having on its own: + +- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the + browser's error page, and keeps retrying by itself. +- The moment connectivity returns it resolves and mounts with **nothing left to download** — the + 600 KB artifact is already there. +- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the + identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is + not, so this is the failure that actually happens. + +Module-owned offline content — a module persisting its own state and media so a screen can keep +showing yesterday's slides — remains the module's responsibility. This is the code foundation it +would build on. + +### Network first, always + +Every cached kind is fetched from the network first and only falls back when that fetch fails. A +kiosk therefore picks up new code the moment the server returns and can never be trapped on an +obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended +screen, and the one that requires physically visiting it to fix. + +Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an +opaque response, and a `503` from an API mid-restart are all things a screen must never later replay +as though they were the artifact. + +### Versioned keys and pruning + +Cache names carry a policy version; superseded generations are deleted on activation, so a policy +change cannot leave a screen serving entries from rules that no longer apply. + +Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After +storing an artifact the worker removes every entry for the same module at a different version — and +deliberately keeps every entry at the current version, because a module's display artifact and each +contributed renderer are separate URLs under one version and dropping any of them would half-cache a +working display. Another module's artifacts are never touched. + +The worker never caches **itself**, which is the other way a screen gets stuck: it is served +`no-cache` with a strong validator, exactly like the host script. + +### Scope is the directory + +The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default +scope is where it was served from, so this one controls display pages and nothing else. The Dashboard +is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host +script under `/assets/` would scope it to a directory where no page lives; widening it with +`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for. + +A controlled page's requests are intercepted wherever they point, which is how an artifact under +`/api/v1/module-ui/` is cached without widening the scope to the whole origin. + +### Reporting, not deciding + +The page gets a small browser-safe signal built from two independent sources, because either alone +would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports +`true`) and the worker's own report that it actually served from cache (ground truth about this +application). A display renders a quiet corner indicator, never a full-page failure: a screen whose +switch rebooted must not blank a lobby wall. + +When the browser reports the network is back, the display resolves its descriptor again on its own. +Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen +sitting on cached code after a deployment — it names the current version, and the network-first worker +fetches it. + +Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page +served over plain HTTP in development, and a policy that refuses all leave a display that works +exactly as it did before, online only. + +### Testable by construction + +Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the +way a status reaches the page all arrive as an environment. The worker file is thin wiring that +supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline +reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect, +and the proof that nothing authorization-bearing is ever stored — rather than through a service +worker a test runner cannot host. + +## Consequences + +- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading + to a browser error page and staying there. +- The platform serves a third fixed display asset, read once before the listener binds like the other + two. +- A display page is now controlled by a service worker, which is a real operational surface: an + operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients + immediately, and caches only code, so the ways it can go wrong are bounded. +- Offline *content* is still absent, and that is the honest state of this foundation rather than an + oversight. + +## Alternatives + +- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission + decision and a device identity; replaying one is a revoked screen still showing content. +- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from + months ago with no way to notice, and fixing it means visiting the screen. +- **Precache everything at install.** Rejected: the worker would have to know which module a screen + runs and at which version — facts only the descriptor carries — and an install would fail whenever + an asset a future version needs did not exist yet. +- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker + it has no use for, for no benefit. +- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead + uplink, so a screen would insist it was fine while showing nothing. +- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its + `script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache + already does correctly. + +## Deferred + +Module-owned offline content and media persistence, background sync, periodic background update, +push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first +visit, and any cached response that carries identity or authorization. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e87d890..d49fa42 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -51,3 +51,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted - [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted - [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted +- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index d453b46..fda4a95 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -119,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe receiving only the host-neutral principal and permission hints already in the UI SDK. - The display browser application is a separate surface entrypoint under `surfaces/shell`; it also statically imports no module and mounts only the exact display descriptor/artifact supplied by the - API. It contains no Dashboard chrome and does not move display rendering into the surface—the + API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the + display shell and the declared browser artifacts it actually loaded + ([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor, + `/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange, + or any cross-origin request — so a revoked device stays revoked and cached code can never + manufacture current authorization. Caching is network-first, so a screen cannot be trapped on + obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the module still owns that renderer. - Separate surfaces do not import each other's internals. - Builder code composes contracts and surface renderer identifiers; it does not import module or diff --git a/docs/module-development/README.md b/docs/module-development/README.md index cc637cc..2340e92 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -746,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl The page and descriptor both require the manifest's display permission through the ordinary Core authorization evaluator and an authenticated cookie session. A `public` display permission is -rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are +rejected; there is no anonymous exception or device token. Artifact bytes are anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown, primary-only, disabled, and dependency-blocked modules have no display routes and return the normal sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart, @@ -764,8 +764,31 @@ artifact remains compatible with a future standalone host. The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API. It shows fixed loading/failure states and offers manual retry; your module owns only the rendered feature experience and cleanup inside its container. There is one display per module. Display -inventory/management, named variants, targeting, public/device identity, a standalone host, shared -CSS, Announcements migration, and Companion integration are deferred. +inventory/management, named variants, targeting, public/device identity, a standalone host, +Announcements migration, and Companion integration are deferred. + +### Your display survives a network outage — as code, not as content + +A display page is controlled by a service worker that caches **the shell and the declared code**: the +page, the host script, your display artifact, and every contributed renderer that display actually +mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is +network-first, so a screen picks up a new build the moment the server returns and can never be +trapped on an obsolete one. + +What that means for you, concretely: + +- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the + loader still verifies the artifact's declared id and version, so a fallback can never mount + something the descriptor did not name. +- **Your display cannot render while the network is down.** Mounting requires the descriptor, the + descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked + stays revoked, and cached code can never manufacture authorization it no longer has. The screen + shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity + returns. +- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and + every signed object-delivery URL are deliberately never stored. If you want a screen to keep + showing content through an outage, persisting that content is *your module's* job — this is the + foundation it would build on, not a substitute for it. ### What a module UI will never own diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index 54e8e88..04a8cdc 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -44,6 +44,10 @@ const buildOptions = { entryPoints: { app: path.join(packageDirectory, "src", "browser", "main.tsx"), "display/app": path.join(packageDirectory, "src", "display", "main.tsx"), + // The display service worker (ADR 0043). A separate entry because a worker is a separate + // realm with its own globals; bundling it into the page would make it unregisterable and its + // `self` meaningless. + "display/sw": path.join(packageDirectory, "src", "display", "sw.ts"), }, format: "esm", jsx: "automatic", diff --git a/surfaces/shell/src/display-assets.ts b/surfaces/shell/src/display-assets.ts index 14ffe62..c96bf26 100644 --- a/surfaces/shell/src/display-assets.ts +++ b/surfaces/shell/src/display-assets.ts @@ -11,4 +11,7 @@ const packageRoot = new URL("../", import.meta.url); export const displayHostAssetUrls = Object.freeze({ html: new URL("dist/browser/display/index.html", packageRoot).href, script: new URL("dist/browser/display/app.js", packageRoot).href, + /** The display's offline service worker (ADR 0043). A third exact file, served at a fixed + * host-owned path whose directory is its scope — so it controls display pages and nothing else. */ + serviceWorker: new URL("dist/browser/display/sw.js", packageRoot).href, }); diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index ca61dcc..e4f291f 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -1,3 +1,4 @@ +import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { type ReactElement, useEffect, useState } from "react"; import { @@ -7,6 +8,11 @@ import { HostProtocolError, } from "../host-protocol.js"; import { DisplayUiFrame } from "./DisplayUiFrame.js"; +import { + type DisplayConnectivity, + type DisplayConnectivitySource, + watchDisplayConnectivity, +} from "./offline-status.js"; import { PairingScreen } from "./PairingScreen.js"; type DisplayState = @@ -22,6 +28,9 @@ export interface DisplayAppProps { readonly moduleId: string; /** Test/composition seam only. Production uses the generic loader's native dynamic import. */ readonly importModule?: ModuleUiImporter; + /** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point + * supplies the real ones; omitting it leaves a display that simply never reports being offline. */ + readonly connectivity?: DisplayConnectivitySource; } const displayStyles = { @@ -34,11 +43,11 @@ const displayStyles = { }, message: { alignItems: "center", - color: "#57534e", + color: designToken("text.secondary"), display: "flex", flexDirection: "column", - fontFamily: "system-ui, sans-serif", - gap: "0.75rem", + fontFamily: designToken("typography.family-sans"), + gap: designToken("spacing.md"), height: "100%", justifyContent: "center", margin: 0, @@ -48,15 +57,47 @@ const displayStyles = { }, retry: { background: "transparent", - border: "1px solid currentColor", - borderRadius: "0.25rem", + border: `${designToken("border.width")} solid currentColor`, + borderRadius: designToken("radius.md"), color: "inherit", cursor: "pointer", font: "inherit", - padding: "0.375rem 0.75rem", + padding: `${designToken("spacing.xs")} ${designToken("spacing.md")}`, + }, + offline: { + background: designToken("status.warning-background"), + borderRadius: designToken("radius.pill"), + color: designToken("text.warning"), + fontFamily: designToken("typography.family-sans"), + fontSize: designToken("typography.size-sm"), + insetBlockStart: designToken("spacing.md"), + insetInlineEnd: designToken("spacing.md"), + padding: `${designToken("spacing.2xs")} ${designToken("spacing.md")}`, + position: "absolute", }, } as const; +/** + * A small, honest indicator: this screen is not currently in touch with Ekklesia, or is running + * from code it cached earlier. + * + * Deliberately unobtrusive and deliberately not an error. An unattended screen showing a full-page + * failure because a switch rebooted is worse than one quietly saying so in a corner, and the screen + * recovers on its own the moment connectivity returns. + */ +function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { + const label = props.connectivity.usingFallback + ? props.connectivity.fallbackKind === "artifact" + ? "Offline — showing the last downloaded display code" + : "Offline — showing the last downloaded Ekklesia display" + : "Offline — waiting for Ekklesia"; + return ( +
+ {label} +
+ ); +} + function DisplayMessage(props: { readonly children: string; readonly testId: string; @@ -85,7 +126,33 @@ function DisplayMessage(props: { export function DisplayApp(props: DisplayAppProps): ReactElement { const [state, setState] = useState({ generation: 0, status: "loading" }); const [attempt, setAttempt] = useState(0); - const { config, importModule, moduleId } = props; + const [connectivity, setConnectivity] = useState({ + online: true, + usingFallback: false, + }); + const { config, connectivity: connectivitySource, importModule, moduleId } = props; + + useEffect(() => { + if (!connectivitySource) { + return; + } + const monitor = watchDisplayConnectivity(connectivitySource); + setConnectivity(monitor.current()); + const unsubscribe = monitor.subscribe((next) => { + setConnectivity(next); + // Nobody presses Retry on a lobby television. When the browser says the network is back, the + // display resolves its descriptor again — which is also what stops a screen from sitting on + // cached code after the server returns, since the fresh descriptor names the current version + // and the network-first worker fetches it (ADR 0043). + if (next.online) { + setAttempt((current) => current + 1); + } + }); + return () => { + unsubscribe(); + monitor.close(); + }; + }, [connectivitySource]); useEffect(() => { const abort = new AbortController(); @@ -124,6 +191,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { return (
+ {connectivity.online && !connectivity.usingFallback ? undefined : ( + + )} {state.status === "loading" ? ( Loading display… ) : undefined} diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx index 78b2703..1dfa771 100644 --- a/surfaces/shell/src/display/main.tsx +++ b/surfaces/shell/src/display/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import type { EkklesiaHostConfig } from "../host-protocol.js"; import { parseDisplayRoute } from "../host-protocol.js"; import { DisplayApp } from "./DisplayApp.js"; +import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js"; declare const __EKKLESIA_API_BASE_URL__: string; @@ -16,6 +17,27 @@ if (!container) { throw new Error("The Ekklesia display host requires a #ekklesia-display element."); } +/** + * Offline resilience is registered as an enhancement and never as a prerequisite (ADR 0043): a + * browser without service workers, or a policy that refuses the registration, leaves a display that + * works exactly as it did before. The promise is deliberately not awaited — a screen must render + * while its worker is still installing. + */ +void registerDisplayServiceWorker( + typeof navigator === "undefined" ? undefined : navigator.serviceWorker, +); + +/** The two real browser sources the connectivity monitor reads. Passed as data so nothing below the + * entry point touches a global. */ +const connectivity: DisplayConnectivitySource = { + addEventListener: (type, listener) => window.addEventListener(type, listener), + initialOnline: navigator.onLine, + removeEventListener: (type, listener) => window.removeEventListener(type, listener), + ...(typeof navigator === "undefined" || !navigator.serviceWorker + ? {} + : { serviceWorkerMessages: navigator.serviceWorker }), +}; + let moduleId: string | undefined; try { moduleId = parseDisplayRoute(window.location.pathname); @@ -31,7 +53,7 @@ createRoot(container).render( <> {moduleId ? ( - + ) : (
/[/...]`. Every URL under + * this prefix is compiled code whose identity the loader verifies before mounting (ADR 0025, + * ADR 0040, ADR 0041). */ +const MODULE_ARTIFACT_PREFIX = "/api/v1/module-ui/"; + +/** What this worker will store, or `undefined` for everything else — which is passed through to the + * network untouched and never enters a cache. */ +export type DisplayCacheKind = "shell" | "artifact"; + +/** Whether the shell or an artifact was served from the cache because the network failed. */ +export interface DisplayOfflineStatus { + readonly usingFallback: boolean; + /** Which kind most recently fell back, for an honest indicator rather than a generic one. */ + readonly kind?: DisplayCacheKind; +} + +export interface DisplayCacheLike { + match(request: Request | string): Promise; + put(request: Request | string, response: Response): Promise; + delete(request: Request | string): Promise; + keys(): Promise; +} + +export interface DisplayCacheStorageLike { + open(cacheName: string): Promise; + keys(): Promise; + delete(cacheName: string): Promise; +} + +export interface DisplayOfflineEnvironment { + readonly caches: DisplayCacheStorageLike; + readonly fetch: (request: Request) => Promise; + /** How a fallback reaches the page. The worker posts to its clients; a test records. */ + readonly notify?: (status: DisplayOfflineStatus) => void; +} + +/** + * Classifies a request, and refuses everything it does not explicitly recognize. + * + * An allowlist rather than a deny list, deliberately. A deny list has to be updated every time the + * platform gains an endpoint, and the failure mode of forgetting is a cached permission decision. + * Here the failure mode of forgetting is that a new kind of code is simply not cached. + */ +export function classifyDisplayRequest( + request: Request, + origin: string, +): DisplayCacheKind | undefined { + if (request.method !== "GET") { + return undefined; + } + let url: URL; + try { + url = new URL(request.url); + } catch { + return undefined; + } + // Cross-origin is never cached. A signed object-delivery URL is cross-origin and is a bearer + // capability with an expiry (ADR 0031); storing one would outlive its own lifetime. + if (url.origin !== origin) { + return undefined; + } + if (url.search !== "") { + // Nothing this worker caches is ever addressed with a query string, and a query is how live + // state is usually parameterized. Refusing them keeps the allowlist exact. + return undefined; + } + if (url.pathname === DISPLAY_SERVICE_WORKER_PATH) { + // The worker script is the browser's own business; caching it would be how a screen gets stuck + // on an obsolete worker. + return undefined; + } + if (url.pathname === DISPLAY_HOST_SCRIPT_PATH) { + return "shell"; + } + if (url.pathname.startsWith(DISPLAY_PAGE_PREFIX) && request.mode === "navigate") { + return "shell"; + } + if (url.pathname.startsWith(MODULE_ARTIFACT_PREFIX)) { + return "artifact"; + } + return undefined; +} + +function cacheNameFor(kind: DisplayCacheKind): string { + return kind === "shell" ? DISPLAY_SHELL_CACHE : DISPLAY_ARTIFACT_CACHE; +} + +/** + * The module identity a versioned artifact URL carries: `/api/v1/module-ui//` + * and anything below it. Returns `undefined` for a shape this worker does not recognize, so an + * unexpected URL is left alone rather than guessed at. + */ +function artifactIdentity(pathname: string): { module: string; version: string } | undefined { + const segments = pathname.slice(MODULE_ARTIFACT_PREFIX.length).split("/"); + const [moduleSegment, versionSegment] = segments; + if (!moduleSegment || !versionSegment) { + return undefined; + } + return { module: moduleSegment, version: versionSegment }; +} + +/** + * Removes every stored artifact for the same module at a *different* version. + * + * Version-keyed URLs mean a new build lands beside the old one rather than replacing it, so without + * this an unattended screen accumulates every version it has ever run. Pruning is deliberately + * scoped to the one module whose artifact was just stored and deliberately keeps every entry for the + * current version — a module's display artifact and each contributed renderer it mounted are + * separate URLs under one version, and dropping any of them would half-cache a working display. + */ +export async function pruneSupersededArtifacts( + cache: DisplayCacheLike, + storedUrl: string, +): Promise { + const stored = new URL(storedUrl); + const identity = artifactIdentity(stored.pathname); + if (!identity) { + return []; + } + const removed: string[] = []; + for (const request of await cache.keys()) { + const candidate = new URL(request.url); + if (candidate.origin !== stored.origin) { + continue; + } + const other = artifactIdentity(candidate.pathname); + if (!other || other.module !== identity.module || other.version === identity.version) { + continue; + } + if (await cache.delete(request)) { + removed.push(request.url); + } + } + return removed; +} + +/** Only a complete, successful, same-origin response is ever stored. A `304`, a `404`, a redirect, + * an opaque cross-origin response, and a `503` from an API mid-restart are all things a screen must + * never later replay as though they were the artifact. */ +function isStorable(response: Response): boolean { + return response.ok && response.status === 200 && response.type !== "opaque"; +} + +/** + * Network first, cache as fallback — the one strategy, for both kinds. + * + * Returns `undefined` for anything unrecognized, which the worker turns into "let the browser do + * what it would have done anyway". That is the important default: this worker's *presence* must not + * change how any other request behaves. + */ +export async function handleDisplayFetch( + request: Request, + origin: string, + environment: DisplayOfflineEnvironment, +): Promise { + const kind = classifyDisplayRequest(request, origin); + if (!kind) { + return undefined; + } + + const cache = await environment.caches.open(cacheNameFor(kind)); + try { + const response = await environment.fetch(request); + if (isStorable(response)) { + // Stored as a clone so the live response reaches the page unread — the page must never wait + // for a cache write to finish rendering. + await cache.put(request, response.clone()); + if (kind === "artifact") { + await pruneSupersededArtifacts(cache, request.url); + } + environment.notify?.({ usingFallback: false }); + } + return response; + } catch { + const cached = await cache.match(request); + if (!cached) { + // Nothing was ever stored for this exact URL — a first visit during an outage, or a version + // this screen has never run. There is no honest answer, so the network failure propagates and + // the page renders its own failure state rather than a fabricated one. + environment.notify?.({ kind, usingFallback: true }); + throw new TypeError("Ekklesia display request failed and nothing is cached for it."); + } + environment.notify?.({ kind, usingFallback: true }); + return cached; + } +} + +/** + * Deletes every cache this worker owns from a superseded generation. + * + * Run on activation. Only names carrying this worker's own prefix are considered, so a deployment + * that shares an origin with something else keeps its caches. + */ +export async function pruneSupersededCaches( + environment: DisplayOfflineEnvironment, +): Promise { + const current = new Set(DISPLAY_CACHE_NAMES); + const removed: string[] = []; + for (const name of await environment.caches.keys()) { + if (name.startsWith(DISPLAY_CACHE_PREFIX) && !current.has(name)) { + if (await environment.caches.delete(name)) { + removed.push(name); + } + } + } + return removed; +} diff --git a/surfaces/shell/src/display/offline-status.ts b/surfaces/shell/src/display/offline-status.ts new file mode 100644 index 0000000..4366a83 --- /dev/null +++ b/surfaces/shell/src/display/offline-status.ts @@ -0,0 +1,155 @@ +import type { DisplayCacheKind, DisplayOfflineStatus } from "./offline-cache.js"; + +/** + * The small browser-safe signal a display renders its offline state from (ADR 0043). + * + * Two independent sources, because they answer different questions and either one alone would lie: + * + * - `navigator.onLine` and the `online`/`offline` events say whether the browser believes it has a + * network. Cheap and immediate, and famously optimistic — a captive portal or a dead uplink behind + * a live Wi-Fi association both report `true`. + * - The service worker says whether it actually had to serve something from its cache. That is + * ground truth about this application specifically, and it is the only source that can tell a + * working network from one that merely exists. + * + * Reporting both, rather than collapsing them, is what lets a display say "offline" honestly instead + * of guessing. Neither is a security signal, and nothing here decides what a display may show — the + * descriptor is still fetched from the network on every attempt, so a device whose access was + * revoked is refused exactly as it always was. + */ +export interface DisplayConnectivity { + /** What the browser believes about the network. Optimistic; never treated as proof. */ + readonly online: boolean; + /** Whether this application has actually served the shell or an artifact from its cache. */ + readonly usingFallback: boolean; + /** Which kind most recently fell back, so an indicator can be specific rather than generic. */ + readonly fallbackKind?: DisplayCacheKind; +} + +export type DisplayConnectivityListener = (connectivity: DisplayConnectivity) => void; + +/** + * The browser capabilities this helper uses, named so a test supplies them directly. A display page + * passes nothing and gets the real ones. + */ +export interface DisplayConnectivitySource { + readonly initialOnline?: boolean; + readonly addEventListener?: (type: "online" | "offline", listener: () => void) => void; + readonly removeEventListener?: (type: "online" | "offline", listener: () => void) => void; + /** Where service-worker messages arrive. `navigator.serviceWorker` in a browser. */ + readonly serviceWorkerMessages?: { + addEventListener(type: "message", listener: (event: MessageEvent) => void): void; + removeEventListener(type: "message", listener: (event: MessageEvent) => void): void; + }; +} + +function isOfflineMessage( + data: unknown, +): data is { readonly ekklesiaDisplayOffline: DisplayOfflineStatus } { + if (typeof data !== "object" || data === null) { + return false; + } + const status = (data as { readonly ekklesiaDisplayOffline?: unknown }).ekklesiaDisplayOffline; + return typeof status === "object" && status !== null && "usingFallback" in status; +} + +export interface DisplayConnectivityMonitor { + current(): DisplayConnectivity; + subscribe(listener: DisplayConnectivityListener): () => void; + /** Detaches every listener. Idempotent. */ + close(): void; +} + +/** + * Watches connectivity and cache fallback, and tells subscribers when either changes. + * + * Deliberately reports rather than acts: it never retries, never reloads, and never decides what a + * display may render. A display that wants to retry does so through its own bootstrap, where the + * descriptor and its authorization are resolved fresh. + */ +export function watchDisplayConnectivity( + source: DisplayConnectivitySource = {}, +): DisplayConnectivityMonitor { + let connectivity: DisplayConnectivity = { + online: source.initialOnline ?? true, + usingFallback: false, + }; + const listeners = new Set(); + let closed = false; + + const publish = (next: DisplayConnectivity): void => { + if ( + next.online === connectivity.online && + next.usingFallback === connectivity.usingFallback && + next.fallbackKind === connectivity.fallbackKind + ) { + return; + } + connectivity = next; + for (const listener of listeners) { + listener(connectivity); + } + }; + + const onOnline = (): void => publish({ ...connectivity, online: true }); + const onOffline = (): void => publish({ ...connectivity, online: false }); + const onMessage = (event: MessageEvent): void => { + if (!isOfflineMessage(event.data)) { + return; + } + const status = event.data.ekklesiaDisplayOffline; + publish({ + online: connectivity.online, + usingFallback: status.usingFallback, + ...(status.usingFallback && status.kind ? { fallbackKind: status.kind } : {}), + }); + }; + + source.addEventListener?.("online", onOnline); + source.addEventListener?.("offline", onOffline); + source.serviceWorkerMessages?.addEventListener("message", onMessage); + + return { + close(): void { + if (closed) { + return; + } + closed = true; + listeners.clear(); + source.removeEventListener?.("online", onOnline); + source.removeEventListener?.("offline", onOffline); + source.serviceWorkerMessages?.removeEventListener("message", onMessage); + }, + current: () => connectivity, + subscribe(listener: DisplayConnectivityListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +/** Where the display service worker is served from — the same fixed, platform-owned path the API + * publishes it at. Its directory is its scope, so it controls display pages and nothing else. */ +export const DISPLAY_SERVICE_WORKER_URL = "/display/sw.js"; + +/** + * Registers the display service worker, and never lets failing to do so break the display. + * + * Offline resilience is an enhancement: a browser without service workers, a page served over plain + * HTTP during development, and a registration a policy refused all leave a display that works + * exactly as it did before — online only. Treating any of them as fatal would trade a real + * capability for a hypothetical one. + */ +export async function registerDisplayServiceWorker( + container: ServiceWorkerContainer | undefined, +): Promise { + if (!container) { + return false; + } + try { + await container.register(DISPLAY_SERVICE_WORKER_URL, { type: "module" }); + return true; + } catch { + return false; + } +} diff --git a/surfaces/shell/src/display/sw.ts b/surfaces/shell/src/display/sw.ts new file mode 100644 index 0000000..364ee27 --- /dev/null +++ b/surfaces/shell/src/display/sw.ts @@ -0,0 +1,76 @@ +/// +import { + DISPLAY_CACHE_NAMES, + type DisplayOfflineEnvironment, + type DisplayOfflineStatus, + handleDisplayFetch, + pruneSupersededCaches, +} from "./offline-cache.js"; + +/** + * The Ekklesia display's service worker: thin wiring, and nothing else. + * + * Every decision lives in `offline-cache.ts`, which touches no global and is therefore exercised + * directly by tests. This file exists to hand that logic the real `caches`, the real `fetch`, and a + * way to reach the page — the three things a test runner cannot host a real worker to provide. + * + * Scope is `/display/`, because this script is served from `/display/sw.js`. It therefore controls + * display pages only: the Dashboard is never controlled by it, and no Dashboard request is ever + * intercepted. A controlled page's requests are intercepted wherever they point, which is how an + * artifact under `/api/v1/module-ui/` is cached without widening the worker's scope to the whole + * origin. + */ + +declare const self: ServiceWorkerGlobalScope; + +function broadcast(status: DisplayOfflineStatus): void { + void self.clients.matchAll({ type: "window" }).then((clients) => { + for (const client of clients) { + client.postMessage({ ekklesiaDisplayOffline: status }); + } + }); +} + +const environment: DisplayOfflineEnvironment = { + caches: self.caches as unknown as DisplayOfflineEnvironment["caches"], + fetch: (request) => fetch(request), + notify: broadcast, +}; + +self.addEventListener("install", () => { + // Nothing is precached. A display's shell and artifacts are stored the first time they are + // genuinely fetched, so this worker never downloads code a screen has not been told to run, and an + // install can never fail because an asset a future version needs did not exist yet. + void self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + await pruneSupersededCaches(environment); + // Claim immediately so the first load after an update is already controlled, rather than + // leaving an unattended screen one reload behind its own worker. + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const handled = handleDisplayFetch(event.request, self.location.origin, environment); + event.respondWith( + handled.then( + (response) => + // `undefined` means this worker recognized nothing and must not change what the browser would + // have done. Re-issuing the request is the only way to say that from inside `respondWith`. + response ?? fetch(event.request), + ), + ); +}); + +self.addEventListener("message", (event) => { + if ((event.data as { readonly ekklesiaDisplayQuery?: unknown } | null)?.ekklesiaDisplayQuery) { + (event.source as Client | null)?.postMessage({ + ekklesiaDisplayCaches: DISPLAY_CACHE_NAMES, + }); + } +}); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 81311ea..8e4882e 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -9,6 +9,7 @@ import type { HostEventSourceFactory, HostEventSourceOptions, } from "../src/host-protocol.js"; +import type { DisplayConnectivitySource } from "../src/display/offline-status.js"; import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js"; declare global { @@ -177,11 +178,13 @@ async function render( config: EkklesiaHostConfig, importModule?: (url: string) => Promise, moduleId = "announcements", + connectivity?: DisplayConnectivitySource, ): Promise { await act(async () => { root.render( , @@ -753,3 +756,170 @@ describe("display host — independent instances and stale work", () => { expect(staleUnmounts).toEqual([staleContainer]); }); }); + +/** + * How an unattended screen behaves when its network comes and goes (ADR 0043). + * + * The caching itself is proven directly against the worker's logic in `display-offline-cache.test.ts`. + * What is proven here is the *page's* half: it says so honestly, it recovers without anyone touching + * it, and it never keeps showing content a revoked device is no longer entitled to. + */ +describe("display host — offline behaviour", () => { + /** A controllable stand-in for the browser's own connectivity events. */ + function connectivitySource(initialOnline: boolean) { + const listeners = new Map void>>(); + const workerListeners = new Set<(event: MessageEvent) => void>(); + return { + source: { + addEventListener(type: "online" | "offline", listener: () => void) { + const existing = listeners.get(type) ?? new Set(); + existing.add(listener); + listeners.set(type, existing); + }, + initialOnline, + removeEventListener(type: "online" | "offline", listener: () => void) { + listeners.get(type)?.delete(listener); + }, + serviceWorkerMessages: { + addEventListener(_type: "message", listener: (event: MessageEvent) => void) { + workerListeners.add(listener); + }, + removeEventListener(_type: "message", listener: (event: MessageEvent) => void) { + workerListeners.delete(listener); + }, + }, + } satisfies DisplayConnectivitySource, + async emit(type: "online" | "offline") { + await act(async () => { + for (const listener of listeners.get(type) ?? []) { + listener(); + } + }); + }, + async emitFallback(kind: "shell" | "artifact") { + await act(async () => { + for (const listener of workerListeners) { + listener({ + data: { ekklesiaDisplayOffline: { kind, usingFallback: true } }, + } as MessageEvent); + } + }); + }, + }; + } + + it("shows no indicator at all while the display is genuinely online", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + expect(query("display-offline")).toBeNull(); + }); + + it("says so, quietly and honestly, when the browser goes offline", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + await connectivity.emit("offline"); + + // A corner indicator, not a full-page failure: a screen whose switch rebooted must not blank a + // lobby wall. + const indicator = query("display-offline"); + expect(indicator?.getAttribute("role")).toBe("status"); + expect(indicator?.textContent).toContain("Offline"); + expect(query("display-module-container")).not.toBeNull(); + }); + + it("reports a cache fallback even while the browser still claims to be online", async () => { + const server = platform(); + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + + await connectivity.emitFallback("artifact"); + + // A captive portal reports `online: true`. Only the worker knows this application actually + // served code from its cache. + expect(query("display-offline")?.textContent).toContain("last downloaded display code"); + }); + + it("resolves again on its own when the network returns — nobody presses Retry on a lobby TV", async () => { + const server = platform(); + const connectivity = connectivitySource(false); + await render( + { apiBaseUrl, fetch: server.fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + const descriptorCalls = () => + server.calls.filter((call) => call.url.endsWith("/api/v1/module-displays/announcements")) + .length; + const before = descriptorCalls(); + + await connectivity.emit("online"); + + // A fresh descriptor is exactly what stops a screen sitting on cached code after the server + // returns: it names the current version, and the network-first worker fetches it. + expect(descriptorCalls()).toBeGreaterThan(before); + }); + + it("drops a revoked device back to pairing the moment connectivity returns", async () => { + // The descriptor is never cached, so a device whose access was revoked while it was offline + // cannot keep showing content: the first successful resolution after reconnecting is the + // server's refusal. + let revoked = false; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/principal")) { + return json({ kind: "authenticated", userId: "user-1" }); + } + if (url.endsWith("/api/v1/module-displays/announcements")) { + return revoked + ? json({ code: "AUTHORIZATION_DENIED" }, 403) + : json(descriptorPayload(), 200); + } + if (url.endsWith("/api/v1/device-pairing/requests")) { + return json( + { code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 }, + 201, + ); + } + if (url.endsWith("/api/v1/device-pairing/claims")) { + return json({ status: "pending" }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + const connectivity = connectivitySource(true); + await render( + { apiBaseUrl, fetch }, + async () => artifact(), + "announcements", + connectivity.source, + ); + expect(query("display-module-container")).not.toBeNull(); + + revoked = true; + await connectivity.emit("offline"); + await connectivity.emit("online"); + + expect(query("display-module-container")).toBeNull(); + expect(query("display-pairing")).not.toBeNull(); + }); +}); diff --git a/surfaces/shell/tests/display-assets.test.ts b/surfaces/shell/tests/display-assets.test.ts index c46dd7b..4952b9a 100644 --- a/surfaces/shell/tests/display-assets.test.ts +++ b/surfaces/shell/tests/display-assets.test.ts @@ -8,14 +8,16 @@ import { displayHostAssetUrls } from "../src/display-assets.js"; const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); describe("display host asset locator", () => { - it("exports only frozen immutable href strings for the two exact built files", () => { + it("exports only frozen immutable href strings for the three exact built files", () => { expect(Object.isFrozen(displayHostAssetUrls)).toBe(true); expect(displayHostAssetUrls).toEqual({ html: new URL("dist/browser/display/index.html", `file://${packageDirectory}/`).href, script: new URL("dist/browser/display/app.js", `file://${packageDirectory}/`).href, + serviceWorker: new URL("dist/browser/display/sw.js", `file://${packageDirectory}/`).href, }); expect(typeof displayHostAssetUrls.html).toBe("string"); expect(typeof displayHostAssetUrls.script).toBe("string"); + expect(typeof displayHostAssetUrls.serviceWorker).toBe("string"); }); it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => { diff --git a/surfaces/shell/tests/display-offline-cache.test.ts b/surfaces/shell/tests/display-offline-cache.test.ts new file mode 100644 index 0000000..32e0a80 --- /dev/null +++ b/surfaces/shell/tests/display-offline-cache.test.ts @@ -0,0 +1,401 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + classifyDisplayRequest, + DISPLAY_ARTIFACT_CACHE, + DISPLAY_SHELL_CACHE, + type DisplayCacheLike, + type DisplayCacheStorageLike, + type DisplayOfflineEnvironment, + type DisplayOfflineStatus, + handleDisplayFetch, + pruneSupersededArtifacts, + pruneSupersededCaches, +} from "../src/display/offline-cache.js"; + +/** + * The display's offline behaviour (ADR 0043), tested against the real caching logic rather than + * against a service worker a test runner cannot host. + * + * That is the whole reason `offline-cache.ts` touches no global: every capability arrives as an + * environment, so a fake cache and a controllable `fetch` exercise the exact code the worker runs. + * `sw.ts` is the thin wiring that supplies the real ones. + */ + +const origin = "https://ekklesia.example.test"; + +/** A faithful enough `Cache`: keyed by URL, storing whole responses, with real `keys()` ordering. */ +function fakeCache(): DisplayCacheLike & { readonly entries: Map } { + const entries = new Map(); + return { + entries, + async delete(request) { + return entries.delete(typeof request === "string" ? request : request.url); + }, + async keys() { + return [...entries.keys()].map((url) => new Request(url)); + }, + async match(request) { + return entries.get(typeof request === "string" ? request : request.url); + }, + async put(request, response) { + entries.set(typeof request === "string" ? request : request.url, response); + }, + }; +} + +interface Harness { + readonly environment: DisplayOfflineEnvironment; + readonly caches: Map>; + readonly statuses: DisplayOfflineStatus[]; + online: boolean; + /** Responses the "server" will give, by URL. Absent means a 404. */ + readonly served: Map Response>; + readonly requested: string[]; +} + +function harness(): Harness { + const caches = new Map>(); + const statuses: DisplayOfflineStatus[] = []; + const served = new Map Response>(); + const requested: string[] = []; + const state: Harness = { + caches, + environment: { + caches: { + async delete(name) { + return caches.delete(name); + }, + async keys() { + return [...caches.keys()]; + }, + async open(name) { + const existing = caches.get(name) ?? fakeCache(); + caches.set(name, existing); + return existing; + }, + } satisfies DisplayCacheStorageLike, + async fetch(request) { + requested.push(request.url); + if (!state.online) { + throw new TypeError("Failed to fetch"); + } + const responder = served.get(request.url); + return responder ? responder() : new Response("missing", { status: 404 }); + }, + notify: (status) => statuses.push(status), + }, + online: true, + requested, + served, + statuses, + }; + return state; +} + +function code(body: string): Response { + return new Response(body, { + headers: { "content-type": "text/javascript; charset=utf-8" }, + status: 200, + }); +} + +const page = `${origin}/display/signage`; +const script = `${origin}/assets/ekklesia-display/app.js`; +const artifact = (version: string) => `${origin}/api/v1/module-ui/signage/${version}/display`; +const contribution = (version: string) => + `${origin}/api/v1/module-ui/announcements/${version}/contributions/announcements.signage-slide`; + +/** + * A navigation request, as a service worker actually receives one. + * + * `mode: "navigate"` cannot be passed to the `Request` constructor — the spec forbids it, precisely + * so page code cannot forge a navigation — so the property is defined on the instance instead. That + * is what a browser hands `event.request` for a top-level load. + */ +function navigation(url: string): Request { + const request = new Request(url); + Object.defineProperty(request, "mode", { value: "navigate" }); + return request; +} + +let live: Harness; + +beforeEach(() => { + live = harness(); + live.served.set(page, () => new Response("display", { status: 200 })); + live.served.set(script, () => code("export const host = 1;")); + live.served.set(artifact("1.0.0"), () => code("export default { version: '1.0.0' };")); + live.served.set(contribution("2.0.0"), () => code("export default { slide: true };")); +}); + +async function visit(url: string, request = new Request(url)): Promise { + return handleDisplayFetch(request, origin, live.environment); +} + +describe("what the display worker will and will not cache", () => { + it.each([ + ["the display page", page, "shell"], + ["the fixed host script", script, "shell"], + ["a module display artifact", artifact("1.0.0"), "artifact"], + ["a contributed renderer", contribution("2.0.0"), "artifact"], + ])("caches %s", (_label, url, kind) => { + const request = kind === "shell" && url === page ? navigation(url) : new Request(url); + + expect(classifyDisplayRequest(request, origin)).toBe(kind); + }); + + it.each([ + ["the display descriptor", `${origin}/api/v1/module-displays/signage`], + ["the principal endpoint", `${origin}/api/v1/principal`], + ["a module API call", `${origin}/api/v1/modules/signage/slides`], + ["a realtime stream", `${origin}/api/v1/module-realtime/signage/state`], + ["a pairing request", `${origin}/api/v1/devices/pairing`], + ["an auth route", `${origin}/api/auth/sign-in/email`], + ["the Dashboard page", `${origin}/`], + ["the worker itself", `${origin}/display/sw.js`], + ["a signed object URL", "https://objects.example.test/bucket/slide.jpg?X-Amz-Signature=opaque"], + ["any other origin", "https://evil.example.test/api/v1/module-ui/signage/1.0.0/display"], + ])("never caches %s", (_label, url) => { + // An allowlist, so a platform endpoint added later is *not* cached by default rather than + // silently becoming a stored permission decision. + expect(classifyDisplayRequest(new Request(url), origin)).toBeUndefined(); + }); + + it("never caches a non-GET request or a query-bearing URL", () => { + expect( + classifyDisplayRequest(new Request(artifact("1.0.0"), { method: "POST" }), origin), + ).toBeUndefined(); + expect(classifyDisplayRequest(new Request(`${artifact("1.0.0")}?t=1`), origin)).toBeUndefined(); + }); + + it("passes an unrecognized request through untouched, storing nothing", async () => { + const descriptor = `${origin}/api/v1/module-displays/signage`; + live.served.set(descriptor, () => Response.json({ module: {} })); + + const handled = await visit(descriptor); + + // `undefined` means "do what the browser would have done anyway" — the worker's presence must + // not change how any other request behaves. + expect(handled).toBeUndefined(); + expect(live.requested).toEqual([]); + expect(live.caches.size).toBe(0); + }); +}); + +describe("first online load", () => { + it("serves everything from the network and stores the shell and artifacts", async () => { + await visit(page, navigation(page)); + await visit(script); + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + + expect(live.requested).toEqual([page, script, artifact("1.0.0"), contribution("2.0.0")]); + expect([...(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.keys() ?? [])]).toEqual([ + page, + script, + ]); + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + artifact("1.0.0"), + contribution("2.0.0"), + ]); + expect(live.statuses.every((status) => !status.usingFallback)).toBe(true); + }); + + it("stores nothing for a failed, redirected, or not-modified response", async () => { + for (const [url, response] of [ + [artifact("1.0.0"), new Response(null, { status: 304 })], + [contribution("2.0.0"), new Response("nope", { status: 404 })], + [script, new Response("gone", { status: 503 })], + ] as const) { + live.served.set(url, () => response.clone()); + await visit(url); + } + + // A screen must never later replay an API restart's 503 as though it were the artifact. + expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size ?? 0).toBe(0); + expect(live.caches.get(DISPLAY_SHELL_CACHE)?.entries.size ?? 0).toBe(0); + }); +}); + +describe("offline reload after one successful visit", () => { + it("serves the shell from cache and reports the fallback", async () => { + await visit(page, navigation(page)); + await visit(script); + live.online = false; + live.statuses.length = 0; + + const reloadedPage = await visit(page, navigation(page)); + const reloadedScript = await visit(script); + + expect(await reloadedPage?.text()).toBe("display"); + expect(await reloadedScript?.text()).toBe("export const host = 1;"); + // A kiosk that reboots during an outage boots into Ekklesia's own shell rather than the + // browser's error page — which is the entire point of this phase. + expect(live.statuses).toEqual([ + { kind: "shell", usingFallback: true }, + { kind: "shell", usingFallback: true }, + ]); + }); + + it("falls back to the last validated artifact and contributed renderer", async () => { + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + live.online = false; + + expect(await (await visit(artifact("1.0.0")))?.text()).toBe( + "export default { version: '1.0.0' };", + ); + expect(await (await visit(contribution("2.0.0")))?.text()).toBe( + "export default { slide: true };", + ); + expect(live.statuses.filter((status) => status.usingFallback)).toHaveLength(2); + }); + + it("fails honestly when nothing was ever cached for that exact URL", async () => { + live.online = false; + + // A first visit during an outage, or a version this screen has never run. There is no honest + // answer, so the failure propagates and the page renders its own state rather than a fabricated + // one. + await expect(visit(artifact("9.9.9"))).rejects.toBeInstanceOf(TypeError); + expect(live.statuses).toEqual([{ kind: "artifact", usingFallback: true }]); + }); + + it("does not fall back across versions: a cached 1.0.0 never answers for 1.1.0", async () => { + await visit(artifact("1.0.0")); + live.online = false; + + await expect(visit(artifact("1.1.0"))).rejects.toBeInstanceOf(TypeError); + }); +}); + +describe("version upgrade and pruning", () => { + it("keeps only the current version of a module's artifacts", async () => { + await visit(artifact("1.0.0")); + live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); + + await visit(artifact("1.1.0")); + + // Version-keyed URLs mean a new build lands beside the old one; without pruning an unattended + // screen accumulates every version it has ever run. + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + artifact("1.1.0"), + ]); + }); + + it("keeps every artifact belonging to the version it just stored", async () => { + const primary = `${origin}/api/v1/module-ui/signage/1.0.0`; + live.served.set(primary, () => code("export default { primary: true };")); + await visit(artifact("1.0.0")); + await visit(primary); + + // A module's display artifact and its other artifacts share one version and are separate URLs; + // dropping either would half-cache a working display. + expect(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.size).toBe(2); + }); + + it("never prunes another module's artifacts", async () => { + await visit(artifact("1.0.0")); + await visit(contribution("2.0.0")); + live.served.set(artifact("1.1.0"), () => code("export default { version: '1.1.0' };")); + + await visit(artifact("1.1.0")); + + expect([...(live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.keys() ?? [])]).toEqual([ + contribution("2.0.0"), + artifact("1.1.0"), + ]); + }); + + it("leaves an unrecognized artifact URL shape alone rather than guessing", async () => { + const cache = fakeCache(); + await cache.put(`${origin}/api/v1/module-ui/signage`, code("odd")); + + expect(await pruneSupersededArtifacts(cache, `${origin}/api/v1/module-ui/signage`)).toEqual([]); + expect(cache.entries.size).toBe(1); + }); + + it("deletes superseded cache generations and keeps unrelated ones", async () => { + await live.environment.caches.open("ekklesia-display-shell-v0"); + await live.environment.caches.open("some-other-application-cache"); + await live.environment.caches.open(DISPLAY_SHELL_CACHE); + + const removed = await pruneSupersededCaches(live.environment); + + expect(removed).toEqual(["ekklesia-display-shell-v0"]); + expect([...live.caches.keys()]).toEqual(["some-other-application-cache", DISPLAY_SHELL_CACHE]); + }); +}); + +describe("reconnecting to current server code", () => { + it("prefers the network on every request, so a screen is never trapped on obsolete code", async () => { + await visit(artifact("1.0.0")); + live.served.set(artifact("1.0.0"), () => code("export default { rebuilt: true };")); + + const afterRebuild = await visit(artifact("1.0.0")); + + // Network first, always: the newer bytes win, and they replace the stored copy. + expect(await afterRebuild?.text()).toBe("export default { rebuilt: true };"); + expect( + await live.caches.get(DISPLAY_ARTIFACT_CACHE)?.entries.get(artifact("1.0.0"))?.text(), + ).toBe("export default { rebuilt: true };"); + }); + + it("reports that it is no longer falling back once the network returns", async () => { + await visit(script); + live.online = false; + await visit(script); + live.online = true; + live.statuses.length = 0; + + await visit(script); + + expect(live.statuses).toEqual([{ usingFallback: false }]); + }); +}); + +describe("what the cache can never do", () => { + it("cannot answer for the descriptor, so a revoked device stays revoked when online", async () => { + const descriptor = `${origin}/api/v1/module-displays/signage`; + live.served.set(descriptor, () => Response.json({ module: {} }, { status: 200 })); + await visit(descriptor); + + // It was never stored while online... + expect(live.caches.size).toBe(0); + + // ...so once the server revokes the device, the refusal is what the display sees. There is no + // cached copy of the previous success anywhere to fall back to. + live.served.set(descriptor, () => + Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }), + ); + expect(await visit(descriptor)).toBeUndefined(); + }); + + it("stores no credential, permission decision, or live state even after many visits", async () => { + for (const url of [ + `${origin}/api/v1/principal`, + `${origin}/api/v1/module-displays/signage`, + `${origin}/api/v1/modules/signage/slides`, + `${origin}/api/v1/module-realtime/signage/state`, + `${origin}/api/auth/sign-out`, + ]) { + live.served.set(url, () => Response.json({ secret: "do-not-store" })); + await visit(url); + await visit(url); + } + + const stored = [...live.caches.values()].flatMap((cache) => [...cache.entries.keys()]); + expect(stored).toEqual([]); + }); + + it("mounts nothing on its own: it only ever answers a request the page made", async () => { + // The worker has no notion of a display, a module, or a version — it classifies URLs. Every + // decision about *what* to mount stays with the page, which resolves a fresh descriptor first. + const fetchSpy = vi.fn(live.environment.fetch); + const environment: DisplayOfflineEnvironment = { ...live.environment, fetch: fetchSpy }; + + await pruneSupersededCaches(environment); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/surfaces/shell/tests/display-offline-status.test.ts b/surfaces/shell/tests/display-offline-status.test.ts new file mode 100644 index 0000000..50e3db0 --- /dev/null +++ b/surfaces/shell/tests/display-offline-status.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DisplayConnectivity, + type DisplayConnectivitySource, + DISPLAY_SERVICE_WORKER_URL, + registerDisplayServiceWorker, + watchDisplayConnectivity, +} from "../src/display/offline-status.js"; + +/** + * The display's connectivity signal (ADR 0043): what it reports, and what it deliberately refuses to + * decide. + */ + +interface FakeSource extends DisplayConnectivitySource { + emitOnline(): void; + emitOffline(): void; + emitWorkerMessage(data: unknown): void; + readonly attached: () => number; +} + +function fakeSource(initialOnline = true): FakeSource { + const windowListeners = new Map void>>(); + const workerListeners = new Set<(event: MessageEvent) => void>(); + return { + addEventListener(type, listener) { + const existing = windowListeners.get(type) ?? new Set(); + existing.add(listener); + windowListeners.set(type, existing); + }, + attached: () => + [...windowListeners.values()].reduce((total, set) => total + set.size, 0) + + workerListeners.size, + emitOffline() { + for (const listener of windowListeners.get("offline") ?? []) { + listener(); + } + }, + emitOnline() { + for (const listener of windowListeners.get("online") ?? []) { + listener(); + } + }, + emitWorkerMessage(data: unknown) { + for (const listener of workerListeners) { + listener({ data } as MessageEvent); + } + }, + initialOnline, + removeEventListener(type, listener) { + windowListeners.get(type)?.delete(listener); + }, + serviceWorkerMessages: { + addEventListener(_type, listener) { + workerListeners.add(listener); + }, + removeEventListener(_type, listener) { + workerListeners.delete(listener); + }, + }, + }; +} + +describe("display connectivity monitor", () => { + it("starts from what the browser believes and reports nothing else", () => { + const monitor = watchDisplayConnectivity(fakeSource(false)); + + expect(monitor.current()).toEqual({ online: false, usingFallback: false }); + monitor.close(); + }); + + it("reports the browser's online and offline transitions", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const seen: DisplayConnectivity[] = []; + monitor.subscribe((next) => seen.push(next)); + + source.emitOffline(); + source.emitOnline(); + + expect(seen).toEqual([ + { online: false, usingFallback: false }, + { online: true, usingFallback: false }, + ]); + monitor.close(); + }); + + it("reports a cache fallback the worker actually performed, with its kind", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const seen: DisplayConnectivity[] = []; + monitor.subscribe((next) => seen.push(next)); + + // The browser still claims to be online — a captive portal, or a dead uplink behind a live + // Wi-Fi association. Only the worker knows this application actually fell back. + source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "artifact", usingFallback: true } }); + + expect(seen).toEqual([{ fallbackKind: "artifact", online: true, usingFallback: true }]); + monitor.close(); + }); + + it("clears the fallback flag once the worker serves from the network again", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + source.emitWorkerMessage({ ekklesiaDisplayOffline: { kind: "shell", usingFallback: true } }); + + source.emitWorkerMessage({ ekklesiaDisplayOffline: { usingFallback: false } }); + + expect(monitor.current()).toEqual({ online: true, usingFallback: false }); + monitor.close(); + }); + + it("ignores a message that is not the worker's own status", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const listener = vi.fn(); + monitor.subscribe(listener); + + for (const data of [null, "offline", { other: true }, { ekklesiaDisplayOffline: "yes" }, 7]) { + source.emitWorkerMessage(data); + } + + expect(listener).not.toHaveBeenCalled(); + monitor.close(); + }); + + it("publishes only genuine changes, so an unattended screen is not re-rendered on every event", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + const listener = vi.fn(); + monitor.subscribe(listener); + + source.emitOnline(); + source.emitOnline(); + source.emitOffline(); + source.emitOffline(); + + expect(listener).toHaveBeenCalledTimes(1); + monitor.close(); + }); + + it("detaches everything on close, idempotently", () => { + const source = fakeSource(true); + const monitor = watchDisplayConnectivity(source); + monitor.subscribe(() => undefined); + expect(source.attached()).toBeGreaterThan(0); + + monitor.close(); + monitor.close(); + + expect(source.attached()).toBe(0); + }); + + it("works with no sources at all, reporting a display that simply never knows", () => { + const monitor = watchDisplayConnectivity(); + + expect(monitor.current()).toEqual({ online: true, usingFallback: false }); + monitor.close(); + }); +}); + +describe("registering the display service worker", () => { + it("registers the one fixed platform path as a module worker", async () => { + const register = vi.fn().mockResolvedValue({}); + + const registered = await registerDisplayServiceWorker({ + register, + } as unknown as ServiceWorkerContainer); + + expect(registered).toBe(true); + expect(register).toHaveBeenCalledWith(DISPLAY_SERVICE_WORKER_URL, { type: "module" }); + // The path's *directory* is the scope: `/display/`, so the worker controls display pages and + // nothing else. + expect(DISPLAY_SERVICE_WORKER_URL).toBe("/display/sw.js"); + }); + + it("never lets a missing or refused registration break the display", async () => { + // Offline resilience is an enhancement. A browser without service workers, a page served over + // plain HTTP in development, and a policy that refuses all leave a display that works exactly as + // it did before — online only. + expect(await registerDisplayServiceWorker(undefined)).toBe(false); + expect( + await registerDisplayServiceWorker({ + register: vi.fn().mockRejectedValue(new Error("SecurityError")), + } as unknown as ServiceWorkerContainer), + ).toBe(false); + }); +}); From 20d5128bad8d629edc0f1c6362495d3f07431604 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 27 Aug 2026 16:16:01 -0500 Subject: [PATCH 6/7] docs: record the five Phase 8 prerequisites, and report contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the living documentation in line with what the five passes actually shipped, and correct the claims they superseded rather than leaving them to mislead: the module UI context is six members, not five; module styling is compiled CSS, not inline objects only; the strict manifest is version 7. Also close a silent failure the new contribution mechanism introduces. A contribution whose target capability nobody provides — or whose provider moved outside its declared range — is not published, and the manifest is valid and the artifact exists, so an operator sees a screen that renders nothing with no error anywhere. `pnpm modules:validate` now lists each declared contribution with its capability and range and says whether a compatible provider is installed, and Doctor's discovery check reports the same per module. Both derive from the publication registry the API serves from, so neither can disagree with what a running platform would actually publish. Co-Authored-By: Claude Opus 5 --- README.md | 74 +++++++++++++++++- core/src/cli/validate-modules.ts | 22 ++++++ core/src/diagnostics/doctor.ts | 31 +++++++- core/tests/doctor.test.ts | 88 ++++++++++++++++++++++ docs/architecture/platform-architecture.md | 83 +++++++++++++++++++- docs/development/api.md | 2 +- docs/development/authorization.md | 5 +- docs/development/repository-workflow.md | 8 +- docs/development/security.md | 2 +- docs/development/testing.md | 71 +++++++++++++++++ 10 files changed, 375 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 76a8e47..777e180 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,8 @@ artifacts cannot drift. externalizing it would make every host supply a compatible React — defeating standalone hosting. The accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); sharing one runtime later is a build optimization that does not change the contract. Styling is React -inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar +inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled +stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, and installing a module or starting the API never triggers one. @@ -488,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never from inside its own `start` hook — and its required dependents block behind it through ADR 0028's existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict -manifest is version 6 after ADR 0033 added the optional display declaration. +manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added +browser contributions. **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and no key management, so a `secret` key's value is supplied by the deployment @@ -792,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read [Security model and deployment trust boundaries](docs/development/security.md) before installing a module or exposing the API. +**Phase 8 adds the five generic platform prerequisites the first production feature module needs.** +None of them is a feature module, and none of them names one. + +Phase 8 pass 1 gives every Ekklesia screen one design vocabulary +([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)). +`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category +and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded +literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme +editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set +of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives, +badge, toast, and empty/error/loading states), every value a token reference, with real +`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens +exactly two shared roles to modules by name and pins both design packages beneath every other role. +The Dashboard and display install the defaults at their document root; custom properties inherit into +module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No +theme editor, brand builder, second theme, or component catalogue exists.** + +Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root +([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module +imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as +text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build +rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved +`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside +its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach +the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for +styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI +is versioned with no dual acceptance; migrating is one `pnpm build`. + +Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides +([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of +the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its +own version, a target capability, a compatible range, and one artifact, and nothing else: there is no +payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it +means. Publication requires that another eligible module provides the capability inside the declared +range; a contribution that fails is absent rather than broken. A consumer discovers through +`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can +only see contributions aimed at a capability it itself provides. A contributed renderer receives its +own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration, +database, storage, or credential — and loading one grants its module nothing. + +Phase 8 pass 4 closes the object-storage write path +([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route +declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB +ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts +bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling +in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no +unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared +oversize is refused before authorization, the buffered path now counts what arrived rather than +trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the +response with the same sanitized `413` even if the handler swallowed it. Signed write URLs, +resumable uploads, quotas, and anything above the ceiling remain deliberately absent. + +Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared +code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service +worker scoped to `/display/` caches the page, the host script, the module's display artifact, and +every contributed renderer that display mounted, network-first so a screen can never be trapped on +obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API +response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is +stated rather than papered over — a display cannot render module content while offline, because +mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays +revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting +during an outage boots into Ekklesia's own offline state and recovers by itself, and that +reconnecting mounts with nothing left to download. **Module-owned offline content and media remain +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. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 71b3f70..4e74ff1 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { PermissionRegistryError } from "../authorization/index.js"; import { composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, discoverModuleSchemas, ModuleDiscoveryError, ModulePermissionCompositionError, @@ -45,11 +46,32 @@ async function main(): Promise { return; } + // Built from the same registry the API publishes from, so this command can never disagree with + // what a running platform would actually serve. A contribution whose target capability nobody + // provides — or whose provider moved outside its declared range — is otherwise silent: the + // manifest is valid, the artifact exists, and nothing renders (ADR 0041). + const publication = createModuleUiPublicationRegistry(modules); + const compatibleContributions = new Set( + publication + .listContributions() + .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), + ); + console.log( `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, ); for (const discoveredModule of modules) { console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); + for (const contribution of discoveredModule.contributions) { + const resolved = compatibleContributions.has( + `${discoveredModule.manifest.id}\u0000${contribution.id}`, + ); + console.log( + ` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${ + resolved ? "compatible provider installed" : "no compatible provider installed" + }`, + ); + } } } diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 0ac0467..c267e5f 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -57,6 +57,7 @@ import { createModuleConfigurationStore, createModuleLifecycleDatabase, createModuleLifecycleStore, + createModuleUiPublicationRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, @@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport { return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); } +/** + * One line per installed module, plus — for a module that declares browser contributions (ADR 0041) + * — how many of them have a compatible provider among the installed set. + * + * Worth reporting because the failure it names is otherwise silent: a contribution whose target + * capability nobody provides, or whose provider moved outside its declared range, is simply not + * published, and an operator sees a screen that renders nothing with no error anywhere. The counts + * are derived from the same publication registry the API serves from, so this can never disagree + * with what the running platform would actually publish. + * + * Static only, like every other Doctor check: it reports what the *manifests* support and never + * claims a module is running. Enablement and dependency blocking are the lifecycle check's job. + */ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { + const publication = createModuleUiPublicationRegistry(modules); + const compatible = new Set( + publication + .listContributions() + .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), + ); return modules.map((module) => { const id = module.manifest.id; const version = module.manifest.version; @@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin ) { throw new TypeError("Unsafe module diagnostic value."); } - return `${id} ${version}`; + const declared = module.contributions.length; + if (declared === 0) { + return `${id} ${version}`; + } + const resolved = module.contributions.filter((contribution) => + compatible.has(`${id}\u0000${contribution.id}`), + ).length; + return `${id} ${version} · ${declared} browser contribution${ + declared === 1 ? "" : "s" + } (${resolved} with a compatible provider)`; }); } diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 57b3fb3..1ab9b86 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -1472,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => { expect(check(report, "object-storage.bindings").status).toBe("skipped"); }); }); + +/** + * A browser contribution whose target capability nobody provides is otherwise silent: the manifest + * is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041). + * Doctor names it. + */ +describe("Doctor reports declared browser contributions", () => { + function contributing( + id: string, + contribution: { capability: string; capabilityRange: string }, + ): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + contributions: [ + { + artifactPath: `/unpublished/${id}/contributions/slide.js`, + capability: contribution.capability, + capabilityRange: contribution.capabilityRange, + id: `${id}.slide`, + version: "1.0.0", + }, + ], + }; + } + + function providing(id: string, capability: { id: string; version: string }): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"], + }; + } + + async function detailsFor(modules: readonly DiscoveredModule[]): Promise { + const report = await diagnoseEkklesia( + options(), + dependencies({ + discoverModules: vi.fn(async () => Object.freeze([...modules])), + discoverSchemas: vi.fn(async () => schemaAggregate(modules)), + }), + ); + return check(report, "modules.discovery").details; + } + + it("says nothing extra for a module that contributes nothing", async () => { + expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]); + }); + + it("reports a contribution whose provider satisfies its declared range", async () => { + const details = await detailsFor([ + providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }), + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]); + + expect(details).toEqual([ + "signage 0.1.0", + "announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)", + ]); + }); + + it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => { + expect( + await detailsFor([ + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]), + ).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]); + + expect( + await detailsFor([ + providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }), + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]), + ).toEqual([ + "signage 0.1.0", + "announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)", + ]); + }); +}); diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 3dcade6..92b6fde 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and independent. The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime -dependencies, consumed by module authors and hosts alike. Its host context is five members — +dependencies, consumed by module authors and hosts alike. Its host context was five members at that +checkpoint and is six today (ADR 0041) — `moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through `api.request(path)` with a path relative to the module's own API root, never by constructing Ekklesia's `/api/v1/modules/` prefix, which is what keeps a standalone host possible. @@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import. `src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler option in one `tooling` package so module authors write no build configuration. React is bundled rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host; -styling is inline style objects only, and a stylesheet import fails the build rather than emitting an +styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040), +and a stylesheet import then failed the build rather than emitting an asset nothing declares. Building remains an authoring step — Core never builds, and discovery still only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading @@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display remains deferred and the display bundle gains no Dashboard login. Public Website routing, production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling, and the missing production first-user creation path are also outside this pass. + +**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None +of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by +something other than a feature module. + +[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform +never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens — +`background.raised`, `text.danger`, `state.hover-background` — where a token's category and +`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a +registry that constructs is one that can be emitted as CSS without escaping. That validation is +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 +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 +run time and nothing for a host to forget to pass. + +[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's +inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports +plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is +still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting +against. Two rules are enforced with their own codes: a module stylesheet may reference +`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader +then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet +installed, unconditionally, because a conditional isolation boundary is one a module can fall out of +by editing a source file. That root is a correctness boundary for *styles* and is never described as +a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach +`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by +`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`. + +[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser +renderer to a capability another module provides — the browser half of the capability system Core has +had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target +capability, a compatible range, and one artifact. That is the complete list: there is no payload +schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a +"slide" is because the declaration cannot express it. Discovery resolves the artifact through the +identical loop every other compiled entrypoint uses; publication requires that *another* eligible +module provides the capability within the declared range, and a contribution that fails is absent +rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by +id — never by building a publication URL — and can only see contributions aimed at a capability it +itself provides. The input is opaque JSON validated for serializability and nothing else, so the +domain contract stays entirely between the two modules. A contributed renderer receives its own id +and that input: no API client, realtime, principal, configuration, database, storage, or credential, +and loading one grants its module nothing. + +[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left +open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined +signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at +1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route +meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to +32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather +than inferred from the size, because inferring it would silently change how an author's own handler +receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one +25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's +own limiter replaces the buffering dependency: a declared oversize is refused before authorization, +the buffered path now counts what actually arrived rather than trusting a header, and a streamed +overflow errors the stream, cancels the source, and replaces the response with the same sanitized +`413` even if the handler swallowed the error. + +[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline +resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to +`/display/` caches the page, the host script, the module's display artifact, and every contributed +renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the +realtime stream, a pairing exchange, or any cross-origin request, which is what a signed +object-delivery URL is. The consequence is stated rather than papered over: a display cannot render +module content while offline, because mounting needs an authorization-bearing descriptor that is +never cached, so a revoked device stays revoked and cached code can never manufacture current +authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline +state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is +network-first everywhere, so a screen can never be trapped on obsolete code. + +Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a +module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above +the absolute ceiling; module-owned offline *content*; and every real feature module, Signage +included. Nothing in this pass claims any of them exists. diff --git a/docs/development/api.md b/docs/development/api.md index 76c079d..1cd9b90 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1232,7 +1232,7 @@ its role-specific URL only from the protected descriptor below. ## Module display host -Manifest version 6 adds one independently optional display declaration +The manifest carries one independently optional display declaration ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)): ```json diff --git a/docs/development/authorization.md b/docs/development/authorization.md index fecce3b..9deeb50 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro ## Module permission composition -A module manifest's `permissions` array (`manifestVersion: 4`, +A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict +manifest is version 7 — see [Module development](../module-development/README.md#canonical-manifest)) declares permissions in the exact same `PermissionDeclaration` shape — but declaring one there only proves it is well-formed and correctly namespaced under the module's own id; it does not, by itself, register @@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many - `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation: every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description rejection, malformed permission id rejection. -- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version +- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version 2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above), module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue path, and default-empty-list isolation across separate parses. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 7525aad..5111686 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -37,7 +37,10 @@ the root verification command discovers them automatically. `pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A missing or empty modules directory is valid and reports zero installed modules. Installed modules -must produce the compiled JavaScript entrypoints declared by their static manifests. Optional +must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's +declared browser contributions are listed with their target capability and range, and marked +according to whether a compatible provider is installed — a contribution nobody provides is otherwise +silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and foreign-key isolation; this command does not connect to PostgreSQL or run migrations. @@ -82,7 +85,8 @@ not a broken deployment). Doctor reports a fixed, deterministic set of checks: - The active Node.js version against the repository's declared engine range. -- Static module discovery and dependency-first ordering. +- Static module discovery and dependency-first ordering. A module that declares browser + contributions also reports how many of them have a compatible provider among the installed set. - Schema discovery, including validated module and table counts. This imports compiled schema definitions, which the module contract requires to perform no configuration reads, I/O, or database connection. diff --git a/docs/development/security.md b/docs/development/security.md index d627ff5..fb88359 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -201,7 +201,7 @@ separate deployment and threat-model decision. ### A display page is authorized; its artifact bytes are not authority -Manifest version 6 binds a display artifact to one permission declared by the same module +The manifest binds a display artifact to one permission declared by the same module ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects a `public` baseline: the current display host requires an ordinary authenticated cookie principal, and public/device identity is not approximated with an anonymous exception. Both the stable diff --git a/docs/development/testing.md b/docs/development/testing.md index 4cbb0b1..218427c 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out of the separate display bundle. +Phase 8 adds evidence for the five platform prerequisites. + +**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is +deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its +name and carries a label, description, and category, that each status family pairs a colour with a +non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name, +and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`, +`!important`, an escape, a control character, or markup are each rejected — with an error that never +echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error +association, icon-button naming, tab roles and arrow-key movement, announced error and loading +states), that status never depends on colour alone, that a dialog renders in place rather than +portalling out of an isolated root, and — mechanically — that the stylesheet and every component +source contain no literal colour, font stack, or theme value and reference only registered tokens. +Both packages compile module-shaped source under the exact browser project a module uses, where +`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted. + +**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain +stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local +`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*` +variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private +alias derived from a registered token is accepted. The loader's tests prove every artifact mounts +inside an open shadow root, that the artifact receives that root rather than the host's element, that +exactly one `