# Module development An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior, data declarations, permissions, capabilities, HTTP routes, migrations — and, as of [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md), its own UI. It does **not** own the application that contains it. Authentication, authorization enforcement, platform context, the application shell, top-level routing, navigation, the global theme and design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host application's. A module consumes those; it never redefines or bypasses them. > **What exists today:** you can author independently optional primary and full-viewport display UI > roles in React, build their declared artifacts, have Core validate and publish them, and have > Ekklesia mount each in its proper browser surface. The primary shell has flat configurable > navigation; the display shell has no Dashboard chrome. A finished design system, display > inventory/management, public/device identity, and the standalone host do not exist. See > [Declaring UI entrypoints](#declaring-ui-entrypoints), > [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and > [What actually mounts your UI](#what-actually-mounts-your-ui). > **Before you publish or install a module:** modules are trusted compiled backend code. Your > module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and > nothing in this guide — manifests, permission declarations, typed facades, route validation, > frozen contexts — isolates it. An operator installing your module is deploying trusted server > code. See [Security model and deployment trust boundaries](../development/security.md). ## Required package shape A module is developed as an ESM workspace package in a checkout's `modules/` directory, and it is installed, self-contained, into an installation's module storage ([An installed module is self-contained](#an-installed-module-is-self-contained)). In a checkout, each immediate child of `modules/` must be an ESM workspace package with a unique package name, a strict semantic version, and the module layer marker: ```text modules/example/ ├── drizzle/ # when the module has migration history │ ├── 0000_initial.sql │ └── meta/ │ ├── 0000_snapshot.json │ ├── _ekklesia.json │ └── _journal.json ├── dist/schema/index.js # when the module owns tables ├── dist/server/index.js ├── dist/ui/index.js # when the module declares primary UI ├── dist/display/index.js # when the module declares a display ├── drizzle.config.ts # when generating migrations ├── ekklesia.module.json ├── package.json ├── src/schema/index.ts # when the module owns tables ├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit ├── src/server/index.ts ├── src/ui/index.tsx # fixed primary-UI build source, when declared └── src/display/index.tsx # fixed display build source, when declared ``` The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a module with no migration history omits `entrypoints.migrations` and needs no placeholder files. ```json { "type": "module", "ekklesia": { "layer": "module" } } ``` The package version must match the manifest and runtime-definition versions. Internal package dependencies use `workspace:` ranges and supported public package entrypoints. ### An installed module is self-contained Ekklesia's image contains no modules ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). An installation keeps installed modules in its own module storage, the directory `EKKLESIA_MODULES_DIR` names (`/var/lib/ekklesia/modules` in the production image, the checkout's `modules/` when it is unset). Discovery reads it only when a process starts. Nothing is hot loaded, and an installed module is read-only to the platform. An installed module directory is **self-contained for runtime use**: everything the platform reads or loads from it, and everything those files import at run time, resolves inside it. You never assemble that directory by hand. You build your module, and the canonical packager turns it into an **Ekklesia Module Package v1** ([ADR 0049](../architecture/decisions/0049-module-package-v1.md)): one deterministic archive and its SHA-256, which an installation verifies and extracts into module storage. **Copying `dist/` is not an installed module.** Your compiled server code imports `@ekklesia/module-sdk` and `drizzle-orm` by name, and Node resolves those only from your module's own directory upwards. The platform's `node_modules` is never on that path, and Ekklesia adds it through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bundles your backend. #### Packaging your module Build your module, then run the packager from the workspace your module is built in. It is a development dependency like `@ekklesia/module-ui-build`: ```bash pnpm exec ekklesia-pack-module --module . --target any --out ../packages \ --source-revision "$(git rev-parse HEAD)" ``` It writes `--any.tar.gz` and its `.sha256` line, and nothing else anywhere: it never installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in a workspace instead: it rewrites that workspace's install state to production-only. The archive holds one directory named with your module id: ```text / ├── package.json generated: name, version, private, "type": "module", the layer marker ├── ekklesia.module.json your manifest, byte for byte ├── ekklesia.package.json package metadata: target, runtime, provenance, file digests ├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled ├── dist/server/index.js your backend entrypoints, bundled ├── dist/schema/index.js ├── dist/chunks/ code your backend entrypoints share ├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built └── drizzle/ your migrations ``` - **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every pure-JavaScript dependency they use, the Module SDK, the contracts, and Drizzle included. The only imports left are Node.js built-ins. A dependency the bundle cannot resolve fails packaging; nothing is left to be found at run time. - **Never shipped:** `src/`, tests, TypeScript declarations, source maps, development dependencies, your development `package.json`, any `workspace:` range, and links of any kind. - **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact on disk that differs, so run your build first. - **`any` is the only target today.** A dependency that is native or platform-specific (a compiled addon, a package that declares an operating system or CPU, or one that loads a per-platform binary such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later. - **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with its declared licence and the licence and notice files it ships. A package that declares no licence fails packaging, and so does one that ships no licence text, unless its licence's standard text is complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`. - **Deterministic.** The same packager version and the same built inputs produce the same archive, byte for byte, wherever the checkout is. **Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two copies interoperate only when the copies are interchangeable, so the current implementation requires **exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The package records it under `transitional`, and an installation refuses a package whose Drizzle differs. This is a constraint of today's implementation, not a permanent contract. #### Verifying a package `node core/dist/cli/verify-module-package.js --sha256-file .sha256`, run from an Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without installing it. Pass `--extract-to ` to receive the verified module directory. [deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation. The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An installed package normally has no source, so the platform cannot check it the same way. Your module's own CI is where its boundary rules, its production dependencies, and its behaviour inside Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)). ### Removing a module keeps its data Removing your module's directory from module storage is not deleting its data: - your tables and rows, buckets and objects stay; - the platform keeps its enablement decision, your configuration, its Dashboard placement, and permission grants; - your applied migration history stays, and the migration plan reports it as retained history for a module that is not installed. Reinstalled with the same module id, your module finds all of it again. Its history is validated strictly again, so a reinstalled package must still contain every migration that was ever applied, unchanged. Never delete or edit a released migration, and never ship an older version over newer data. Disabling is different from removing: a disabled module stays installed and is simply not loaded. Deleting a module's data (purge) does not exist yet. ## Canonical manifest `ekklesia.module.json` is inert and authoritative, so Core can validate every installed module without executing module code: ```json { "manifestVersion": 7, "id": "calendar", "name": "Calendar", "version": "0.1.0", "compatibility": { "platform": "^0.1.0", "moduleSdk": "^0.1.0" }, "dependencies": { "modules": { "required": { "people": "^0.1.0" } }, "capabilities": { "required": { "people.records": "^2.0.0" }, "optional": { "media.items": ">=1.1.0 <2.0.0" } } }, "capabilities": [ { "id": "calendar.events", "version": "1.0.0" } ], "permissions": [ { "id": "calendar.events.read", "baseline": "restricted", "description": "Read calendar events." }, { "id": "calendar.display.view", "baseline": "authenticated", "description": "View the calendar display." } ], "storage": { "buckets": [ { "id": "assets", "description": "Private event assets owned by Calendar." } ] }, "contributions": [ { "id": "calendar.signage-slide", "version": "1.0.0", "capability": "signage.slide-renderer", "capabilityRange": "^1.0.0", "artifact": "./dist/contributions/signage-slide/index.js", "description": "Renders an upcoming-events slide for a signage timeline." } ], "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "migrations": "./drizzle", "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` Module, capability, and permission identifiers are lowercase and namespaced. A provided capability has its own exact semantic version because its public contract can evolve independently from the module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. `manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a version 6 manifest, and any other version, fails validation outright rather than being silently reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. Version 7 adds the optional `contributions` array ([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that contributes nothing changes only the version literal; every browser artifact remains independently optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin with `.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or `restricted` — see [Authorization](../development/authorization.md#permission-registry) for what each baseline means to the evaluator), and a required, non-blank `description` up to 240 characters for operators and audit review only — Core's authorization layer never reads or interprets it. Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest only proves the declaration is well-formed and namespaced correctly, and does not itself register anything with Core's authorization registry (see [Authorization](../development/authorization.md#module-permission-composition) for how Core composes every discovered module's declarations, alongside its own, into the one platform permission registry). A duplicate permission id within one manifest, or a permission id outside the module's own namespace, fails validation the same way a duplicate or misnamed capability id does. `apps/api` composes this registry once, at startup, from every module installed at that time (see [Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement)); installing, removing, or editing a module's manifest takes effect only on the next API restart, not while it is running. Once installed and discovered, a module's declared permission is enforceable and grantable through exactly the same mechanism as any Core-owned permission — the same `requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a module's own HTTP routes, and creating relationship-derived grants automatically from a module's own business logic, are both still later work; this only makes a declared permission *usable* through Core's existing, generic surface. ### Your module's display name `name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees in Ekklesia's Dashboard navigation. It is your module's **product identity**: what the product is called, which you own. Set it to something a church staff member would recognize ("Livestream"), not your package name or module id. **You do not declare where your module appears.** Dashboard groups — "Media", "Planning", "Sunday Production" — are presentation configuration an administrator owns, stored by the platform and changed at any time without touching your module ([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a rendering preference into module structure. That separation also means grouping never affects you. Two modules sharing a folder have no relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another module's functionality, declare it in `dependencies.modules` — that is the only kind of module relationship the platform has, and it is what orders discovery and startup. An administrator moving your module into a different folder changes nothing about your artifact, your API root, your permissions, your dependencies, or when you start. Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`, `ui`, and `display.artifact` alike. `migrations` is the one directory entrypoint. All paths begin with `./`, remain inside the package, and exist. Production never imports source TypeScript directly. A display artifact must remain distinct from every other configured entrypoint before and after symlink resolution. Modules with no tables omit `entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both. Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard organization is presentation configuration owned by the Dashboard, never module architecture. Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. Core will later map them to deployment-specific physical S3 buckets and provide scoped access; module code must not configure SeaweedFS or another S3 client directly. ## Declaring UI entrypoints A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome: ```json { "contributions": [ { "id": "calendar.signage-slide", "version": "1.0.0", "capability": "signage.slide-renderer", "capabilityRange": "^1.0.0", "artifact": "./dist/contributions/signage-slide/index.js", "description": "Renders an upcoming-events slide for a signage timeline." } ], "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", "migrations": "./drizzle", "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative, inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source TypeScript. The display path must not collide with any other entrypoint. Its permission must be one of this module's own declarations and must use `authenticated` or `restricted`, never `public`; anonymous/device display identity is deferred. There is one display role per module for now. Named variants and targeting are later contracts, not ad hoc manifest keys. ### What the artifact must export Each artifact default-exports one definition, built with `defineModuleUi` from `@ekklesia/module-ui-sdk` (ADR 0022): ```tsx import { defineModuleUi } from "@ekklesia/module-ui-sdk"; import { createRoot } from "react-dom/client"; import { Editor } from "./editor.js"; export default defineModuleUi({ id: "announcements", version: "0.1.0", mount(container, context) { const root = createRoot(container); root.render(); return () => root.unmount(); }, }); ``` `id` and `version` are build-artifact identity markers and must match your manifest, exactly like the ones in `defineModuleRuntime`. A host verifies both against the manifest it already validated, so a stale UI bundle is caught rather than mounted. `mount` receives a container element and the host context, and returns an unmount function or nothing. If your mount acquires anything — a React root, a subscription, a timer, an event listener — return an unmount and release it there, the same discipline your `stop` hook follows. React is the supported way to write module UI. It is deliberately **not** part of the contract: the platform hands you an element and a context, so your framework choice and the host's stay independent. Primary and display artifacts use this exact same ABI and context. There is no mode flag, `presentation.kind`, Dashboard boolean, or host identity to branch on; the separately declared artifact is the role selection. ### What the host gives you `mount` receives exactly six members, and nothing else is available: | Member | What it is | | --- | --- | | `moduleId` | Your module's id, so you never re-derive it from a URL | | `api` | `request(path, init)` against **your own** module API — see below | | `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` | | `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) | Navigation, module configuration, organization context, and diagnostics reporting are **not** in the contract. They need designs that do not exist, and a speculative field frozen into a public contract is harder to remove than to add. The host **theme** is deliberately not a context member either, and that is not a gap: design tokens reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import `@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that can forget to pass one. ### Never build your own API URL Ask the host for a path relative to your own module's API root: ```ts const response = await context.api.request("posts"); ``` Do **not** write `/api/v1/modules/announcements/posts`. That prefix is Ekklesia's private deployment detail. A standalone host serving only your module may expose the same routes at `/api`, at a different origin, or behind a proxy — and a module that builds the URL itself silently stops being hostable anywhere but Ekklesia. The host resolves the path and owns credentials, base URL, and platform headers; your UI never sees a session token. `request` returns a `Response`, the same Fetch vocabulary your backend routes already speak. ### Client-side permission checks are UX, not security `context.permissions.has("announcements.posts.publish")` tells you what the host believes, so you can hide an action or disable a control. **It is not a security boundary.** Server-side authorization is the only authority: your routes are enforced by Core's `requirePermission` middleware regardless of what any browser decided. Checking `has()` and then calling your API is still fully authorized server-side; skipping the check is still denied. The point is that a user does not see a button that would fail. ### Your UI is trusted code in the host's page Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no sandbox, and no capability confinement — your UI can reach the host page and any global the browser exposes. This matches the backend model: installing a module already means running its code in the API process. Write module UI as trusted first-party code, because that is what it is. See [Security model and deployment trust boundaries](../development/security.md). ### Building your UI Put each declared role at its fixed source path and build once: ```text src/ui/index.tsx → entrypoints.ui src/display/index.tsx → entrypoints.display.artifact pnpm build ``` You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it ([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)), and your package's build script runs both halves of the module: ```json { "scripts": { "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui" }, "dependencies": { "@ekklesia/module-ui-sdk": "workspace:*", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@ekklesia/module-ui-build": "workspace:*", "@types/react": "19.2.18", "@types/react-dom": "19.2.4" } } ``` Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts cannot drift. Source paths are the exact conventions above; module authors do not configure them. The builder resolves and preflights all roles in deterministic `ui`, then `display` order, compiles both in memory, and writes only after every declared bundle succeeds. A missing or broken display source therefore cannot leave a newly written primary artifact (or vice versa). ```json { "entrypoints": { "ui": "./dist/ui/index.js", "display": { "artifact": "./dist/display/index.js", "permission": "calendar.display.view" } } } ``` A module may declare either role alone. Declaring neither is not an error — the build reports that there is nothing to build and exits successfully, so the same script works for a backend-only module. The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact, or a declared role's fixed source is missing. It also refuses any display output that could overwrite `server`, `schema`, `migrations`, `jobs`, or primary UI before invoking esbuild. #### TypeScript projects Keep UI and backend TypeScript separate. Your Node project already includes `src/**/*.ts`, which does not match `.tsx`, and a UI project covers the browser half: ```json { "extends": "@ekklesia/typescript-config/browser-react.json", "compilerOptions": { "noEmit": true, "types": [] }, "include": [ "src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts" ] } ``` `"types": []` is deliberate, and as of [ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md) it actually holds: `process.env` in a UI file fails to compile even though the file imports `@ekklesia/module-ui-sdk`. It did not hold previously — the SDK imported `@ekklesia/contracts`, whose barrel pulled Drizzle's types and with them Node's globals into every module UI program. The SDK now has **no dependencies at all**, so importing it cannot drag a backend type graph into your browser code, and a compile test in that package proves both directions. `noEmit` is also deliberate — TypeScript only checks types here; esbuild produces the artifact. #### What is in your artifact **React is bundled into it.** Your artifact is self-contained: it resolves nothing at run time and needs no React from its host, which is what lets the same file run under Ekklesia and under a future standalone host that may not use React at all. The cost is that a page hosting several module UIs loads several React copies, and artifacts are large (the template's is roughly 570 KB, unminified so an operator can review it). That is accepted for now and can be optimized later **without changing the contract**, because the contract is a mount function rather than a component. ### Design tokens and shared elements Your UI may import two shared design packages, and only those two ([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)): | Package | What it is | | --- | --- | | `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them | | `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens | ```tsx import { designToken } from "@ekklesia/design-tokens"; import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui"; export function Slides({ context }: { readonly context: ModuleUiHostContext }) { return ( <> Add slide} title="Slides">

Nothing scheduled yet.

Paused
); } ``` Render `` once, near the top of your tree. It puts the element stylesheet into whichever root your artifact is mounted in, so you never have to know whether that is a document or an isolated root. **Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*; `#9f1239` says what it looks like today. An installation that changes its theme changes the first and not the second. `designToken` only accepts registered names, so a typo is a compile error. **Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables, and inventing one would create a global a future theme editor could not enumerate and another module could unknowingly depend on. You may derive as many *private* aliases as you like inside your own artifact, under your own prefix: ```tsx
``` The host installs the default token values at its own root, and CSS custom properties inherit through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at run time and nothing for the host to hand you. 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: a stylesheet compiled into your artifact Author plain `.css` beside your UI source, import it, and declare it ([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)): ```text src/ui/index.tsx src/ui/styles.css ``` ```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 └─
#shadow-root (open) ├─