# 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 └─
← shadow host, created and removed by the loader #shadow-root (open) ├─