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]>
167 lines
10 KiB
Markdown
167 lines
10 KiB
Markdown
# 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*.
|