Phase 8/module platform prerequisites #40
+7
-5
@@ -35,13 +35,15 @@ pnpm infrastructure:config
|
||||
private source paths.
|
||||
- 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
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||
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
|
||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
||||
same UI SDK context with no presentation-mode flag.
|
||||
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||
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
|
||||
`@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`)
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"better-auth": "1.6.23",
|
||||
"happy-dom": "20.0.11",
|
||||
"pg": "8.22.0",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
discoverModules,
|
||||
startDiscoveredModuleRuntime,
|
||||
} 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 {
|
||||
bootstrapDisplayHost,
|
||||
bootstrapHost,
|
||||
@@ -165,6 +171,7 @@ async function createFixtureModule(
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
@@ -184,6 +191,7 @@ async function createFixtureModule(
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
@@ -310,10 +318,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
||||
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. */
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||
* test environment.
|
||||
*
|
||||
* 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"];
|
||||
@@ -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.
|
||||
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.
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
|
||||
// 7. And it tears down cleanly.
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
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"]);
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
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.
|
||||
expect(container.textContent).toBe("example:denied");
|
||||
expect(mountedText(container)).toBe("example:denied");
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
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,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
await mounted.unmount();
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
expect(movedContainer.textContent).toBe("example:200:2");
|
||||
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||
"GET /api/v1/modules/example/records",
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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. */
|
||||
function artifactSource(id: string, version: string): string {
|
||||
return `export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: ${JSON.stringify(version)},
|
||||
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", () => {
|
||||
/**
|
||||
* 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,
|
||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
||||
* route exists to keep apart.
|
||||
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||
*/
|
||||
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() {
|
||||
@@ -915,9 +937,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("announcements:mounted");
|
||||
expect(mountedText(container)).toBe("announcements:mounted");
|
||||
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 () => {
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`@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*.
|
||||
@@ -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
|
||||
- [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
|
||||
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`@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
|
||||
(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,
|
||||
@@ -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
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@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
|
||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||
|
||||
@@ -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
|
||||
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
|
||||
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
||||
```text
|
||||
src/ui/index.tsx
|
||||
src/ui/styles.css
|
||||
```
|
||||
|
||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
||||
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
||||
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
||||
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
||||
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
||||
this phase.
|
||||
```tsx
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import styles from "./styles.css";
|
||||
|
||||
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
|
||||
|
||||
@@ -493,7 +578,7 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
|
||||
|
||||
| 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` |
|
||||
| `@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
|
||||
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
|
||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
||||
stylesheet import still fails the build.
|
||||
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||||
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
|
||||
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
|
||||
|
||||
@@ -23,6 +23,8 @@ export type ModuleUiBuildErrorCode =
|
||||
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
||||
| "MODULE_UI_SOURCE_MISSING"
|
||||
| "MODULE_UI_STYLESHEET_INVALID"
|
||||
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
||||
| "MODULE_UI_BUILD_FAILED";
|
||||
|
||||
export type ModuleUiArtifactKind = "ui" | "display";
|
||||
@@ -34,6 +36,10 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
|
||||
MODULE_UI_ENTRYPOINT_COLLISION:
|
||||
"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_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.",
|
||||
});
|
||||
|
||||
@@ -58,32 +64,97 @@ export class ModuleUiBuildError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a stylesheet import instead of quietly emitting one.
|
||||
*
|
||||
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
|
||||
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
|
||||
* 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.
|
||||
* 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
|
||||
* `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
|
||||
* outside the registry a future theme editor enumerates (ADR 0039).
|
||||
*/
|
||||
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 {
|
||||
name: "ekklesia-reject-stylesheet-imports",
|
||||
name: STYLESHEET_PLUGIN_NAME,
|
||||
setup(pluginBuild) {
|
||||
// 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.
|
||||
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: [
|
||||
{
|
||||
text:
|
||||
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
|
||||
"load one. Use React inline style objects for now (ADR 0023).",
|
||||
"Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
|
||||
"contract in this platform (ADR 0040).",
|
||||
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,
|
||||
outfile: artifact.outputFile,
|
||||
platform: "browser",
|
||||
plugins: [rejectStylesheetImports()],
|
||||
plugins: [compileStylesheetImports()],
|
||||
sourcemap: true,
|
||||
target: MODULE_UI_BUILD_TARGET,
|
||||
// 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.
|
||||
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(
|
||||
result.outputFiles.map((outputFile) => ({
|
||||
contents: outputFile.contents,
|
||||
@@ -253,10 +335,21 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleUiBuildError) {
|
||||
throw error;
|
||||
}
|
||||
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
||||
// programmatic caller never receives raw paths or internals.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,10 +342,10 @@ describe("module UI build", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
"styles.css",
|
||||
"theme.scss",
|
||||
"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({
|
||||
ui: "./dist/ui/index.js",
|
||||
files: {
|
||||
@@ -358,47 +358,131 @@ describe("module UI build", () => {
|
||||
await buildModuleUi({ moduleDirectory: directory });
|
||||
expect.unreachable("expected the stylesheet import to be rejected");
|
||||
} 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 ?? [];
|
||||
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(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({
|
||||
ui: "./dist/ui/index.js",
|
||||
files: {
|
||||
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
|
||||
"src/ui/styles.css": ".example { color: red; }",
|
||||
"src/ui/index.tsx": `
|
||||
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(
|
||||
ModuleUiBuildError,
|
||||
);
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
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(
|
||||
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
||||
).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({
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: "example.display.view",
|
||||
},
|
||||
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; }",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
||||
code: "MODULE_UI_BUILD_FAILED",
|
||||
kind: "display",
|
||||
});
|
||||
const result = await buildModuleUi({ moduleDirectory: directory });
|
||||
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
|
||||
|
||||
expect(artifact).toContain(".display");
|
||||
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./isolated-root.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,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ModuleUiUnmount,
|
||||
parseModuleUiExport,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
|
||||
|
||||
/**
|
||||
* How a host obtains a module's UI artifact.
|
||||
@@ -45,7 +46,11 @@ const defaultImporter: ModuleUiImporter = (url) => import(url);
|
||||
|
||||
export interface LoadModuleUiOptions {
|
||||
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;
|
||||
/** Supplied whole by the host. This package forwards it and never adds to it. */
|
||||
readonly context: ModuleUiHostContext;
|
||||
@@ -61,16 +66,28 @@ export interface MountedModuleUi {
|
||||
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||
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.
|
||||
*/
|
||||
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({
|
||||
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.",
|
||||
});
|
||||
|
||||
@@ -103,6 +120,12 @@ export class ModuleUiHostError extends Error {
|
||||
*
|
||||
* 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`.
|
||||
*
|
||||
* 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> {
|
||||
const { container, context, source } = options;
|
||||
@@ -124,16 +147,26 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
||||
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;
|
||||
try {
|
||||
const result = await definition.mount(container, context);
|
||||
const result = await definition.mount(isolated.mountTarget, context);
|
||||
if (typeof result === "function") {
|
||||
cleanup = result;
|
||||
}
|
||||
} catch (error) {
|
||||
// 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
|
||||
// it and decides what a failed mount should look like.
|
||||
// claim a failed module is mounted. The host's own container is deliberately left as it was:
|
||||
// the host owns it and decides what a failed mount should look like.
|
||||
isolated.dispose();
|
||||
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||
}
|
||||
|
||||
@@ -144,14 +177,20 @@ export async function loadModuleUi(options: LoadModuleUiOptions): Promise<Mounte
|
||||
get mounted() {
|
||||
return mounted;
|
||||
},
|
||||
shadowRoot: isolated.shadowRoot,
|
||||
async unmount() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
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 styles from "./styles.css";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
styles,
|
||||
async mount(container, context) {
|
||||
const response = await context.api.request("records");
|
||||
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
|
||||
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 { 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.
|
||||
@@ -15,10 +26,14 @@ function moduleUrl(source: string): string {
|
||||
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 {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(options.id ?? "announcements")},
|
||||
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
||||
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
|
||||
mount(container, context) {
|
||||
globalThis.__mounts ??= [];
|
||||
globalThis.__mounts.push({ moduleId: context.moduleId, container });
|
||||
@@ -54,6 +69,20 @@ function container(): 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" };
|
||||
|
||||
describe("generic module UI host loader", () => {
|
||||
@@ -70,10 +99,99 @@ describe("generic module UI host loader", () => {
|
||||
expect(mounted.moduleId).toBe("announcements");
|
||||
expect(mounted.version).toBe("1.2.0");
|
||||
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 requested: string[] = [];
|
||||
const context = hostContext(requested);
|
||||
@@ -86,7 +204,9 @@ describe("generic module UI host loader", () => {
|
||||
|
||||
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
|
||||
.__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");
|
||||
});
|
||||
|
||||
@@ -104,7 +224,7 @@ describe("generic module UI host loader", () => {
|
||||
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 parent = element.parentElement;
|
||||
|
||||
@@ -116,7 +236,8 @@ describe("generic module UI host loader", () => {
|
||||
await mounted.unmount();
|
||||
|
||||
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.
|
||||
expect(element.parentElement).toBe(parent);
|
||||
expect(document.body.contains(element)).toBe(true);
|
||||
@@ -151,13 +272,15 @@ describe("generic module UI host loader", () => {
|
||||
|
||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||
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 () => {
|
||||
const element = container();
|
||||
const artifact = `
|
||||
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"); }; },
|
||||
};`;
|
||||
|
||||
@@ -169,6 +292,8 @@ describe("generic module UI host loader", () => {
|
||||
|
||||
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
|
||||
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();
|
||||
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
|
||||
});
|
||||
@@ -189,16 +314,17 @@ describe("generic module UI host loader", () => {
|
||||
context: hostContext([]),
|
||||
});
|
||||
|
||||
expect(first.textContent).toBe("mounted:announcements");
|
||||
expect(second.textContent).toBe("mounted:announcements");
|
||||
expect(artifactText(first)).toBe("mounted:announcements");
|
||||
expect(artifactText(second)).toBe("mounted:announcements");
|
||||
expect(a.shadowRoot).not.toBe(b.shadowRoot);
|
||||
|
||||
await a.unmount();
|
||||
|
||||
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
|
||||
expect(a.mounted).toBe(false);
|
||||
expect(b.mounted).toBe(true);
|
||||
expect(first.textContent).toBe("");
|
||||
expect(second.textContent).toBe("mounted:announcements");
|
||||
expect(first.children).toHaveLength(0);
|
||||
expect(artifactText(second)).toBe("mounted:announcements");
|
||||
});
|
||||
|
||||
describe("rejects an artifact before mounting it", () => {
|
||||
@@ -209,7 +335,7 @@ describe("generic module UI host loader", () => {
|
||||
await expect(
|
||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||
).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 () => {
|
||||
@@ -219,7 +345,19 @@ describe("generic module UI host loader", () => {
|
||||
await expect(
|
||||
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
|
||||
).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 () => {
|
||||
@@ -233,7 +371,9 @@ describe("generic module UI host loader", () => {
|
||||
|
||||
it("when the default export is not a module UI definition", async () => {
|
||||
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({
|
||||
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 url = moduleUrl(`
|
||||
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"); },
|
||||
};`);
|
||||
|
||||
@@ -284,9 +425,38 @@ describe("generic module UI host loader", () => {
|
||||
expect((error as ModuleUiHostError).message).not.toContain("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
|
||||
// as the module left it: the host owns it and decides what a failed mount looks like.
|
||||
expect(element.textContent).toBe("partial");
|
||||
// No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
|
||||
// neither partial DOM nor an installed stylesheet behind in the host's container.
|
||||
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 () => {
|
||||
@@ -294,7 +464,14 @@ describe("generic module UI host loader", () => {
|
||||
const seen: string[] = [];
|
||||
const importModule: ModuleUiImporter = async (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({
|
||||
|
||||
@@ -5,7 +5,20 @@ import { fileURLToPath } from "node:url";
|
||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
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(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -80,12 +93,32 @@ describe("loading a real compiled artifact", () => {
|
||||
context: ekklesiaContext(requested),
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("example:200:user-123");
|
||||
expect(artifactText(container)).toBe("example:200:user-123");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(container.children).toHaveLength(0);
|
||||
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 () => {
|
||||
await expect(
|
||||
loadModuleUi({
|
||||
@@ -118,8 +151,8 @@ describe("loading a real compiled artifact", () => {
|
||||
// the module asked for "records" both times and resolved nothing itself.
|
||||
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
||||
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
||||
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
|
||||
expect(standaloneContainer.textContent).toBe("example:200:guest");
|
||||
expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
|
||||
expect(artifactText(standaloneContainer)).toBe("example:200:guest");
|
||||
|
||||
await inEkklesia.unmount();
|
||||
await inStandalone.unmount();
|
||||
|
||||
@@ -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
|
||||
* time, freeze, and preserve the literal type. A malformed definition fails where it was written
|
||||
* rather than in a host, at mount time, in a browser.
|
||||
* time, freeze, and stamp the browser mount ABI this artifact was built against. A malformed
|
||||
* 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>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition> {
|
||||
return parseModuleUiDefinition(definition) as Readonly<Definition>;
|
||||
export function defineModuleUi(definition: ModuleUiDefinitionInput): ModuleUiDefinition {
|
||||
return parseModuleUiDefinition({ ...definition, abi: MODULE_UI_ABI_VERSION });
|
||||
}
|
||||
|
||||
@@ -2,17 +2,38 @@ import type { ModuleUiHostContext } from "./host-context.js";
|
||||
|
||||
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
|
||||
* `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
|
||||
* (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>;
|
||||
|
||||
/**
|
||||
* What either compiled browser artifact (`entrypoints.ui` or `entrypoints.display.artifact`)
|
||||
* default-exports (ADR 0021, ADR 0022, ADR 0033).
|
||||
* What a module author passes to {@link defineModuleUi}.
|
||||
*
|
||||
* `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
|
||||
@@ -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,
|
||||
* so a module's internal framework choice, and a host's, can each change without renegotiating the
|
||||
* 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 version: string;
|
||||
readonly styles?: string;
|
||||
readonly mount: (
|
||||
container: Element,
|
||||
context: ModuleUiHostContext,
|
||||
) => 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 =
|
||||
| "MODULE_UI_EXPORT_MISSING"
|
||||
| "MODULE_UI_DEFINITION_INVALID"
|
||||
| "MODULE_UI_ABI_UNSUPPORTED"
|
||||
| "MODULE_UI_STYLES_INVALID"
|
||||
| "MODULE_UI_IDENTITY_MISMATCH";
|
||||
|
||||
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.",
|
||||
MODULE_UI_DEFINITION_INVALID:
|
||||
"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:
|
||||
"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> {
|
||||
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.
|
||||
*/
|
||||
export function isModuleUiDefinition(value: unknown): value is ModuleUiDefinition {
|
||||
if (!isRecord(value)) {
|
||||
if (!isRecord(value) || value.abi !== MODULE_UI_ABI_VERSION) {
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isNonEmptyString(value.id) &&
|
||||
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 {
|
||||
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)) {
|
||||
throw new ModuleUiContractError("MODULE_UI_DEFINITION_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: value.id,
|
||||
mount: value.mount,
|
||||
...(value.styles === undefined ? {} : { styles: value.styles }),
|
||||
version: value.version,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,23 +2,32 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defineModuleUi,
|
||||
isModuleUiDefinition,
|
||||
MODULE_UI_ABI_VERSION,
|
||||
ModuleUiContractError,
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiDefinition,
|
||||
type ModuleUiDefinitionInput,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiRealtimeClient,
|
||||
parseModuleUiDefinition,
|
||||
parseModuleUiExport,
|
||||
} from "../src/index.js";
|
||||
|
||||
function validDefinition(): ModuleUiDefinition {
|
||||
function definitionInput(): ModuleUiDefinitionInput {
|
||||
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
|
||||
* 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. */
|
||||
const noop = (): void => undefined;
|
||||
const abi = MODULE_UI_ABI_VERSION;
|
||||
|
||||
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
||||
subscribe: () => Object.freeze({ close: noop }),
|
||||
@@ -29,23 +38,66 @@ function fakeContainer(): Element & { textContent: string } {
|
||||
}
|
||||
|
||||
describe("module UI definition contract", () => {
|
||||
it("accepts and freezes a valid definition", () => {
|
||||
const definition = defineModuleUi(validDefinition());
|
||||
it("accepts, stamps the ABI on, and freezes a valid definition", () => {
|
||||
const definition = defineModuleUi(definitionInput());
|
||||
|
||||
expect(definition.id).toBe("announcements");
|
||||
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);
|
||||
});
|
||||
|
||||
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]>([
|
||||
["a missing id", { version: "0.1.0", mount: noop }],
|
||||
["a blank id", { id: " ", version: "0.1.0", mount: noop }],
|
||||
["a non-string id", { id: 7, version: "0.1.0", mount: noop }],
|
||||
["a missing version", { id: "announcements", mount: noop }],
|
||||
["a blank version", { id: "announcements", version: "", mount: noop }],
|
||||
["a missing mount", { id: "announcements", version: "0.1.0" }],
|
||||
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }],
|
||||
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }],
|
||||
["a blank stylesheet", " "],
|
||||
["a non-string stylesheet", 7],
|
||||
["an array of stylesheets", [".a{}"]],
|
||||
])("rejects %s", (_label, styles) => {
|
||||
const candidate = { ...validDefinition(), styles };
|
||||
|
||||
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||
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) => {
|
||||
expect(isModuleUiDefinition(candidate)).toBe(false);
|
||||
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
||||
@@ -64,7 +116,7 @@ describe("module UI definition contract", () => {
|
||||
|
||||
it("reports a malformed definition with a stable, sanitized code", () => {
|
||||
try {
|
||||
parseModuleUiDefinition({ id: "announcements" });
|
||||
parseModuleUiDefinition({ abi, id: "announcements" });
|
||||
expect.unreachable("expected a contract error");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleUiContractError);
|
||||
|
||||
Generated
+12
@@ -58,6 +58,9 @@ importers:
|
||||
'@ekklesia/module-ui-host':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-ui-host
|
||||
'@ekklesia/module-ui-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-ui-sdk
|
||||
'@ekklesia/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/typescript-config
|
||||
@@ -73,6 +76,9 @@ importers:
|
||||
better-auth:
|
||||
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])))
|
||||
happy-dom:
|
||||
specifier: 20.0.11
|
||||
version: 20.0.11
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
@@ -364,12 +370,18 @@ importers:
|
||||
|
||||
templates/module:
|
||||
dependencies:
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
'@ekklesia/module-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-sdk
|
||||
'@ekklesia/module-ui-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-ui-sdk
|
||||
'@ekklesia/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||
|
||||
@@ -49,12 +49,13 @@ export interface BoundaryResult {
|
||||
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
|
||||
// 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
|
||||
// rejected because module styling and the module UI build pipeline are not designed yet — allowing
|
||||
// a stylesheet would imply a bundling contract that does not exist.
|
||||
// `.jsx` stays rejected because this repository is strict-TypeScript throughout. `.css` is handled
|
||||
// separately below rather than listed here: since ADR 0040 a module may author a plain stylesheet,
|
||||
// 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([
|
||||
".astro",
|
||||
".css",
|
||||
".htm",
|
||||
".html",
|
||||
".jsx",
|
||||
@@ -161,6 +162,25 @@ const SHARED_PACKAGE_ROLES = new Set([
|
||||
"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 {
|
||||
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
||||
}
|
||||
@@ -856,10 +876,21 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
for (const repositoryFile of allFiles) {
|
||||
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||
const location = describeRepositoryPath(relativeFile);
|
||||
if (
|
||||
location.layer === "module" &&
|
||||
MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile).toLowerCase())
|
||||
) {
|
||||
if (location.layer !== "module") {
|
||||
continue;
|
||||
}
|
||||
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({
|
||||
from: relativeFile,
|
||||
message: "UI source files belong in a surface, not a module.",
|
||||
|
||||
@@ -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 { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
HostEventSourceFactory,
|
||||
HostEventSourceOptions,
|
||||
} from "../src/host-protocol.js";
|
||||
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -154,6 +155,7 @@ function artifact(
|
||||
) {
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id,
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
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(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("nav")).toBeNull();
|
||||
expect(container.querySelector("aside")).toBeNull();
|
||||
@@ -239,7 +241,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
"realtime",
|
||||
]);
|
||||
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(
|
||||
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
|
||||
).toBe(true);
|
||||
@@ -255,6 +257,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
const requestedArtifacts: string[] = [];
|
||||
const source = `
|
||||
export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: "announcements",
|
||||
version: "1.2.0",
|
||||
mount(container, context) {
|
||||
@@ -303,7 +306,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
expect(requestedArtifacts).toEqual([
|
||||
`${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,
|
||||
);
|
||||
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} />));
|
||||
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(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/display`,
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
|
||||
expect(deepQuery(container, '[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
|
||||
deliveryUrl,
|
||||
);
|
||||
expect(container.querySelector("nav")).toBeNull();
|
||||
@@ -478,7 +484,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
await act(async () => query("display-retry")?.click());
|
||||
|
||||
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[0]).not.toBe(containers[1]);
|
||||
expect(containers[0]?.isConnected).toBe(false);
|
||||
@@ -508,7 +514,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
await act(async () => {
|
||||
@@ -525,7 +531,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
});
|
||||
|
||||
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).not.toMatch(/secret|\/srv\//u);
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
@@ -541,7 +547,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
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[1]?.isConnected).toBe(true);
|
||||
});
|
||||
@@ -610,7 +616,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector('[data-testid="display-media"]')?.dispatchEvent(
|
||||
deepQuery(container, '[data-testid="display-media"]')?.dispatchEvent(
|
||||
new ErrorEvent("error", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
@@ -620,7 +626,7 @@ describe("display host — authentication and controlled failures", () => {
|
||||
});
|
||||
|
||||
expect(query("display-error")).toBeNull();
|
||||
expect(query("display-media")).not.toBeNull();
|
||||
expect(deepQuery(container, '[data-testid="display-media"]')).not.toBeNull();
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -686,7 +692,7 @@ describe("display host — independent instances and stale work", () => {
|
||||
|
||||
expect(cleanups).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 () => {
|
||||
@@ -728,7 +734,7 @@ describe("display host — independent instances and stale work", () => {
|
||||
|
||||
await render(config, importer);
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
@@ -737,8 +743,9 @@ describe("display host — independent instances and stale work", () => {
|
||||
});
|
||||
|
||||
expect(query("display-module-container")).toBe(liveContainer);
|
||||
expect(liveContainer?.textContent).toBe("calendar-live");
|
||||
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
||||
expect(isolatedText(liveContainer)).toBe("calendar-live");
|
||||
const liveMountTarget = isolatedMountTarget(liveContainer);
|
||||
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
|
||||
expect(staleContainer?.isConnected).toBe(false);
|
||||
expect(staleUnmounts).toEqual([staleContainer]);
|
||||
});
|
||||
|
||||
@@ -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 { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -99,6 +100,7 @@ interface ArtifactOptions {
|
||||
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id,
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
options.mountedInto?.push(element);
|
||||
@@ -443,7 +445,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
|
||||
expect(query("host-login")).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(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
|
||||
"/api/v1/principal",
|
||||
@@ -565,6 +567,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
},
|
||||
async () => ({
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: "example",
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
element.textContent = "example mounted";
|
||||
@@ -693,6 +696,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
) {
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: "example",
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
capture.context = context;
|
||||
@@ -770,7 +774,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
});
|
||||
|
||||
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(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("module-container")?.textContent).toBe("example mounted");
|
||||
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||
expect(capture.unmountCalls).toBe(0);
|
||||
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/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 () => {
|
||||
@@ -972,10 +976,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
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();
|
||||
// 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(document.body);
|
||||
});
|
||||
@@ -1002,7 +1007,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
});
|
||||
|
||||
expect(query("module-loading")).not.toBeNull();
|
||||
expect(query("module-container")?.textContent).toBe("");
|
||||
expect(isolatedText(query("module-container"))).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
release?.();
|
||||
@@ -1010,7 +1015,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -1019,6 +1024,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
const id = url.includes("/calendar/") ? "calendar" : "example";
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id,
|
||||
mount(element: Element) {
|
||||
element.textContent = `${id} mounted`;
|
||||
@@ -1040,14 +1046,14 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
},
|
||||
importModule,
|
||||
);
|
||||
expect(query("module-container")?.textContent).toBe("example mounted");
|
||||
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||
|
||||
await act(async () => {
|
||||
(query("select-calendar") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -1065,6 +1071,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id,
|
||||
mount(element: Element) {
|
||||
mountedInto.push(element);
|
||||
@@ -1096,7 +1103,8 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
(query("select-calendar") as HTMLButtonElement).click();
|
||||
});
|
||||
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.
|
||||
await act(async () => {
|
||||
@@ -1104,10 +1112,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
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.
|
||||
expect(liveContainer?.textContent).toBe("calendar mounted");
|
||||
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
||||
expect(isolatedText(liveContainer)).toBe("calendar mounted");
|
||||
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
|
||||
expect(staleContainer).toBeDefined();
|
||||
expect(staleContainer?.isConnected).toBe(false);
|
||||
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"));
|
||||
|
||||
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 () => {
|
||||
@@ -1174,7 +1182,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -1185,6 +1193,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: "example",
|
||||
mount(element: Element) {
|
||||
element.textContent = "example mounted";
|
||||
@@ -1212,7 +1221,7 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
||||
});
|
||||
|
||||
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
|
||||
// instance already torn down.
|
||||
@@ -1223,6 +1232,6 @@ describe("Ekklesia shell — failure is host-owned", () => {
|
||||
(query("select-calendar") as HTMLButtonElement).click();
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -27,8 +27,10 @@
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/design-tokens": "workspace:*",
|
||||
"@ekklesia/module-sdk": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/ui": "workspace:*",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import displayStyles from "./styles.css";
|
||||
|
||||
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
|
||||
const mayView = props.context.permissions.has("example.display.view");
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="example-display"
|
||||
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 }}>
|
||||
<main className="example-display" data-testid="example-display">
|
||||
<h1 className="example-display__headline">
|
||||
{mayView
|
||||
? `Example full-screen display (${props.context.moduleId})`
|
||||
: "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 —
|
||||
* 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({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
styles: displayStyles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ExampleDisplay context={context} />);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||
@@ -39,15 +44,25 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
||||
}, [canRead, context.api]);
|
||||
|
||||
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 (
|
||||
<section style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<h1 style={{ margin: 0 }}>Example module UI</h1>
|
||||
<p style={{ margin: 0 }} data-testid="record-count">
|
||||
{recordCount === undefined ? "Loading records…" : `${recordCount} record(s)`}
|
||||
<Panel description="Everything this module knows about." title="Example module UI">
|
||||
{recordCount === undefined ? (
|
||||
<LoadingState label="Loading records…" />
|
||||
) : (
|
||||
<div className="example-summary">
|
||||
<p className="example-summary__count" data-testid="record-count">
|
||||
{`${recordCount} record(s)`}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
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,
|
||||
@@ -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
|
||||
* 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({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ExampleApp context={context} />);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -207,6 +207,26 @@ describe("module UI build pipeline", () => {
|
||||
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 () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
@@ -219,7 +239,7 @@ describe("module UI build pipeline", () => {
|
||||
);
|
||||
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.
|
||||
// Server-side authorization is what actually protects the route.
|
||||
expect(requested).toEqual([]);
|
||||
|
||||
@@ -4,5 +4,11 @@
|
||||
"noEmit": true,
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("repository dependency boundaries", () => {
|
||||
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 uiViolations = result.violations.filter(
|
||||
(violation) => violation.rule === "modules-do-not-own-ui",
|
||||
@@ -70,10 +70,17 @@ describe("repository dependency boundaries", () => {
|
||||
expect(uiViolations).toHaveLength(2);
|
||||
expect(uiViolations.map((violation) => violation.from)).toEqual(
|
||||
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/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
|
||||
// legitimate source and must no longer be reported.
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user