feat: give module UI real stylesheets, and every artifact its own root

ADR 0023 refused stylesheet imports for a good 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 story. They cannot express
`:hover`, `:focus-visible`, `:disabled`, `@media`, or `@keyframes` — so an
accessible focus treatment was not expressible, and the shared elements added
in the previous commit are built from real CSS for exactly that reason.

Compile the stylesheet into the artifact instead of beside it. A module
imports plain `.css`; the canonical builder resolves local `@import`s through
a nested bundle and turns each file into its compiled text, which the author
declares as `defineModuleUi({ styles })`. One declared file still contains
everything: nothing extra to publish, nothing to fetch, and no way for CSS to
become an asset nobody serves. The build additionally fails if a CSS output
ever appears beside an artifact, so a future loader change cannot bring the
sidecar back. Two rules are enforced with their own codes: a module stylesheet
may reference `--ekklesia-*` and never declare one, and every `@import` must
resolve locally or the artifact would fetch styles at run time. Plain `.css`
only; other dialects are a toolchain decision needing their own ADR.

Then make the isolation real. The generic loader mounts every browser artifact
— primary, display, and later a contributed renderer — inside an open Shadow
DOM root it creates, installs the artifact's stylesheet there, and hands the
artifact an element inside it. Shadow DOM is the one mechanism a browser
actually enforces; a class prefix or a "scope your selectors" instruction is a
convention one mistake defeats, and the failure mode is a module silently
restyling the Dashboard. It applies unconditionally, including to artifacts
with no stylesheet, because a conditional boundary is one a module can fall
out of by editing a source file. Both wrappers are `display: contents` through
base rules installed before the artifact's own, so layout is unchanged and a
full-viewport display can still override `:host`. Unmount removes the whole
root in a `finally`, so a cleanup that throws cannot leave styles behind, and
remounting cannot accumulate a second copy.

This is a correctness boundary for styles and is not described as a sandbox:
the root is `open` precisely because module code is trusted, same-realm code
that could reach `document` anyway.

An artifact's DOM is no longer a direct child of the host's element, so the
mount ABI is versioned explicitly. `defineModuleUi` stamps
`MODULE_UI_ABI_VERSION` so it can never drift from the SDK the artifact
compiled against, and a version-1 artifact fails with its own code — "rebuild
your module", not a typo hunt. No dual acceptance, matching every manifest
cutover here. Migration is one `pnpm build`.

`pnpm boundaries` allows `.css` only under `src/ui/` and `src/display/`: a
stylesheet anywhere else in a module is the undeclared sidecar again, just
relocated.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 07:21:14 -05:00
co-authored by Claude Opus 5
parent e03d38fcab
commit 07835b7b56
38 changed files with 1460 additions and 204 deletions
+99 -13
View File
@@ -471,20 +471,105 @@ Every other shared package stays rejected inside a module: `@ekklesia/api-client
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
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