Phase 8/module platform prerequisites #40

Merged
Zayin_Admin merged 7 commits from phase-8/module-platform-prerequisites into main 2026-09-06 00:41:23 +00:00
38 changed files with 1460 additions and 204 deletions
Showing only changes of commit 07835b7b56 - Show all commits
+7 -5
View File
@@ -35,13 +35,15 @@ pnpm infrastructure:config
private source paths. private source paths.
- Use `workspace:` ranges for internal package dependencies. - Use `workspace:` ranges for internal package dependencies.
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may - Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role `src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no 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 bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
same UI SDK context with no presentation-mode flag. installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
presentation-mode flag, and the same versioned mount ABI.
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and - 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 `@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`) allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
+2
View File
@@ -28,11 +28,13 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13", "@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"happy-dom": "20.0.11",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3", "smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
+45 -12
View File
@@ -20,7 +20,13 @@ import {
discoverModules, discoverModules,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import { import {
bootstrapDisplayHost, bootstrapDisplayHost,
bootstrapHost, bootstrapHost,
@@ -165,6 +171,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"), path.join(moduleDirectory, "dist/ui/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -184,6 +191,7 @@ async function createFixtureModule(
await writeFile( await writeFile(
path.join(moduleDirectory, "dist/display/index.js"), path.join(moduleDirectory, "dist/display/index.js"),
`export default { `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: "0.1.0", version: "0.1.0",
async mount(container, context) { async mount(container, context) {
@@ -310,10 +318,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
return `data:text/javascript;base64,${bytes.toString("base64")}`; return `data:text/javascript;base64,${bytes.toString("base64")}`;
} }
/** The loader hands the container straight to the module and never inspects it, so a plain sink is /**
* enough and no DOM implementation is needed in this Node-environment suite. */ * A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
function containerSink(): { textContent: string } { * test environment.
return { textContent: "" }; *
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
* dynamically imports real module server entrypoints from temporary directories, which then fail to
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
* this server-side suite depends on exactly as Node provides it.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
} }
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
@@ -365,13 +398,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// 5. The module rendered, having reached its own backend through the host. // 5. The module rendered, having reached its own backend through the host.
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix. // 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
// 7. And it tears down cleanly. // 7. And it tears down cleanly.
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -443,10 +476,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}, },
}); });
expect(container.textContent).toBe("example:display:200:2"); expect(mountedText(container)).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(mountedText(container)).toBe("");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -514,7 +547,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}); });
// Hiding a control is a usability improvement: the module simply did not ask. // Hiding a control is a usability improvement: the module simply did not ask.
expect(container.textContent).toBe("example:denied"); expect(mountedText(container)).toBe("example:denied");
expect(harness.moduleApiCalls).toEqual([]); expect(harness.moduleApiCalls).toEqual([]);
} finally { } finally {
await harness.stop(); await harness.stop();
@@ -593,7 +626,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: groupedEntry.descriptor.version, version: groupedEntry.descriptor.version,
}, },
}); });
expect(container.textContent).toBe("example:200:2"); expect(mountedText(container)).toBe("example:200:2");
await mounted.unmount(); await mounted.unmount();
// Now move it somewhere else entirely. // Now move it somewhere else entirely.
@@ -645,7 +678,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
version: movedEntry.descriptor.version, version: movedEntry.descriptor.version,
}, },
}); });
expect(movedContainer.textContent).toBe("example:200:2"); expect(mountedText(movedContainer)).toBe("example:200:2");
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
"GET /api/v1/modules/example/records", "GET /api/v1/modules/example/records",
]); ]);
+31 -9
View File
@@ -16,6 +16,8 @@ import {
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
@@ -59,6 +61,7 @@ async function createModulesDirectory(): Promise<string> {
* sequence whose exact publication and loader-compatibility can be asserted. */ * sequence whose exact publication and loader-compatibility can be asserted. */
function artifactSource(id: string, version: string): string { function artifactSource(id: string, version: string): string {
return `export default { return `export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(id)}, id: ${JSON.stringify(id)},
version: ${JSON.stringify(version)}, version: ${JSON.stringify(version)},
mount(container, context) { mount(container, context) {
@@ -862,6 +865,12 @@ describe("module UI artifact URL generation", () => {
}); });
}); });
interface HostContainer {
readonly children: { readonly length: number };
querySelector(selectors: string): HostContainer | null;
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
}
describe("module UI artifact publication — the generic loader consumes what is published", () => { describe("module UI artifact publication — the generic loader consumes what is published", () => {
/** /**
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and * Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
@@ -880,16 +889,29 @@ describe("module UI artifact publication — the generic loader consumes what is
} }
/** /**
* The loader forwards the container untouched to the module's own `mount` and never inspects it, * A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
* so a plain sink is enough and no DOM implementation is required. The container's type is read * root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles * rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
* against Node libs only — naming a DOM type here would be the same browser/server confusion this * DOM type here would be the same browser/server confusion this route exists to keep apart.
* route exists to keep apart.
*/ */
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"]; type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
function containerSink(): { textContent: string } { /**
return { textContent: "" }; * `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
* real isolated root inside the container, and installing a DOM environment here would replace
* the module runner's resolution for a server-side suite that dynamically imports real module
* entrypoints.
*/
function containerSink(): HostContainer {
return new Window().document.createElement("div") as unknown as HostContainer;
}
/** What the mounted artifact rendered, read through its open isolated root. */
function mountedText(container: HostContainer): string {
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
return (
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
);
} }
function hostContext() { function hostContext() {
@@ -915,9 +937,9 @@ describe("module UI artifact publication — the generic loader consumes what is
source: { moduleId: "announcements", url, version: "1.4.2" }, source: { moduleId: "announcements", url, version: "1.4.2" },
}); });
expect(container.textContent).toBe("announcements:mounted"); expect(mountedText(container)).toBe("announcements:mounted");
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(container.children).toHaveLength(0);
}); });
it("still rejects the published artifact when the host expects a different identity", async () => { it("still rejects the published artifact when the host expects a different identity", async () => {
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
them here would expand the manifest and runtime contract before a host can validate them. them here would expand the manifest and runtime contract before a host can validate them.
> **Correction — this rule is superseded.**
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
> unchanged.
### Browser TypeScript stays scoped to UI source ### Browser TypeScript stays scoped to UI source
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets `@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
@@ -0,0 +1,166 @@
# 0040: Module stylesheets, and every browser artifact gets an isolated root
- 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 0023](0023-module-ui-build-pipeline.md),
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0039](0039-shared-design-tokens-and-elements.md)
## Context
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
mechanism, and a style-isolation decision, none of which exist yet."
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
not have.
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
already needs somewhere for a stylesheet to live.
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
one module's renderer mounted inside another module's screen is precisely where a leaked selector
does damage nobody can trace.
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
turn out to be the same decision.
## Decision
### A stylesheet is compiled **into** the declared artifact, as text
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
That is why no manifest field is required. There is still exactly **one** declared file per browser
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
loader change cannot quietly reintroduce one.
Two rules are enforced on the compiled text, each with its own sanitized error code:
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
a local value, and is explicitly allowed.
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
fetches a stylesheet at run time is not self-contained.
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
language and toolchain decision needing its own ADR, not a detail of a build pass.
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
### Every browser artifact mounts inside an isolated root
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
stylesheet there, and hands the artifact an element inside that root:
```text
host container ← the host's element; the loader never mutates it beyond this one child
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
#shadow-root (open)
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
```
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
instruction are conventions that one mistake defeats, and the failure mode is a module silently
restyling the Dashboard or another module's renderer.
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
one, because a conditional isolation boundary is one a module can accidentally fall out of.
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
for *styles*.
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
### Mount and unmount are deterministic, in both directions
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
a failed artifact leaves neither partial DOM nor installed styles behind.
### The mount ABI is versioned, and the cutover is hard
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
the author never writes it, so it can never drift from the SDK the artifact actually compiled
against.
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
stylesheet needs no source change at all.
## Consequences
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
accessible focus treatment actually works inside a module artifact.
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
other too: they share styles only if they share a source file.
- A host's own tests reach an artifact's DOM through its open root rather than through
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
churn is the visible price of the boundary being real.
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
(ADR 0023). It remains one file.
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
## Alternatives
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
costs none of that.
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
containment. It also cannot stop the *host's* rules from reaching into an artifact.
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
inside React would leak into the host document without ever importing a `.css` file.
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
closing the root only blinds the host's own diagnostics.
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
rebuild rather than an edit.
## Deferred
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
layout rules, and any claim that Shadow DOM confines module *code*.
+1
View File
@@ -48,3 +48,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.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 - [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 - [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
+13 -1
View File
@@ -20,7 +20,11 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in - A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and 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, `@ekklesia/module-ui-sdk`. Since
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
(`src/ui/`, `src/display/`); a stylesheet anywhere else in a module is a file no build reads and no
host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework 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 any shared role (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, beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
@@ -51,6 +55,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
Node's global declarations — into browser code. Core still owns the containing Node's global declarations — into browser code. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`. `@ekklesia/module-ui-sdk`.
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
module code remains trusted, same-realm code. The browser mount ABI is versioned
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
semantics.
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display - Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their (`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
+99 -13
View File
@@ -471,20 +471,105 @@ 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 `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
import, and `pnpm boundaries` says so. import, and `pnpm boundaries` says so.
### Styling: inline style objects only, for now ### Styling: a stylesheet compiled into your artifact
Style with React's `style` prop: Author plain `.css` beside your UI source, import it, and declare it
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
```tsx ```text
<section style={{ display: "grid", gap: "0.5rem" }}>…</section> src/ui/index.tsx
src/ui/styles.css
``` ```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than ```tsx
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no import { defineModuleUi } from "@ekklesia/module-ui-sdk";
manifest field declares it and no host loads it — so it would work in your tests and silently do import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and import styles from "./styles.css";
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
this phase. export default defineModuleUi({
id: "announcements",
version: "0.1.0",
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
mount(container, context) { /* … */ },
});
```
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
your module and include it in your UI TypeScript project:
```ts
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
```
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
Two rules are enforced by the build, each with its own error:
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
stylesheet at run time, and a declared artifact has to be self-contained.
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
object storage.
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
in your module is a file no build reads and no host loads.
### Your artifact renders in its own isolated root
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
a Shadow DOM root the host creates, with your stylesheet installed in it:
```text
host container
└─ <div data-ekklesia-module-ui>
#shadow-root (open)
├─ <style> host base rules, then your declared CSS
└─ <div> ← the container your mount() receives
```
What that buys you, and what it costs:
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
prefix and without worrying about another module's `.slide`.
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
run time, nothing for the host to pass.
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
display share styles only if they share a source file.
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
find your elements — including in your own tests, which should mount the artifact and query inside
the root the loader hands `mount`.
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
`:host { display: block }`, which works because the host installs its base rules *before* yours.
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
correctness boundary for styles, and nothing more. See
[Security model and deployment trust boundaries](../development/security.md).
### The mount ABI is versioned
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
verifies it before mounting, and an artifact built against an older SDK fails with
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
all; if your own tests assert on module DOM, they need to look inside the isolated root.
### What you may and may not use today ### What you may and may not use today
@@ -493,7 +578,7 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
| Allowed in a module | Still rejected | | Allowed in a module | Still rejected |
| --- | --- | | --- | --- |
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` | | `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.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` | | `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/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` | | `@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` |
@@ -581,8 +666,9 @@ Four things about it matter to you as a module author:
- **Only the artifact that role declares is served.** The module id and version in either URL are - **Only the artifact that role declares is served.** The module id and version in either URL are
lookup keys, never path components — there is no way to request a second file from your package. lookup keys, never path components — there is no way to request a second file from your package.
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
stylesheet import still fails the build. artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
still not supported.
- **The version must match exactly.** A request for any other version is refused rather than served - **The version must match exactly.** A request for any other version is refused rather than served
your installed artifact, so a stale host cannot mount something it did not ask for. your installed artifact, so a stale host cannot mount something it did not ask for.
- **Your artifact bytes are public.** These two routes require no authentication — fetching code - **Your artifact bytes are public.** These two routes require no authentication — fetching code
+109 -16
View File
@@ -23,6 +23,8 @@ export type ModuleUiBuildErrorCode =
| "MODULE_UI_ENTRYPOINT_UNSAFE" | "MODULE_UI_ENTRYPOINT_UNSAFE"
| "MODULE_UI_ENTRYPOINT_COLLISION" | "MODULE_UI_ENTRYPOINT_COLLISION"
| "MODULE_UI_SOURCE_MISSING" | "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_STYLESHEET_INVALID"
| "MODULE_UI_STYLESHEET_UNDECLARED"
| "MODULE_UI_BUILD_FAILED"; | "MODULE_UI_BUILD_FAILED";
export type ModuleUiArtifactKind = "ui" | "display"; export type ModuleUiArtifactKind = "ui" | "display";
@@ -34,6 +36,10 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
MODULE_UI_ENTRYPOINT_COLLISION: MODULE_UI_ENTRYPOINT_COLLISION:
"A display UI entrypoint must not overwrite another declared artifact.", "A display UI entrypoint must not overwrite another declared artifact.",
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.", MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
MODULE_UI_STYLESHEET_INVALID:
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
MODULE_UI_STYLESHEET_UNDECLARED:
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.", MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
}); });
@@ -58,32 +64,97 @@ export class ModuleUiBuildError extends Error {
} }
/** /**
* Rejects a stylesheet import instead of quietly emitting one. * A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
* * referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the * `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so * outside the registry a future theme editor enumerates (ADR 0039).
* it would work in a local test and silently do nothing in a real host. Module styling has no
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
* this additionally covers a stylesheet reached through a dependency.
*/ */
function rejectStylesheetImports(): Plugin { const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
const SURVIVING_IMPORT = /@import\s/iu;
/**
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
*
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
*
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
* only file the platform publishes, exactly as before stylesheets existed.
*
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
* detail of this pass, and would need its own ADR.
*/
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
function compileStylesheetImports(): Plugin {
const decoder = new TextDecoder();
return { return {
name: "ekklesia-reject-stylesheet-imports", name: STYLESHEET_PLUGIN_NAME,
setup(pluginBuild) { setup(pluginBuild) {
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which // No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is // rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are
// pure ASCII, so the flag would change nothing anyway. // pure ASCII, so the flag would change nothing anyway.
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({ pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
errors: [ errors: [
{ {
text: text:
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " + "Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
"load one. Use React inline style objects for now (ADR 0023).", "contract in this platform (ADR 0040).",
location: { file: args.importer }, location: { file: args.importer },
}, },
], ],
})); }));
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
const compiled = await build({
bundle: true,
entryPoints: [args.path],
logLevel: "silent",
minify: false,
target: MODULE_UI_BUILD_TARGET,
write: false,
});
const stylesheet = compiled.outputFiles
.map((outputFile) => decoder.decode(outputFile.contents))
.join("\n");
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
return {
errors: [
{
text:
"A module stylesheet may reference Ekklesia's global theme variables but must " +
"never declare one. Derive a private alias under your own prefix instead " +
"(ADR 0039).",
location: { file: args.path },
},
],
};
}
if (SURVIVING_IMPORT.test(stylesheet)) {
return {
errors: [
{
text:
"A module stylesheet left an unresolved @import. A declared artifact must be " +
"self-contained, so every import has to resolve to a local file (ADR 0040).",
location: { file: args.path },
},
],
};
}
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
});
}, },
}; };
} }
@@ -237,7 +308,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
minify: false, minify: false,
outfile: artifact.outputFile, outfile: artifact.outputFile,
platform: "browser", platform: "browser",
plugins: [rejectStylesheetImports()], plugins: [compileStylesheetImports()],
sourcemap: true, sourcemap: true,
target: MODULE_UI_BUILD_TARGET, target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle. // Fail rather than silently shipping a partial bundle.
@@ -246,6 +317,17 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set. // atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false, write: false,
}); });
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
// design exists to make impossible: a file nothing declares, no host fetches, and every local
// test silently passes without. Producing one fails the build rather than shipping it.
const sidecar = result.outputFiles.find(
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
);
if (sidecar) {
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
}
pendingWrites.push( pendingWrites.push(
result.outputFiles.map((outputFile) => ({ result.outputFiles.map((outputFile) => ({
contents: outputFile.contents, contents: outputFile.contents,
@@ -253,10 +335,21 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
})), })),
); );
} catch (error) { } catch (error) {
if (error instanceof ModuleUiBuildError) {
throw error;
}
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a // esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
// programmatic caller never receives raw paths or internals. // programmatic caller never receives raw paths or internals.
if (isRecord(error) && Array.isArray(error.errors)) { if (isRecord(error) && Array.isArray(error.errors)) {
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind), { // A stylesheet the module authored broke one of this platform's own rules, rather than
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
// allowed to do that" from "your TSX does not compile".
const code = error.errors.some(
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
)
? "MODULE_UI_STYLESHEET_INVALID"
: "MODULE_UI_BUILD_FAILED";
throw Object.assign(new ModuleUiBuildError(code, artifact.kind), {
diagnostics: error.errors, diagnostics: error.errors,
}); });
} }
@@ -342,10 +342,10 @@ describe("module UI build", () => {
}); });
it.each([ it.each([
"styles.css",
"theme.scss", "theme.scss",
"layout.less", "layout.less",
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => { "tokens.styl",
])("rejects a %s import: only plain CSS has a compilation contract", async (stylesheet) => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
files: { files: {
@@ -358,47 +358,131 @@ describe("module UI build", () => {
await buildModuleUi({ moduleDirectory: directory }); await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet import to be rejected"); expect.unreachable("expected the stylesheet import to be rejected");
} catch (error) { } catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED"); expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? []; const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe( expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(true);
true,
);
} }
}); });
it("never leaves a stylesheet sidecar next to the artifact", async () => { it("compiles a plain stylesheet into the artifact instead of emitting a sidecar", async () => {
const directory = await createModule({ const directory = await createModule({
ui: "./dist/ui/index.js", ui: "./dist/ui/index.js",
files: { files: {
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};', "src/ui/index.tsx": `
"src/ui/styles.css": ".example { color: red; }", import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": ".example { color: var(--ekklesia-text-primary); }",
}, },
}); });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf( const result = await buildModuleUi({ moduleDirectory: directory });
ModuleUiBuildError, const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
);
// The whole stylesheet travels inside the one declared artifact.
expect(artifact).toContain(".example");
expect(artifact).toContain("--ekklesia-text-primary");
// And nothing was emitted beside it. A sidecar is the exact failure this design prevents: a
// file nothing declares, no host fetches, and every local test silently passes without.
await expect( await expect(
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"), readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
).rejects.toThrow(); ).rejects.toThrow();
}); });
it("enforces the stylesheet boundary for display UI too", async () => { it("resolves a local @import so the compiled stylesheet is self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import "./base.css";\n.example { color: red; }',
"src/ui/base.css": ".base { display: grid; }",
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain(".base");
expect(artifact).toContain(".example");
});
it("rejects a stylesheet that keeps an unresolved @import, so the artifact stays self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import url("https://fonts.example.invalid/family.css");',
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_STYLESHEET_INVALID",
});
});
it("rejects a stylesheet that declares a global Ekklesia theme variable", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
// Referencing a token is the contract; *declaring* one would invent a global theme variable
// outside the registry a future theme editor enumerates (ADR 0039).
"src/ui/styles.css": ":host { --ekklesia-text-primary: hotpink; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("never declare one"))).toBe(true);
}
});
it("accepts a private alias derived from a registered token", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css":
":host { --example-gap: var(--ekklesia-spacing-sm); }\n.example { gap: var(--example-gap); }",
},
});
const artifact = await readFile(
(await buildModuleUi({ moduleDirectory: directory })).artifacts[0]?.outputFile ?? "",
"utf8",
);
expect(artifact).toContain("--example-gap");
});
it("compiles a display artifact's stylesheet the same way", async () => {
const directory = await createModule({ const directory = await createModule({
display: { display: {
artifact: "./dist/display/index.js", artifact: "./dist/display/index.js",
permission: "example.display.view", permission: "example.display.view",
}, },
files: { files: {
"src/display/index.tsx": 'import "./styles.css";\nexport default {};', "src/display/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/display/styles.css": ".display { position: fixed; }", "src/display/styles.css": ".display { position: fixed; }",
}, },
}); });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ const result = await buildModuleUi({ moduleDirectory: directory });
code: "MODULE_UI_BUILD_FAILED", const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
kind: "display",
}); expect(artifact).toContain(".display");
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow(); await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
}); });
+1
View File
@@ -1 +1,2 @@
export * from "./isolated-root.js";
export * from "./load-module-ui.js"; export * from "./load-module-ui.js";
@@ -0,0 +1,108 @@
/**
* The isolated root a host mounts a browser artifact into (ADR 0040).
*
* Every declared browser artifact — a module's primary UI, its display UI, and a contributed
* renderer — is mounted the same way, into its own Shadow DOM root the loader creates and owns:
*
* ```text
* host container ← the host's element; the loader never mutates it beyond this child
* └─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
* #shadow-root (open)
* ├─ <style data-ekklesia-module-ui-styles> base rules + the artifact's declared CSS
* └─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
* ```
*
* Shadow DOM is chosen because it is the one mechanism in a browser that actually **enforces** the
* rule rather than describing it: rules inside a shadow root do not apply outside it, and document
* rules do not apply inside it. A class-name convention, a build-time prefix, or a "please scope
* your selectors" instruction are all conventions a single mistake defeats, and the failure mode is
* a module silently restyling the Dashboard or another module's renderer.
*
* It is **not** a security boundary, and nothing here should be read as one. `mode: "open"` is
* deliberate: an installed module is trusted first-party code running in the host's own realm
* (ADR 0022, ADR 0024), it could reach `document` directly if it wanted to, and `mode: "closed"`
* would buy no protection while making the artifact's DOM unreachable to the host's own tests and
* diagnostics. This is a correctness boundary for *styles*, not a sandbox.
*
* Custom properties inherit through a shadow boundary, so the design tokens a host installed at its
* root (ADR 0039) are available inside every artifact with nothing passed and nothing imported.
*/
export const MODULE_UI_ISOLATION_ATTRIBUTE = "data-ekklesia-module-ui";
export const MODULE_UI_ISOLATION_STYLE_ATTRIBUTE = "data-ekklesia-module-ui-styles";
export const MODULE_UI_ISOLATION_ROOT_ATTRIBUTE = "data-ekklesia-module-ui-root";
/**
* The rules the host always installs, before any artifact CSS.
*
* Both wrappers are `display: contents`, so the two elements the loader adds contribute no boxes of
* their own and an artifact's own top-level elements lay out inside the host's container exactly as
* they did before isolation existed. They are declared as CSS rather than inline styles precisely so
* an artifact's own stylesheet — which the host appends *after* these — can override them when it
* genuinely needs a box to size.
*/
const BASE_STYLESHEET = `:host{display:contents}\n[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]{display:contents}`;
export interface IsolatedArtifactRoot {
/** The element handed to the artifact's `mount`. */
readonly mountTarget: Element;
/** The open shadow root, for a host's own tests and diagnostics. */
readonly shadowRoot: ShadowRoot;
/** Removes the whole isolated root — content and styles together. Idempotent. */
dispose(): void;
}
/**
* Builds one isolated root inside `container` and returns it.
*
* Nothing is attached to `container` until the shadow root, its stylesheet, and its mount target
* have all been created, so a host whose environment cannot provide Shadow DOM gets a thrown error
* and an untouched container rather than a half-built, unisolated mount.
*
* Each call creates a **fresh** root, and `dispose` removes it whole. That is what makes styles
* deterministic in both directions: a mount installs exactly one `<style>`, an unmount removes it
* with the element that owns it, and remounting cannot accumulate a second copy because there is
* nothing left to accumulate onto.
*/
export function createIsolatedArtifactRoot(
container: Element,
styles: string | undefined,
): IsolatedArtifactRoot {
const ownerDocument = container.ownerDocument;
if (!ownerDocument) {
throw new TypeError("A module UI container must belong to a document.");
}
const shadowHost = ownerDocument.createElement("div");
shadowHost.setAttribute(MODULE_UI_ISOLATION_ATTRIBUTE, "");
if (typeof shadowHost.attachShadow !== "function") {
throw new TypeError("This environment does not support Shadow DOM.");
}
const shadowRoot = shadowHost.attachShadow({ mode: "open" });
const style = ownerDocument.createElement("style");
style.setAttribute(MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, "");
// The artifact's own CSS comes last, so it wins over the base rules above by ordinary cascade
// order rather than by anything the host has to special-case.
style.textContent = styles === undefined ? BASE_STYLESHEET : `${BASE_STYLESHEET}\n${styles}`;
shadowRoot.appendChild(style);
const mountTarget = ownerDocument.createElement("div");
mountTarget.setAttribute(MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, "");
shadowRoot.appendChild(mountTarget);
container.appendChild(shadowHost);
let disposed = false;
return {
dispose: () => {
if (disposed) {
return;
}
disposed = true;
shadowHost.remove();
},
mountTarget,
shadowRoot,
};
}
+47 -8
View File
@@ -4,6 +4,7 @@ import {
type ModuleUiUnmount, type ModuleUiUnmount,
parseModuleUiExport, parseModuleUiExport,
} from "@ekklesia/module-ui-sdk"; } from "@ekklesia/module-ui-sdk";
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
/** /**
* How a host obtains a module's UI artifact. * How a host obtains a module's UI artifact.
@@ -45,7 +46,11 @@ const defaultImporter: ModuleUiImporter = (url) => import(url);
export interface LoadModuleUiOptions { export interface LoadModuleUiOptions {
readonly source: ModuleUiSource; readonly source: ModuleUiSource;
/** The element the module renders into. The host owns it; the module owns its contents. */ /**
* The element the loader builds this artifact's isolated root inside. The host owns it, the
* module owns its own root's contents, and the loader adds and removes exactly one child of its
* own (ADR 0040). The artifact never receives this element.
*/
readonly container: Element; readonly container: Element;
/** Supplied whole by the host. This package forwards it and never adds to it. */ /** Supplied whole by the host. This package forwards it and never adds to it. */
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
@@ -61,16 +66,28 @@ export interface MountedModuleUi {
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */ /** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean; readonly mounted: boolean;
/** /**
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later * This artifact's open shadow root. Exposed so a host's own tests and diagnostics can see what an
* artifact actually rendered; a host has no reason to write to it, and doing so would be reaching
* into content the module owns.
*/
readonly shadowRoot: ShadowRoot;
/**
* Runs the module's cleanup, if it returned one, then removes the isolated root the loader
* created — content and installed styles together. Idempotent: the first call cleans up, later
* calls do nothing. Safe when the module returned no cleanup function. * calls do nothing. Safe when the module returned no cleanup function.
*/ */
unmount(): Promise<void>; unmount(): Promise<void>;
} }
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED"; export type ModuleUiHostErrorCode =
| "MODULE_UI_IMPORT_FAILED"
| "MODULE_UI_ISOLATION_UNAVAILABLE"
| "MODULE_UI_MOUNT_FAILED";
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.", MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
MODULE_UI_ISOLATION_UNAVAILABLE:
"A module UI artifact could not be given an isolated root in this environment.",
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.", MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
}); });
@@ -103,6 +120,12 @@ export class ModuleUiHostError extends Error {
* *
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host * Nothing is mounted until the artifact's declared identity matches the manifest identity the host
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`. * passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
*
* Every artifact is mounted into its own isolated root the loader creates inside `container`
* (ADR 0040), and the artifact's declared `styles` are installed there. That is what keeps one
* module's stylesheet out of the Dashboard, out of another module, and out of another contributed
* renderer — and it applies identically to every role, so there is one mounting path rather than a
* styled one and an unstyled one.
*/ */
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> { export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
const { container, context, source } = options; const { container, context, source } = options;
@@ -124,16 +147,26 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
version: source.version, version: source.version,
}); });
// Built before `mount` runs and torn down again if `mount` throws, so a failed artifact leaves
// neither DOM nor styles behind.
let isolated: IsolatedArtifactRoot;
try {
isolated = createIsolatedArtifactRoot(container, definition.styles);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
}
let cleanup: ModuleUiUnmount | undefined; let cleanup: ModuleUiUnmount | undefined;
try { try {
const result = await definition.mount(container, context); const result = await definition.mount(isolated.mountTarget, context);
if (typeof result === "function") { if (typeof result === "function") {
cleanup = result; cleanup = result;
} }
} catch (error) { } catch (error) {
// No cleanup is registered for a mount that never completed, so the returned handle can never // No cleanup is registered for a mount that never completed, so the returned handle can never
// claim a failed module is mounted. The container is deliberately left untouched: the host owns // claim a failed module is mounted. The host's own container is deliberately left as it was:
// it and decides what a failed mount should look like. // the host owns it and decides what a failed mount should look like.
isolated.dispose();
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error }); throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
} }
@@ -144,14 +177,20 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
get mounted() { get mounted() {
return mounted; return mounted;
}, },
shadowRoot: isolated.shadowRoot,
async unmount() { async unmount() {
if (!mounted) { if (!mounted) {
return; return;
} }
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once // Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
// and a second call never re-runs it. // and a second call never re-runs it. The isolated root is removed in a `finally`, so a
// module whose cleanup throws still has its styles taken out of the page.
mounted = false; mounted = false;
await cleanup?.(); try {
await cleanup?.();
} finally {
isolated.dispose();
}
}, },
}; };
} }
@@ -0,0 +1,4 @@
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
@@ -1,15 +1,19 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import styles from "./styles.css";
/** /**
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built * A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader. * artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
* *
* React is deliberately absent — the module template already proves the React path end to end, and * React is deliberately absent — the module template already proves the React path end to end, and
* omitting it keeps this artifact small enough to serve as a `data:` URL. * omitting it keeps this artifact small enough to serve as a `data:` URL. It does import a local
* stylesheet, so the built artifact also proves the CSS half of that loop: `.css` source -> compiled
* text inside the declared artifact -> installed in the isolated root at mount (ADR 0040).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "example", id: "example",
version: "0.1.0", version: "0.1.0",
styles,
async mount(container, context) { async mount(container, context) {
const response = await context.api.request("records"); const response = await context.api.request("records");
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest"; const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
@@ -0,0 +1,5 @@
/* Compiled into the artifact by the canonical builder and installed into its isolated root by the
* loader (ADR 0040). It references a global token and declares none of its own. */
.artifact-label {
color: var(--ekklesia-text-primary);
}
@@ -1,7 +1,18 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import {
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
type ModuleUiHostContext,
} from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
ModuleUiHostError,
type ModuleUiImporter,
} from "../src/index.js";
/** /**
* A browser-loadable URL for a module built from source text. * A browser-loadable URL for a module built from source text.
@@ -15,10 +26,14 @@ function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`; return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
} }
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => ` const validArtifact = (
options: { id?: string; version?: string; cleanup?: boolean; styles?: string } = {},
) => `
export default { export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(options.id ?? "announcements")}, id: ${JSON.stringify(options.id ?? "announcements")},
version: ${JSON.stringify(options.version ?? "1.2.0")}, version: ${JSON.stringify(options.version ?? "1.2.0")},
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
mount(container, context) { mount(container, context) {
globalThis.__mounts ??= []; globalThis.__mounts ??= [];
globalThis.__mounts.push({ moduleId: context.moduleId, container }); globalThis.__mounts.push({ moduleId: context.moduleId, container });
@@ -54,6 +69,20 @@ function container(): Element {
return element; return element;
} }
/**
* What the artifact actually rendered, reached the way a host's own diagnostics would: through the
* open shadow root the loader created. The host's container itself holds only the loader's one
* wrapper element, which is exactly the isolation ADR 0040 introduced.
*/
function artifactRoot(element: Element): Element | null {
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null;
}
function artifactText(element: Element): string {
return artifactRoot(element)?.textContent ?? "";
}
const source = { moduleId: "announcements", version: "1.2.0" }; const source = { moduleId: "announcements", version: "1.2.0" };
describe("generic module UI host loader", () => { describe("generic module UI host loader", () => {
@@ -70,10 +99,99 @@ describe("generic module UI host loader", () => {
expect(mounted.moduleId).toBe("announcements"); expect(mounted.moduleId).toBe("announcements");
expect(mounted.version).toBe("1.2.0"); expect(mounted.version).toBe("1.2.0");
expect(mounted.mounted).toBe(true); expect(mounted.mounted).toBe(true);
expect(element.textContent).toBe("mounted:announcements"); expect(artifactText(element)).toBe("mounted:announcements");
}); });
it("passes the host's own container and context straight through", async () => { it("mounts the artifact into an isolated root rather than the host's own container", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
// The host's container gained exactly one child: the loader's shadow host.
expect(element.children).toHaveLength(1);
const shadowHost = element.firstElementChild as Element;
expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true);
expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot);
// Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed
// module is trusted code in the host's own realm and could reach `document` regardless.
expect(mounted.shadowRoot.mode).toBe("open");
// The artifact's DOM is not reachable from the host's own document tree.
expect(element.textContent).toBe("");
});
it("installs a declared stylesheet inside the isolated root, exactly once", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) },
container: element,
context: hostContext([]),
});
const styles = mounted.shadowRoot.querySelectorAll(
`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`,
);
expect(styles).toHaveLength(1);
expect(styles[0]?.textContent).toContain(".posts{color:red}");
// The host document never receives the artifact's rules.
expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull();
});
it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) },
container: element,
context: hostContext([]),
});
const stylesheet =
mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)
?.textContent ?? "";
expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan(
stylesheet.indexOf(":host{display:block}"),
);
});
it("still mounts an artifact that declares no stylesheet", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
expect(artifactText(element)).toBe("mounted:announcements");
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
});
it("never accumulates styles across repeated mounts of the same container", async () => {
const element = container();
const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" }));
for (let attempt = 0; attempt < 3; attempt += 1) {
const mounted = await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
await mounted.unmount();
expect(element.children).toHaveLength(0);
}
});
it("passes the isolated mount target and the host's context straight through", async () => {
const element = container(); const element = container();
const requested: string[] = []; const requested: string[] = [];
const context = hostContext(requested); const context = hostContext(requested);
@@ -86,7 +204,9 @@ describe("generic module UI host loader", () => {
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] }) const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
.__mounts; .__mounts;
expect(mounts?.at(-1)?.container).toBe(element); // The artifact receives the loader's isolated root, never the host's own element.
expect(mounts?.at(-1)?.container).toBe(artifactRoot(element));
expect(mounts?.at(-1)?.container).not.toBe(element);
expect(mounts?.at(-1)?.moduleId).toBe("announcements"); expect(mounts?.at(-1)?.moduleId).toBe("announcements");
}); });
@@ -104,7 +224,7 @@ describe("generic module UI host loader", () => {
expect(requested.join(" ")).not.toContain("/api/v1/modules"); expect(requested.join(" ")).not.toContain("/api/v1/modules");
}); });
it("runs the module's cleanup on unmount without touching the host container", async () => { it("runs the module's cleanup and removes the isolated root without touching the host container", async () => {
const element = container(); const element = container();
const parent = element.parentElement; const parent = element.parentElement;
@@ -116,7 +236,8 @@ describe("generic module UI host loader", () => {
await mounted.unmount(); await mounted.unmount();
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
expect(element.textContent).toBe(""); // The artifact's root, and with it its styles, are gone.
expect(element.children).toHaveLength(0);
// The host owns the container; unmounting a module never removes it. // The host owns the container; unmounting a module never removes it.
expect(element.parentElement).toBe(parent); expect(element.parentElement).toBe(parent);
expect(document.body.contains(element)).toBe(true); expect(document.body.contains(element)).toBe(true);
@@ -151,13 +272,15 @@ describe("generic module UI host loader", () => {
await expect(mounted.unmount()).resolves.toBeUndefined(); await expect(mounted.unmount()).resolves.toBeUndefined();
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
expect(element.children).toHaveLength(0);
}); });
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => { it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
const element = container(); const element = container();
const artifact = ` const artifact = `
export default { export default {
id: "announcements", version: "1.2.0", abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; }, mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
};`; };`;
@@ -169,6 +292,8 @@ describe("generic module UI host loader", () => {
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded"); await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
// A module that fails to clean up must not leave its stylesheet installed in the page.
expect(element.children).toHaveLength(0);
await expect(mounted.unmount()).resolves.toBeUndefined(); await expect(mounted.unmount()).resolves.toBeUndefined();
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1); expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
}); });
@@ -189,16 +314,17 @@ describe("generic module UI host loader", () => {
context: hostContext([]), context: hostContext([]),
}); });
expect(first.textContent).toBe("mounted:announcements"); expect(artifactText(first)).toBe("mounted:announcements");
expect(second.textContent).toBe("mounted:announcements"); expect(artifactText(second)).toBe("mounted:announcements");
expect(a.shadowRoot).not.toBe(b.shadowRoot);
await a.unmount(); await a.unmount();
// Each handle owns its own cleanup state; unmounting one leaves the other alone. // Each handle owns its own cleanup state; unmounting one leaves the other alone.
expect(a.mounted).toBe(false); expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true); expect(b.mounted).toBe(true);
expect(first.textContent).toBe(""); expect(first.children).toHaveLength(0);
expect(second.textContent).toBe("mounted:announcements"); expect(artifactText(second)).toBe("mounted:announcements");
}); });
describe("rejects an artifact before mounting it", () => { describe("rejects an artifact before mounting it", () => {
@@ -209,7 +335,7 @@ describe("generic module UI host loader", () => {
await expect( await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe(""); expect(element.children).toHaveLength(0);
}); });
it("when the version does not match the manifest", async () => { it("when the version does not match the manifest", async () => {
@@ -219,7 +345,19 @@ describe("generic module UI host loader", () => {
await expect( await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe(""); expect(element.children).toHaveLength(0);
});
it("when it was built against the previous mount ABI", async () => {
const element = container();
const url = moduleUrl(
'export default { id: "announcements", version: "1.2.0", mount() {} };',
);
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" });
expect(element.children).toHaveLength(0);
}); });
it("when there is no default export", async () => { it("when there is no default export", async () => {
@@ -233,7 +371,9 @@ describe("generic module UI host loader", () => {
it("when the default export is not a module UI definition", async () => { it("when the default export is not a module UI definition", async () => {
const element = container(); const element = container();
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };'); const url = moduleUrl(
`export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0" };`,
);
const failure = loadModuleUi({ const failure = loadModuleUi({
source: { ...source, url }, source: { ...source, url },
@@ -263,11 +403,12 @@ describe("generic module UI host loader", () => {
} }
}); });
it("reports a throwing mount as a mount failure and registers no cleanup", async () => { it("reports a throwing mount as a mount failure, registers no cleanup, and removes the root", async () => {
const element = container(); const element = container();
const url = moduleUrl(` const url = moduleUrl(`
export default { export default {
id: "announcements", version: "1.2.0", abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); }, mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
};`); };`);
@@ -284,9 +425,38 @@ describe("generic module UI host loader", () => {
expect((error as ModuleUiHostError).message).not.toContain("exploded"); expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded"); expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
} }
// No handle exists, so nothing can claim the module is mounted. The container is left exactly // No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
// as the module left it: the host owns it and decides what a failed mount looks like. // neither partial DOM nor an installed stylesheet behind in the host's container.
expect(element.textContent).toBe("partial"); expect(element.children).toHaveLength(0);
});
it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => {
const element = container();
const detached = {
ownerDocument: document,
appendChild: element.appendChild.bind(element),
} as unknown as Element;
// A container whose created element cannot take a shadow root: the loader must refuse rather
// than fall back to mounting the artifact — and its styles — straight into the host's page.
const noShadow = new Proxy(detached, {
get(target, property, receiver) {
if (property === "ownerDocument") {
return {
createElement: () => ({ setAttribute: () => undefined }),
};
}
return Reflect.get(target, property, receiver);
},
});
await expect(
loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: noShadow,
context: hostContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" });
expect(element.children).toHaveLength(0);
}); });
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => { it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
@@ -294,7 +464,14 @@ describe("generic module UI host loader", () => {
const seen: string[] = []; const seen: string[] = [];
const importModule: ModuleUiImporter = async (url) => { const importModule: ModuleUiImporter = async (url) => {
seen.push(url); seen.push(url);
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } }; return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "announcements",
version: "1.2.0",
mount: () => undefined,
},
};
}; };
const mounted = await loadModuleUi({ const mounted = await loadModuleUi({
@@ -5,7 +5,20 @@ import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build"; import { buildModuleUi } from "@ekklesia/module-ui-build";
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadModuleUi } from "../src/index.js"; import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "../src/index.js";
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
function artifactText(container: Element): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
const fixtureDirectory = path.join( const fixtureDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
@@ -80,12 +93,32 @@ describe("loading a real compiled artifact", () => {
context: ekklesiaContext(requested), context: ekklesiaContext(requested),
}); });
expect(container.textContent).toBe("example:200:user-123"); expect(artifactText(container)).toBe("example:200:user-123");
await mounted.unmount(); await mounted.unmount();
expect(container.textContent).toBe(""); expect(container.children).toHaveLength(0);
expect(mounted.mounted).toBe(false); expect(mounted.mounted).toBe(false);
}); });
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
const container = document.createElement("div");
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
expect(artifactSource).toContain(".artifact-label");
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext([]),
});
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
expect(stylesheet).toContain(".artifact-label");
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
await mounted.unmount();
});
it("still rejects the real artifact when the host expects a different version", async () => { it("still rejects the real artifact when the host expects a different version", async () => {
await expect( await expect(
loadModuleUi({ loadModuleUi({
@@ -118,8 +151,8 @@ describe("loading a real compiled artifact", () => {
// the module asked for "records" both times and resolved nothing itself. // the module asked for "records" both times and resolved nothing itself.
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]); expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
expect(standaloneRequests).toEqual(["https://example.test/api/records"]); expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
expect(ekklesiaContainer.textContent).toBe("example:200:user-123"); expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
expect(standaloneContainer.textContent).toBe("example:200:guest"); expect(artifactText(standaloneContainer)).toBe("example:200:guest");
await inEkklesia.unmount(); await inEkklesia.unmount();
await inStandalone.unmount(); await inStandalone.unmount();
+13 -7
View File
@@ -1,12 +1,18 @@
import { type ModuleUiDefinition, parseModuleUiDefinition } from "./module-ui.js"; import {
MODULE_UI_ABI_VERSION,
type ModuleUiDefinition,
type ModuleUiDefinitionInput,
parseModuleUiDefinition,
} from "./module-ui.js";
/** /**
* The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring * The module author's entry point, mirroring `defineModuleRuntime` (ADR 0006): validate at authoring
* time, freeze, and preserve the literal type. A malformed definition fails where it was written * time, freeze, and stamp the browser mount ABI this artifact was built against. A malformed
* rather than in a host, at mount time, in a browser. * definition fails where it was written rather than in a host, at mount time, in a browser.
*
* The ABI marker is added here rather than written by the author, so it can never drift from the SDK
* the artifact actually compiled against — which is the whole point of having one (ADR 0040).
*/ */
export function defineModuleUi<const Definition extends ModuleUiDefinition>( export function defineModuleUi(definition: ModuleUiDefinitionInput): ModuleUiDefinition {
definition: Definition, return parseModuleUiDefinition({ ...definition, abi: MODULE_UI_ABI_VERSION });
): Readonly<Definition> {
return parseModuleUiDefinition(definition) as Readonly<Definition>;
} }
+70 -5
View File
@@ -2,17 +2,38 @@ import type { ModuleUiHostContext } from "./host-context.js";
export type Awaitable<Value> = Value | PromiseLike<Value>; export type Awaitable<Value> = Value | PromiseLike<Value>;
/**
* The version of the browser mount ABI this package defines, and the only one a host accepts.
*
* Version 1 was `{ id, version, mount }` mounted straight into the host's own container. Version 2
* adds a declared `styles` stylesheet and makes every artifact mount inside an **isolated root** the
* host creates (ADR 0040). That is a behavioural change an artifact can observe — its DOM is no
* longer a direct child of the host's element — so it is versioned explicitly rather than
* introduced silently.
*
* There is deliberately no dual acceptance, exactly as there is none for a manifest version
* (ADR 0006, ADR 0014, ADR 0021, ADR 0033): an artifact built against version 1 fails to mount with
* `MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics. Migration is to
* rebuild the module, which is one `pnpm build` — the canonical builder owns every option, and a
* module that declares no stylesheet needs no source change at all.
*
* `defineModuleUi` stamps this for the author. A module never writes it.
*/
export const MODULE_UI_ABI_VERSION = 2;
/** /**
* Released by a host when the module UI should be torn down. A module that acquires anything during * Released by a host when the module UI should be torn down. A module that acquires anything during
* `mount` — a framework root, a subscription, a timer, an event listener — must return one of these * `mount` — a framework root, a subscription, a timer, an event listener — must return one of these
* and release that work here, exactly as a backend module releases resources in its `stop` hook * and release that work here, exactly as a backend module releases resources in its `stop` hook
* (ADR 0006). Returning nothing declares that the mount acquired nothing to release. * (ADR 0006). Returning nothing declares that the mount acquired nothing to release.
*
* A module never has to remove its declared `styles`: the host installed them into the isolated root
* it owns, and it removes that whole root after this runs.
*/ */
export type ModuleUiUnmount = () => Awaitable<void>; export type ModuleUiUnmount = () => Awaitable<void>;
/** /**
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`) * What a module author passes to {@link defineModuleUi}.
* default-exports (ADR 0021, ADR 0022, ADR 0033).
* *
* `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` * `id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition`
* exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it * exactly (ADR 0006): they let a host prove the UI bundle it imported was built from the module it
@@ -23,19 +44,38 @@ export type ModuleUiUnmount = () => Awaitable<void>;
* write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function, * write that (ADR 0022), but React appears nowhere in this type — the contract is a mount function,
* so a module's internal framework choice, and a host's, can each change without renegotiating the * so a module's internal framework choice, and a host's, can each change without renegotiating the
* platform boundary. * platform boundary.
*
* `styles` is the artifact's own compiled CSS **text**, not a URL, a filename, or a sidecar
* (ADR 0040). A module imports its local `.css` files and the canonical builder compiles them into
* this string, so the declared JavaScript artifact stays exactly as self-contained as it was before
* stylesheets existed: there is no second file to publish, nothing for a host to fetch, and no way
* for a stylesheet to become an asset nothing serves. The host installs it into the artifact's
* isolated root at mount and removes it at unmount.
*/ */
export interface ModuleUiDefinition { export interface ModuleUiDefinitionInput {
readonly id: string; readonly id: string;
readonly version: string; readonly version: string;
readonly styles?: string;
readonly mount: ( readonly mount: (
container: Element, container: Element,
context: ModuleUiHostContext, context: ModuleUiHostContext,
) => Awaitable<ModuleUiUnmount | void>; ) => Awaitable<ModuleUiUnmount | void>;
} }
/**
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
* default-exports (ADR 0021, ADR 0022, ADR 0033, ADR 0040): the author's declaration plus the ABI
* marker `defineModuleUi` stamped on it.
*/
export interface ModuleUiDefinition extends ModuleUiDefinitionInput {
readonly abi: number;
}
export type ModuleUiContractErrorCode = export type ModuleUiContractErrorCode =
| "MODULE_UI_EXPORT_MISSING" | "MODULE_UI_EXPORT_MISSING"
| "MODULE_UI_DEFINITION_INVALID" | "MODULE_UI_DEFINITION_INVALID"
| "MODULE_UI_ABI_UNSUPPORTED"
| "MODULE_UI_STYLES_INVALID"
| "MODULE_UI_IDENTITY_MISMATCH"; | "MODULE_UI_IDENTITY_MISMATCH";
const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Object.freeze({
@@ -43,6 +83,10 @@ const errorMessages: Readonly<Record<ModuleUiContractErrorCode, string>> = Objec
"A module UI entrypoint must have a default export produced by defineModuleUi.", "A module UI entrypoint must have a default export produced by defineModuleUi.",
MODULE_UI_DEFINITION_INVALID: MODULE_UI_DEFINITION_INVALID:
"A module UI definition must declare a non-empty id and version and a mount function, and nothing else.", "A module UI definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_UI_ABI_UNSUPPORTED:
"This module UI artifact was built for a different browser mount ABI. Rebuild the module against the current module UI SDK.",
MODULE_UI_STYLES_INVALID:
"A module UI definition's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_UI_IDENTITY_MISMATCH: MODULE_UI_IDENTITY_MISMATCH:
"The module UI artifact's id and version do not match the module it was loaded for.", "The module UI artifact's id and version do not match the module it was loaded for.",
}); });
@@ -64,7 +108,7 @@ export class ModuleUiContractError extends Error {
} }
} }
const definitionKeys = new Set(["id", "mount", "version"]); const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -80,12 +124,15 @@ function isNonEmptyString(value: unknown): value is string {
* grammar — there is exactly one place each of those is defined, and it is not here. * grammar — there is exactly one place each of those is defined, and it is not here.
*/ */
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition { export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
if (!isRecord(value)) { if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
return false; return false;
} }
if (Object.keys(value).some((key) => !definitionKeys.has(key))) { if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false; return false;
} }
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
return false;
}
return ( return (
isNonEmptyString(value.id) && isNonEmptyString(value.id) &&
isNonEmptyString(value.version) && isNonEmptyString(value.version) &&
@@ -93,13 +140,31 @@ export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinitio
); );
} }
/**
* Parses an artifact's default export.
*
* The ABI marker is checked **first and on its own**, so an artifact built against the previous
* mount ABI is reported as exactly that rather than as a malformed definition — the difference
* between "rebuild your module" and a developer hunting for a typo.
*/
export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition { export function parseModuleUiDefinition(value: unknown): ModuleUiDefinition {
if (!isRecord(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
}
if (value.abi !== MODULE_UI_ABI_VERSION) {
throw new ModuleUiContractError("MODULE_UI_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleUiContractError("MODULE_UI_STYLES_INVALID");
}
if (!isModuleUiDefinition(value)) { if (!isModuleUiDefinition(value)) {
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID"); throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
} }
return Object.freeze({ return Object.freeze({
abi: MODULE_UI_ABI_VERSION,
id: value.id, id: value.id,
mount: value.mount, mount: value.mount,
...(value.styles === undefined ? {} : { styles: value.styles }),
version: value.version, version: value.version,
}); });
} }
@@ -2,23 +2,32 @@ import { beforeEach, describe, expect, it } from "vitest";
import { import {
defineModuleUi, defineModuleUi,
isModuleUiDefinition, isModuleUiDefinition,
MODULE_UI_ABI_VERSION,
ModuleUiContractError, ModuleUiContractError,
type ModuleUiApiClient, type ModuleUiApiClient,
type ModuleUiDefinition, type ModuleUiDefinition,
type ModuleUiDefinitionInput,
type ModuleUiHostContext, type ModuleUiHostContext,
type ModuleUiRealtimeClient, type ModuleUiRealtimeClient,
parseModuleUiDefinition, parseModuleUiDefinition,
parseModuleUiExport, parseModuleUiExport,
} from "../src/index.js"; } from "../src/index.js";
function validDefinition(): ModuleUiDefinition { function definitionInput(): ModuleUiDefinitionInput {
return { id: "announcements", version: "0.1.0", mount: (): void => undefined }; return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
} }
/** What a compiled artifact actually default-exports: the author's declaration plus the ABI marker
* `defineModuleUi` stamps on it (ADR 0040). */
function validDefinition(): ModuleUiDefinition {
return { abi: MODULE_UI_ABI_VERSION, ...definitionInput() };
}
/** The contract treats the container as an opaque host-supplied mount target, so a plain object /** The contract treats the container as an opaque host-supplied mount target, so a plain object
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the * with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
* contract, and this package deliberately does not take a DOM dependency to obtain one. */ * contract, and this package deliberately does not take a DOM dependency to obtain one. */
const noop = (): void => undefined; const noop = (): void => undefined;
const abi = MODULE_UI_ABI_VERSION;
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({ const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
subscribe: () => Object.freeze({ close: noop }), subscribe: () => Object.freeze({ close: noop }),
@@ -29,23 +38,66 @@ function fakeContainer(): Element & { textContent: string } {
} }
describe("module UI definition contract", () => { describe("module UI definition contract", () => {
it("accepts and freezes a valid definition", () => { it("accepts, stamps the ABI on, and freezes a valid definition", () => {
const definition = defineModuleUi(validDefinition()); const definition = defineModuleUi(definitionInput());
expect(definition.id).toBe("announcements"); expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0"); expect(definition.version).toBe("0.1.0");
// Stamped by the SDK the artifact compiled against, never written by the author, so it can
// never drift from the contract the artifact was actually built for.
expect(definition.abi).toBe(MODULE_UI_ABI_VERSION);
expect(definition.styles).toBeUndefined();
expect(Object.isFrozen(definition)).toBe(true); expect(Object.isFrozen(definition)).toBe(true);
}); });
it("carries a declared stylesheet through as the artifact's own compiled CSS text", () => {
const definition = defineModuleUi({ ...definitionInput(), styles: ".a{color:red}" });
expect(definition.styles).toBe(".a{color:red}");
});
it("rejects an artifact built against a different mount ABI, with its own code", () => {
// The whole point of the marker: a version-1 artifact is reported as "rebuild your module",
// not as a malformed definition a developer would hunt for a typo in.
const versionOne = { id: "announcements", version: "0.1.0", mount: noop };
expect(isModuleUiDefinition(versionOne)).toBe(false);
try {
parseModuleUiDefinition(versionOne);
expect.unreachable("expected a contract error");
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_ABI_UNSUPPORTED");
}
expect(() =>
parseModuleUiDefinition({ ...versionOne, abi: MODULE_UI_ABI_VERSION + 1 }),
).toThrowError(expect.objectContaining({ code: "MODULE_UI_ABI_UNSUPPORTED" }));
});
it.each<[string, unknown]>([ it.each<[string, unknown]>([
["a missing id", { version: "0.1.0", mount: noop }], ["a blank stylesheet", " "],
["a blank id", { id: " ", version: "0.1.0", mount: noop }], ["a non-string stylesheet", 7],
["a non-string id", { id: 7, version: "0.1.0", mount: noop }], ["an array of stylesheets", [".a{}"]],
["a missing version", { id: "announcements", mount: noop }], ])("rejects %s", (_label, styles) => {
["a blank version", { id: "announcements", version: "", mount: noop }], const candidate = { ...validDefinition(), styles };
["a missing mount", { id: "announcements", version: "0.1.0" }],
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }], expect(isModuleUiDefinition(candidate)).toBe(false);
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }], expect(() => parseModuleUiDefinition(candidate)).toThrowError(
expect.objectContaining({ code: "MODULE_UI_STYLES_INVALID" }),
);
});
it.each<[string, unknown]>([
["a missing id", { abi, version: "0.1.0", mount: noop }],
["a blank id", { abi, id: " ", version: "0.1.0", mount: noop }],
["a non-string id", { abi, id: 7, version: "0.1.0", mount: noop }],
["a missing version", { abi, id: "announcements", mount: noop }],
["a blank version", { abi, id: "announcements", version: "", mount: noop }],
["a missing mount", { abi, id: "announcements", version: "0.1.0" }],
["a non-function mount", { abi, id: "announcements", version: "0.1.0", mount: "render" }],
[
"an unknown property",
{ abi, id: "announcements", version: "0.1.0", mount: noop, routes: [] },
],
])("rejects a definition with %s", (_label, candidate) => { ])("rejects a definition with %s", (_label, candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false); expect(isModuleUiDefinition(candidate)).toBe(false);
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError); expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
@@ -64,7 +116,7 @@ describe("module UI definition contract", () => {
it("reports a malformed definition with a stable, sanitized code", () => { it("reports a malformed definition with a stable, sanitized code", () => {
try { try {
parseModuleUiDefinition({ id: "announcements" }); parseModuleUiDefinition({ abi, id: "announcements" });
expect.unreachable("expected a contract error"); expect.unreachable("expected a contract error");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(ModuleUiContractError); expect(error).toBeInstanceOf(ModuleUiContractError);
+12
View File
@@ -58,6 +58,9 @@ importers:
'@ekklesia/module-ui-host': '@ekklesia/module-ui-host':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-ui-host version: link:../../packages/module-ui-host
'@ekklesia/module-ui-sdk':
specifier: workspace:*
version: link:../../packages/module-ui-sdk
'@ekklesia/typescript-config': '@ekklesia/typescript-config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/typescript-config version: link:../../packages/typescript-config
@@ -73,6 +76,9 @@ importers:
better-auth: better-auth:
specifier: 1.6.23 specifier: 1.6.23
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))) version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
happy-dom:
specifier: 20.0.11
version: 20.0.11
pg: pg:
specifier: 8.22.0 specifier: 8.22.0
version: 8.22.0 version: 8.22.0
@@ -364,12 +370,18 @@ importers:
templates/module: templates/module:
dependencies: dependencies:
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../../packages/design-tokens
'@ekklesia/module-sdk': '@ekklesia/module-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-sdk version: link:../../packages/module-sdk
'@ekklesia/module-ui-sdk': '@ekklesia/module-ui-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-ui-sdk version: link:../../packages/module-ui-sdk
'@ekklesia/ui':
specifier: workspace:*
version: link:../../packages/ui
drizzle-orm: drizzle-orm:
specifier: 0.45.2 specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected]) version: 0.45.2(@types/[email protected])([email protected])([email protected])
+39 -8
View File
@@ -49,12 +49,13 @@ export interface BoundaryResult {
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
// Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the // Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the
// supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source. // supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source.
// `.jsx` stays rejected because this repository is strict-TypeScript throughout, and `.css` stays // `.jsx` stays rejected because this repository is strict-TypeScript throughout. `.css` is handled
// rejected because module styling and the module UI build pipeline are not designed yet — allowing // separately below rather than listed here: since ADR 0040 a module may author a plain stylesheet,
// a stylesheet would imply a bundling contract that does not exist. // but only inside the browser-artifact source roots the canonical builder actually compiles. Every
// other stylesheet dialect stays rejected outright — adding one is a toolchain decision needing its
// own ADR, not a detail of a build pass.
const MODULE_UI_EXTENSIONS = new Set([ const MODULE_UI_EXTENSIONS = new Set([
".astro", ".astro",
".css",
".htm", ".htm",
".html", ".html",
".jsx", ".jsx",
@@ -161,6 +162,25 @@ const SHARED_PACKAGE_ROLES = new Set([
"ui", "ui",
]); ]);
/**
* The module-package-relative directories whose contents `@ekklesia/module-ui-build` compiles into a
* declared browser artifact (ADR 0023, ADR 0033, ADR 0040).
*
* A stylesheet is only meaningful inside one of these: anywhere else in a module it would be a file
* no build reads, no manifest declares, and no host ever loads — the undeclared-sidecar failure this
* repository has rejected since ADR 0023, just relocated. Restricting the location is also what
* keeps the relaxation honest: a module gains stylesheets for its browser artifacts, not the ability
* to scatter presentation files through its backend.
*/
const MODULE_BROWSER_SOURCE_ROOTS = ["src/ui/", "src/display/"];
/** Whether a repository-relative path inside a module package sits in one of those roots. Module
* packages live at `modules/<name>/…` and `templates/module/…`, both two segments deep. */
function isModuleBrowserSource(relativePath: string): boolean {
const withinPackage = relativePath.split("/").slice(2).join("/");
return MODULE_BROWSER_SOURCE_ROOTS.some((sourceRoot) => withinPackage.startsWith(sourceRoot));
}
function normalizePath(value: string): string { function normalizePath(value: string): string {
return value.split(path.sep).join("/").replace(/^\.\//, ""); return value.split(path.sep).join("/").replace(/^\.\//, "");
} }
@@ -856,10 +876,21 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
for (const repositoryFile of allFiles) { for (const repositoryFile of allFiles) {
const relativeFile = normalizePath(path.relative(root, repositoryFile)); const relativeFile = normalizePath(path.relative(root, repositoryFile));
const location = describeRepositoryPath(relativeFile); const location = describeRepositoryPath(relativeFile);
if ( if (location.layer !== "module") {
location.layer === "module" && continue;
MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile).toLowerCase()) }
) { const extension = path.extname(repositoryFile).toLowerCase();
if (extension === ".css") {
if (!isModuleBrowserSource(location.relativePath)) {
violations.push({
from: relativeFile,
message: `A module stylesheet must live inside a compiled browser source root (${MODULE_BROWSER_SOURCE_ROOTS.join(", ")}).`,
rule: "modules-do-not-own-ui",
});
}
continue;
}
if (MODULE_UI_EXTENSIONS.has(extension)) {
violations.push({ violations.push({
from: relativeFile, from: relativeFile,
message: "UI source files belong in a surface, not a module.", message: "UI source files belong in a surface, not a module.",
+23 -16
View File
@@ -1,4 +1,4 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act, Component, type ReactElement, type ReactNode } from "react"; import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,6 +9,7 @@ import type {
HostEventSourceFactory, HostEventSourceFactory,
HostEventSourceOptions, HostEventSourceOptions,
} from "../src/host-protocol.js"; } from "../src/host-protocol.js";
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@@ -154,6 +155,7 @@ function artifact(
) { ) {
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id, id,
mount(element: Element, context: ModuleUiHostContext) { mount(element: Element, context: ModuleUiHostContext) {
if (options.mount) { if (options.mount) {
@@ -204,7 +206,7 @@ describe("display host — full-viewport boundary", () => {
}); });
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
expect(query("display-module-container")?.textContent).toBe("announcements:display"); expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
expect(container.querySelector("header")).toBeNull(); expect(container.querySelector("header")).toBeNull();
expect(container.querySelector("nav")).toBeNull(); expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("aside")).toBeNull(); expect(container.querySelector("aside")).toBeNull();
@@ -239,7 +241,7 @@ describe("display host — full-viewport boundary", () => {
"realtime", "realtime",
]); ]);
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(query("display-module-container")?.textContent).toBe("permitted"); expect(isolatedText(query("display-module-container"))).toBe("permitted");
expect( expect(
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")), server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
).toBe(true); ).toBe(true);
@@ -255,6 +257,7 @@ describe("display host — full-viewport boundary", () => {
const requestedArtifacts: string[] = []; const requestedArtifacts: string[] = [];
const source = ` const source = `
export default { export default {
abi: ${MODULE_UI_ABI_VERSION},
id: "announcements", id: "announcements",
version: "1.2.0", version: "1.2.0",
mount(container, context) { mount(container, context) {
@@ -303,7 +306,7 @@ describe("display host — full-viewport boundary", () => {
expect(requestedArtifacts).toEqual([ expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`, `${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]); ]);
expect(container.querySelector('[data-testid="announcement-slide"]')?.getAttribute("src")).toBe( expect(deepQuery(container, '[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
deliveryUrl, deliveryUrl,
); );
expect(container.innerHTML).not.toContain("socket.io"); expect(container.innerHTML).not.toContain("socket.io");
@@ -375,7 +378,10 @@ describe("display host — full-viewport boundary", () => {
}; };
await act(async () => root.render(<HostApp config={config} importModule={importer} />)); await act(async () => root.render(<HostApp config={config} importModule={importer} />));
expect(container.textContent).toContain("announcement management"); // The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management",
);
expect(container.querySelector("nav")).not.toBeNull(); expect(container.querySelector("nav")).not.toBeNull();
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]); expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
@@ -401,7 +407,7 @@ describe("display host — full-viewport boundary", () => {
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`, `${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`, `${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]); ]);
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe( expect(deepQuery(container, '[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
deliveryUrl, deliveryUrl,
); );
expect(container.querySelector("nav")).toBeNull(); expect(container.querySelector("nav")).toBeNull();
@@ -478,7 +484,7 @@ describe("display host — authentication and controlled failures", () => {
await act(async () => query("display-retry")?.click()); await act(async () => query("display-retry")?.click());
expect(query("display-error")).toBeNull(); expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toBe("recovered"); expect(isolatedText(query("display-module-container"))).toBe("recovered");
expect(containers).toHaveLength(2); expect(containers).toHaveLength(2);
expect(containers[0]).not.toBe(containers[1]); expect(containers[0]).not.toBe(containers[1]);
expect(containers[0]?.isConnected).toBe(false); expect(containers[0]?.isConnected).toBe(false);
@@ -508,7 +514,7 @@ describe("display host — authentication and controlled failures", () => {
); );
await render({ apiBaseUrl, fetch: server.fetch }, importer); await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-module-container")?.textContent).toContain("/srv/modules"); expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
consoleError.mockClear(); consoleError.mockClear();
await act(async () => { await act(async () => {
@@ -525,7 +531,7 @@ describe("display host — authentication and controlled failures", () => {
}); });
expect(query("display-error")).not.toBeNull(); expect(query("display-error")).not.toBeNull();
expect(query("display-module-container")?.textContent).toBe(""); expect(isolatedText(query("display-module-container"))).toBe("");
expect(container.textContent).toBe("This display could not be loaded.Retry"); expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u); expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(importer).toHaveBeenCalledTimes(1); expect(importer).toHaveBeenCalledTimes(1);
@@ -541,7 +547,7 @@ describe("display host — authentication and controlled failures", () => {
expect(importer).toHaveBeenCalledTimes(2); expect(importer).toHaveBeenCalledTimes(2);
expect(query("display-error")).toBeNull(); expect(query("display-error")).toBeNull();
expect(query("display-module-container")?.textContent).toContain("/srv/modules"); expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
expect(mountedInto[0]?.isConnected).toBe(false); expect(mountedInto[0]?.isConnected).toBe(false);
expect(mountedInto[1]?.isConnected).toBe(true); expect(mountedInto[1]?.isConnected).toBe(true);
}); });
@@ -610,7 +616,7 @@ describe("display host — authentication and controlled failures", () => {
); );
await act(async () => { await act(async () => {
container.querySelector('[data-testid="display-media"]')?.dispatchEvent( deepQuery(container, '[data-testid="display-media"]')?.dispatchEvent(
new ErrorEvent("error", { new ErrorEvent("error", {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
@@ -620,7 +626,7 @@ describe("display host — authentication and controlled failures", () => {
}); });
expect(query("display-error")).toBeNull(); expect(query("display-error")).toBeNull();
expect(query("display-media")).not.toBeNull(); expect(deepQuery(container, '[data-testid="display-media"]')).not.toBeNull();
expect(cleanup).not.toHaveBeenCalled(); expect(cleanup).not.toHaveBeenCalled();
}); });
@@ -686,7 +692,7 @@ describe("display host — independent instances and stale work", () => {
expect(cleanups).toHaveLength(1); expect(cleanups).toHaveLength(1);
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1); expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
expect(query("display-module-container")?.textContent).toBe("announcements:display"); expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
}); });
it("discards a late import into its detached container without disturbing the newer route", async () => { it("discards a late import into its detached container without disturbing the newer route", async () => {
@@ -728,7 +734,7 @@ describe("display host — independent instances and stale work", () => {
await render(config, importer); await render(config, importer);
await render(config, importer, "calendar"); await render(config, importer, "calendar");
expect(query("display-module-container")?.textContent).toBe("calendar-live"); expect(isolatedText(query("display-module-container"))).toBe("calendar-live");
const liveContainer = query("display-module-container"); const liveContainer = query("display-module-container");
await act(async () => { await act(async () => {
@@ -737,8 +743,9 @@ describe("display host — independent instances and stale work", () => {
}); });
expect(query("display-module-container")).toBe(liveContainer); expect(query("display-module-container")).toBe(liveContainer);
expect(liveContainer?.textContent).toBe("calendar-live"); expect(isolatedText(liveContainer)).toBe("calendar-live");
const staleContainer = mountedInto.find((element) => element !== liveContainer); const liveMountTarget = isolatedMountTarget(liveContainer);
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
expect(staleContainer?.isConnected).toBe(false); expect(staleContainer?.isConnected).toBe(false);
expect(staleUnmounts).toEqual([staleContainer]); expect(staleUnmounts).toEqual([staleContainer]);
}); });
+28 -19
View File
@@ -1,9 +1,10 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@@ -99,6 +100,7 @@ interface ArtifactOptions {
function artifact(id: string, version: string, options: ArtifactOptions = {}) { function artifact(id: string, version: string, options: ArtifactOptions = {}) {
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id, id,
mount(element: Element, context: ModuleUiHostContext) { mount(element: Element, context: ModuleUiHostContext) {
options.mountedInto?.push(element); options.mountedInto?.push(element);
@@ -443,7 +445,7 @@ describe("Ekklesia shell — browser authentication", () => {
expect(query("host-login")).toBeNull(); expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull(); expect(query("host-logout")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce(); expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([ expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/principal", "/api/v1/principal",
@@ -565,6 +567,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}, },
async () => ({ async () => ({
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id: "example", id: "example",
mount(element: Element, context: ModuleUiHostContext) { mount(element: Element, context: ModuleUiHostContext) {
element.textContent = "example mounted"; element.textContent = "example mounted";
@@ -693,6 +696,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
) { ) {
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id: "example", id: "example",
mount(element: Element, context: ModuleUiHostContext) { mount(element: Element, context: ModuleUiHostContext) {
capture.context = context; capture.context = context;
@@ -770,7 +774,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}); });
expect(query("host-login")).toBeNull(); expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(capture.unmountCalls).toBe(0); expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2); expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
}); });
@@ -790,7 +794,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}); });
expect(query("host-login")).toBeNull(); expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(capture.unmountCalls).toBe(0); expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2); expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
}); });
@@ -920,7 +924,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`, `${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`, `${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
]); ]);
expect(query("module-container")?.textContent).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
}); });
it("mounts the identical artifact whichever group a module is presented in", async () => { it("mounts the identical artifact whichever group a module is presented in", async () => {
@@ -972,10 +976,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
artifact("example", "0.1.0", { mountedInto }), artifact("example", "0.1.0", { mountedInto }),
); );
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(query("module-loading")).toBeNull(); expect(query("module-loading")).toBeNull();
// The module was handed the shell's container, never the page root or the shell's own layout. // The module was handed the shell's container, never the page root or the shell's own layout.
expect(mountedInto[0]).toBe(query("module-container")); // The artifact receives the loader's isolated root, never the shell's own element (ADR 0040).
expect(mountedInto[0]).toBe(isolatedMountTarget(query("module-container")));
expect(mountedInto[0]).not.toBe(container); expect(mountedInto[0]).not.toBe(container);
expect(mountedInto[0]).not.toBe(document.body); expect(mountedInto[0]).not.toBe(document.body);
}); });
@@ -1002,7 +1007,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
}); });
expect(query("module-loading")).not.toBeNull(); expect(query("module-loading")).not.toBeNull();
expect(query("module-container")?.textContent).toBe(""); expect(isolatedText(query("module-container"))).toBe("");
await act(async () => { await act(async () => {
release?.(); release?.();
@@ -1010,7 +1015,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
}); });
expect(query("module-loading")).toBeNull(); expect(query("module-loading")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
}); });
it("unmounts the previous module when the selection changes", async () => { it("unmounts the previous module when the selection changes", async () => {
@@ -1019,6 +1024,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
const id = url.includes("/calendar/") ? "calendar" : "example"; const id = url.includes("/calendar/") ? "calendar" : "example";
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id, id,
mount(element: Element) { mount(element: Element) {
element.textContent = `${id} mounted`; element.textContent = `${id} mounted`;
@@ -1040,14 +1046,14 @@ describe("Ekklesia shell — mount lifecycle", () => {
}, },
importModule, importModule,
); );
expect(query("module-container")?.textContent).toBe("example mounted"); expect(isolatedText(query("module-container"))).toBe("example mounted");
await act(async () => { await act(async () => {
(query("select-calendar") as HTMLButtonElement).click(); (query("select-calendar") as HTMLButtonElement).click();
}); });
expect(unmountedContainers).toHaveLength(1); expect(unmountedContainers).toHaveLength(1);
expect(query("module-container")?.textContent).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
}); });
it("keeps a stale load from ever reaching the newer selection's container", async () => { it("keeps a stale load from ever reaching the newer selection's container", async () => {
@@ -1065,6 +1071,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
} }
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id, id,
mount(element: Element) { mount(element: Element) {
mountedInto.push(element); mountedInto.push(element);
@@ -1096,7 +1103,8 @@ describe("Ekklesia shell — mount lifecycle", () => {
(query("select-calendar") as HTMLButtonElement).click(); (query("select-calendar") as HTMLButtonElement).click();
}); });
const liveContainer = query("module-container"); const liveContainer = query("module-container");
expect(liveContainer?.textContent).toBe("calendar mounted"); const liveMountTarget = isolatedMountTarget(liveContainer);
expect(isolatedText(liveContainer)).toBe("calendar mounted");
// Only now does the stale `example` import resolve. // Only now does the stale `example` import resolve.
await act(async () => { await act(async () => {
@@ -1104,10 +1112,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
await examplePending; await examplePending;
}); });
// It mounted into its own, already-discarded container — never the live one — and was torn // It mounted into its own, already-discarded isolated root — never the live one — and was torn
// down immediately, so the newer selection is untouched. // down immediately, so the newer selection is untouched.
expect(liveContainer?.textContent).toBe("calendar mounted"); expect(isolatedText(liveContainer)).toBe("calendar mounted");
const staleContainer = mountedInto.find((element) => element !== liveContainer); const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
expect(staleContainer).toBeDefined(); expect(staleContainer).toBeDefined();
expect(staleContainer?.isConnected).toBe(false); expect(staleContainer?.isConnected).toBe(false);
expect(unmountedContainers).toContain(staleContainer); expect(unmountedContainers).toContain(staleContainer);
@@ -1140,7 +1148,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9")); await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
expect(query("module-error")).not.toBeNull(); expect(query("module-error")).not.toBeNull();
expect(query("module-container")?.textContent).toBe(""); expect(isolatedText(query("module-container"))).toBe("");
}); });
it("renders its own error when the module's mount throws", async () => { it("renders its own error when the module's mount throws", async () => {
@@ -1174,7 +1182,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
}); });
expect(query("module-error")).toBeNull(); expect(query("module-error")).toBeNull();
expect(query("module-container")?.textContent).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
}); });
it("survives a module whose cleanup throws, and never re-runs it", async () => { it("survives a module whose cleanup throws, and never re-runs it", async () => {
@@ -1185,6 +1193,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
} }
return { return {
default: { default: {
abi: MODULE_UI_ABI_VERSION,
id: "example", id: "example",
mount(element: Element) { mount(element: Element) {
element.textContent = "example mounted"; element.textContent = "example mounted";
@@ -1212,7 +1221,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
}); });
expect(cleanupCalls).toBe(1); expect(cleanupCalls).toBe(1);
expect(query("module-container")?.textContent).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an // Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
// instance already torn down. // instance already torn down.
@@ -1223,6 +1232,6 @@ describe("Ekklesia shell — failure is host-owned", () => {
(query("select-calendar") as HTMLButtonElement).click(); (query("select-calendar") as HTMLButtonElement).click();
}); });
expect(cleanupCalls).toBe(2); expect(cleanupCalls).toBe(2);
expect(query("module-container")?.textContent).toBe("calendar mounted"); expect(isolatedText(query("module-container"))).toBe("calendar mounted");
}); });
}); });
@@ -0,0 +1,55 @@
/**
* Test helpers for reaching a mounted module artifact's DOM.
*
* Since ADR 0040 the generic loader mounts every declared browser artifact inside its own Shadow DOM
* root, so a module's elements are deliberately *not* in the host document's tree: `querySelector`
* on the shell's container cannot see them, and neither can a stray Dashboard stylesheet. These
* helpers walk into those open roots so a shell test can still assert on what a module rendered —
* which is exactly what a host's own diagnostics would do, and is why the roots are `open`.
*
* Deliberately test-only. Production shell code never reaches into an artifact's root; the module
* owns its contents.
*/
const ISOLATION_HOST_SELECTOR = "[data-ekklesia-module-ui]";
function isolatedRoots(root: ParentNode): readonly ShadowRoot[] {
return [...root.querySelectorAll(ISOLATION_HOST_SELECTOR)]
.map((host) => host.shadowRoot)
.filter((shadowRoot): shadowRoot is ShadowRoot => shadowRoot !== null);
}
/** The first match anywhere in `root`, including inside every mounted artifact's isolated root. */
export function deepQuery(root: ParentNode, selector: string): Element | null {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
for (const shadowRoot of isolatedRoots(root)) {
const nested = deepQuery(shadowRoot, selector);
if (nested) {
return nested;
}
}
return null;
}
/** Every match anywhere in `root`, host tree first, then each artifact's isolated root in order. */
export function deepQueryAll(root: ParentNode, selector: string): readonly Element[] {
return [
...root.querySelectorAll(selector),
...isolatedRoots(root).flatMap((shadowRoot) => [...deepQueryAll(shadowRoot, selector)]),
];
}
/** The text an artifact rendered, read from the exact element the loader hands its `mount` — so the
* stylesheet the loader installed alongside it is never mistaken for content. */
export function isolatedText(container: Element | null | undefined): string {
return isolatedMountTarget(container)?.textContent ?? "";
}
/** The element the loader passed to a mounted artifact's `mount`, for identity assertions. */
export function isolatedMountTarget(container: Element | null | undefined): Element | null {
const host = container?.querySelector(ISOLATION_HOST_SELECTOR);
return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]") ?? null;
}
+2
View File
@@ -27,8 +27,10 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json" "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-sdk": "workspace:*", "@ekklesia/module-sdk": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
+8 -17
View File
@@ -1,27 +1,13 @@
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import displayStyles from "./styles.css";
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) { function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
const mayView = props.context.permissions.has("example.display.view"); const mayView = props.context.permissions.has("example.display.view");
return ( return (
<main <main className="example-display" data-testid="example-display">
data-testid="example-display" <h1 className="example-display__headline">
style={{
alignItems: "center",
background: "#111827",
color: "#f9fafb",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
textAlign: "center",
width: "100%",
}}
>
<h1 style={{ margin: 0 }}>
{mayView {mayView
? `Example full-screen display (${props.context.moduleId})` ? `Example full-screen display (${props.context.moduleId})`
: "Display unavailable"} : "Display unavailable"}
@@ -33,10 +19,15 @@ function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
/** /**
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint — * A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting. * not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
*
* It declares its own stylesheet, isolated from the primary UI's exactly as it is from the
* Dashboard's: two artifacts of the same module share no styles unless they share a source file
* (ADR 0040).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "example", id: "example",
version: "0.1.0", version: "0.1.0",
styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
root.render(<ExampleDisplay context={context} />); root.render(<ExampleDisplay context={context} />);
+36
View File
@@ -0,0 +1,36 @@
/*
* The display artifact's own stylesheet.
*
* A full-viewport display is a different product experience from the management UI, so it gets its
* own rules in its own isolated root — the two artifacts of this one module share no styles unless
* they share a source file (ADR 0040).
*
* `:host { display: block }` is a deliberate override of the platform's transparent default: a
* display needs a real box to fill the viewport, and the host installs its base rules before this
* stylesheet precisely so an artifact can override them by ordinary cascade order.
*/
:host {
display: block;
}
.example-display {
align-items: center;
background: var(--ekklesia-background-page);
color: var(--ekklesia-text-primary);
display: flex;
font-family: var(--ekklesia-typography-family-sans);
height: 100%;
justify-content: center;
min-height: 100vh;
padding: var(--ekklesia-spacing-2xl);
text-align: center;
width: 100%;
}
.example-display__headline {
font-size: var(--ekklesia-typography-size-heading-1);
font-weight: var(--ekklesia-typography-weight-bold);
line-height: var(--ekklesia-typography-line-height-tight);
margin: 0;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled
* CSS **text**, as a string (ADR 0040).
*
* ```ts
* import styles from "./styles.css";
* export default defineModuleUi({ id, version, styles, mount });
* ```
*
* There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact,
* and the host installs it into the artifact's isolated root at mount. Copy this file into your own
* module and include it in your UI TypeScript project.
*/
declare module "*.css" {
const stylesheet: string;
export default stylesheet;
}
+22 -7
View File
@@ -1,9 +1,14 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useEffect, useState } from "react";
/** /**
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads * The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
* everything it needs from the host context and hardcodes no URL, no origin, and no global. * everything it needs from the host context and hardcodes no URL, no origin, and no global.
*
* Presentation comes from the platform's shared elements and this module's own stylesheet
* (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other
* Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040).
*/ */
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement { export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
const [recordCount, setRecordCount] = useState<number | undefined>(undefined); const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
@@ -39,15 +44,25 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
}, [canRead, context.api]); }, [canRead, context.api]);
if (!canRead) { if (!canRead) {
return <p style={{ margin: 0 }}>You do not have access to example records.</p>; return (
<EmptyState
description="Ask an administrator for the example.records.read permission."
title="No access to example records"
/>
);
} }
return ( return (
<section style={{ display: "grid", gap: "0.5rem" }}> <Panel description="Everything this module knows about." title="Example module UI">
<h1 style={{ margin: 0 }}>Example module UI</h1> {recordCount === undefined ? (
<p style={{ margin: 0 }} data-testid="record-count"> <LoadingState label="Loading records…" />
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`} ) : (
</p> <div className="example-summary">
</section> <p className="example-summary__count" data-testid="record-count">
{`${recordCount} record(s)`}
</p>
</div>
)}
</Panel>
); );
} }
+9
View File
@@ -1,6 +1,8 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ExampleApp } from "./ExampleApp.js"; import { ExampleApp } from "./ExampleApp.js";
import ownStyles from "./styles.css";
/** /**
* The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports, * The module's compiled UI entrypoint. `pnpm build` bundles this file, and everything it imports,
@@ -8,10 +10,17 @@ import { ExampleApp } from "./ExampleApp.js";
* *
* `id` and `version` must match the manifest: a host verifies both before mounting, so a stale * `id` and `version` must match the manifest: a host verifies both before mounting, so a stale
* artifact is caught rather than rendered. * artifact is caught rather than rendered.
*
* `styles` is this artifact's complete stylesheet: the shared elements' rules plus this module's own
* (ADR 0040). Both are compiled into the artifact as text and installed by the host into the
* artifact's isolated root at mount, so nothing here leaks into the Dashboard and nothing in the
* Dashboard reaches in. The design tokens both stylesheets reference are inherited from the host's
* root rather than bundled (ADR 0039).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "example", id: "example",
version: "0.1.0", version: "0.1.0",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
root.render(<ExampleApp context={context} />); root.render(<ExampleApp context={context} />);
+31
View File
@@ -0,0 +1,31 @@
/*
* This module's own stylesheet.
*
* `@ekklesia/module-ui-build` compiles it into the declared browser artifact as text, and the host
* installs it into this artifact's isolated root at mount (ADR 0040). There is no sidecar file to
* publish and no URL to fetch.
*
* Two rules make this safe to write freely:
*
* 1. These selectors apply **only** inside this artifact's own root. `.example-summary` here cannot
* reach the Dashboard, another module, or another contributed renderer — and their rules cannot
* reach in.
* 2. Reference Ekklesia's global theme variables; never declare one. A private alias under your own
* prefix, like `--example-*` below, is exactly the supported way to name a local value (ADR 0039).
* The build rejects a stylesheet that declares an `--ekklesia-*` property.
*/
:host {
--example-summary-gap: var(--ekklesia-spacing-sm);
}
.example-summary {
display: grid;
gap: var(--example-summary-gap);
}
.example-summary__count {
color: var(--ekklesia-text-secondary);
font-variant-numeric: tabular-nums;
margin: 0;
}
+21 -1
View File
@@ -207,6 +207,26 @@ describe("module UI build pipeline", () => {
await (unmount as () => Promise<void>)(); await (unmount as () => Promise<void>)();
}); });
it("declares its compiled stylesheet on the artifact rather than beside it", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
// The module's own CSS *and* the shared elements' rules travel inside the one declared
// JavaScript artifact, as text (ADR 0040). A host installs them; nothing fetches a second file.
expect(definition.styles).toContain(".example-summary");
expect(definition.styles).toContain(".ek-panel");
expect(definition.styles).toContain("var(--ekklesia-text-secondary)");
// A module references the global theme and never declares part of it (ADR 0039).
expect(definition.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
await expect(readFile(path.join(path.dirname(artifactPath), "index.css"))).rejects.toThrow();
const displayNamespace = (await import(displayArtifactHref)) as unknown;
const displayDefinition = parseModuleUiExport(displayNamespace, manifestIdentity);
// Two artifacts of one module: separate roots, separate stylesheets.
expect(displayDefinition.styles).toContain(".example-display");
expect(displayDefinition.styles).not.toContain(".example-summary");
});
it("renders a denied state from the host's presentation-only permission hint", async () => { it("renders a denied state from the host's presentation-only permission hint", async () => {
const namespace = (await import(artifactHref)) as unknown; const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity); const definition = parseModuleUiExport(namespace, manifestIdentity);
@@ -219,7 +239,7 @@ describe("module UI build pipeline", () => {
); );
await flush(); await flush();
expect(container.textContent).toContain("do not have access"); expect(container.textContent).toContain("No access to example records");
// A hidden control is a usability choice, not enforcement: the module simply did not ask. // A hidden control is a usability choice, not enforcement: the module simply did not ask.
// Server-side authorization is what actually protects the route. // Server-side authorization is what actually protects the route.
expect(requested).toEqual([]); expect(requested).toEqual([]);
+7 -1
View File
@@ -4,5 +4,11 @@
"noEmit": true, "noEmit": true,
"types": [] "types": []
}, },
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"] "include": [
"src/module-styles.d.ts",
"src/ui/**/*.tsx",
"src/ui/**/*.ts",
"src/display/**/*.tsx",
"src/display/**/*.ts"
]
} }
+8 -1
View File
@@ -61,7 +61,7 @@ describe("repository dependency boundaries", () => {
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule); expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
}); });
it("rejects stylesheet and case-insensitive view files inside a module, but not TSX", async () => { it("rejects a misplaced stylesheet and case-insensitive view files inside a module, but not TSX", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-has-ui")); const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
const uiViolations = result.violations.filter( const uiViolations = result.violations.filter(
(violation) => violation.rule === "modules-do-not-own-ui", (violation) => violation.rule === "modules-do-not-own-ui",
@@ -70,10 +70,17 @@ describe("repository dependency boundaries", () => {
expect(uiViolations).toHaveLength(2); expect(uiViolations).toHaveLength(2);
expect(uiViolations.map((violation) => violation.from)).toEqual( expect(uiViolations.map((violation) => violation.from)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
// A stylesheet outside a compiled browser source root is a file no build reads and no host
// loads — the undeclared sidecar ADR 0023 rejected, just relocated (ADR 0040).
"modules/calendar/src/calendar.css", "modules/calendar/src/calendar.css",
"modules/calendar/src/view.ASTRO", "modules/calendar/src/view.ASTRO",
]), ]),
); );
// Inside `src/ui/`, a plain stylesheet is legitimate source the canonical builder compiles into
// the declared artifact.
expect(uiViolations.map((violation) => violation.from)).not.toContain(
"modules/calendar/src/ui/styles.css",
);
// React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is // React is the supported module UI authoring model (ADR 0022), so a module's own `.tsx` is
// legitimate source and must no longer be reported. // legitimate source and must no longer be reported.
expect(uiViolations.map((violation) => violation.from)).not.toContain( expect(uiViolations.map((violation) => violation.from)).not.toContain(
@@ -0,0 +1,4 @@
/* Legal since ADR 0040: a plain stylesheet inside a compiled browser source root. */
.calendar-day {
color: var(--ekklesia-text-primary);
}