diff --git a/README.md b/README.md index 76a8e47..777e180 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,8 @@ artifacts cannot drift. externalizing it would make every host supply a compatible React — defeating standalone hosting. The accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template); sharing one runtime later is a build optimization that does not change the contract. Styling is React -inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar +inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled +stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar `.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds, and installing a module or starting the API never triggers one. @@ -488,7 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never from inside its own `start` hook — and its required dependents block behind it through ADR 0028's existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict -manifest is version 6 after ADR 0033 added the optional display declaration. +manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added +browser contributions. **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and no key management, so a `secret` key's value is supplied by the deployment @@ -792,6 +794,74 @@ unproven launch-blockers rather than existing guarantees. Read [Security model and deployment trust boundaries](docs/development/security.md) before installing a module or exposing the API. +**Phase 8 adds the five generic platform prerequisites the first production feature module needs.** +None of them is a feature module, and none of them names one. + +Phase 8 pass 1 gives every Ekklesia screen one design vocabulary +([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)). +`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category +and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded +literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme +editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set +of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives, +badge, toast, and empty/error/loading states), every value a token reference, with real +`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens +exactly two shared roles to modules by name and pins both design packages beneath every other role. +The Dashboard and display install the defaults at their document root; custom properties inherit into +module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No +theme editor, brand builder, second theme, or component catalogue exists.** + +Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root +([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module +imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as +text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build +rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved +`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside +its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach +the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for +styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI +is versioned with no dual acceptance; migrating is one `pnpm build`. + +Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides +([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of +the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its +own version, a target capability, a compatible range, and one artifact, and nothing else: there is no +payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it +means. Publication requires that another eligible module provides the capability inside the declared +range; a contribution that fails is absent rather than broken. A consumer discovers through +`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can +only see contributions aimed at a capability it itself provides. A contributed renderer receives its +own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration, +database, storage, or credential — and loading one grants its module nothing. + +Phase 8 pass 4 closes the object-storage write path +([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route +declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB +ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts +bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling +in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no +unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared +oversize is refused before authorization, the buffered path now counts what arrived rather than +trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the +response with the same sanitized `413` even if the handler swallowed it. Signed write URLs, +resumable uploads, quotas, and anything above the ceiling remain deliberately absent. + +Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared +code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service +worker scoped to `/display/` caches the page, the host script, the module's display artifact, and +every contributed renderer that display mounted, network-first so a screen can never be trapped on +obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API +response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is +stated rather than papered over — a display cannot render module content while offline, because +mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays +revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting +during an outage boots into Ekklesia's own offline state and recovers by itself, and that +reconnecting mounts with nothing left to download. **Module-owned offline content and media remain +the module's responsibility and do not exist yet.** + +Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature +module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 71b3f70..4e74ff1 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { PermissionRegistryError } from "../authorization/index.js"; import { composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, discoverModuleSchemas, ModuleDiscoveryError, ModulePermissionCompositionError, @@ -45,11 +46,32 @@ async function main(): Promise { return; } + // Built from the same registry the API publishes from, so this command can never disagree with + // what a running platform would actually serve. A contribution whose target capability nobody + // provides — or whose provider moved outside its declared range — is otherwise silent: the + // manifest is valid, the artifact exists, and nothing renders (ADR 0041). + const publication = createModuleUiPublicationRegistry(modules); + const compatibleContributions = new Set( + publication + .listContributions() + .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), + ); + console.log( `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, ); for (const discoveredModule of modules) { console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); + for (const contribution of discoveredModule.contributions) { + const resolved = compatibleContributions.has( + `${discoveredModule.manifest.id}\u0000${contribution.id}`, + ); + console.log( + ` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${ + resolved ? "compatible provider installed" : "no compatible provider installed" + }`, + ); + } } } diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 0ac0467..c267e5f 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -57,6 +57,7 @@ import { createModuleConfigurationStore, createModuleLifecycleDatabase, createModuleLifecycleStore, + createModuleUiPublicationRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, @@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport { return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) }); } +/** + * One line per installed module, plus — for a module that declares browser contributions (ADR 0041) + * — how many of them have a compatible provider among the installed set. + * + * Worth reporting because the failure it names is otherwise silent: a contribution whose target + * capability nobody provides, or whose provider moved outside its declared range, is simply not + * published, and an operator sees a screen that renders nothing with no error anywhere. The counts + * are derived from the same publication registry the API serves from, so this can never disagree + * with what the running platform would actually publish. + * + * Static only, like every other Doctor check: it reports what the *manifests* support and never + * claims a module is running. Enablement and dependency blocking are the lifecycle check's job. + */ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { + const publication = createModuleUiPublicationRegistry(modules); + const compatible = new Set( + publication + .listContributions() + .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), + ); return modules.map((module) => { const id = module.manifest.id; const version = module.manifest.version; @@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin ) { throw new TypeError("Unsafe module diagnostic value."); } - return `${id} ${version}`; + const declared = module.contributions.length; + if (declared === 0) { + return `${id} ${version}`; + } + const resolved = module.contributions.filter((contribution) => + compatible.has(`${id}\u0000${contribution.id}`), + ).length; + return `${id} ${version} · ${declared} browser contribution${ + declared === 1 ? "" : "s" + } (${resolved} with a compatible provider)`; }); } diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 57b3fb3..1ab9b86 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -1472,3 +1472,91 @@ describe("Ekklesia Doctor — object storage", () => { expect(check(report, "object-storage.bindings").status).toBe("skipped"); }); }); + +/** + * A browser contribution whose target capability nobody provides is otherwise silent: the manifest + * is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041). + * Doctor names it. + */ +describe("Doctor reports declared browser contributions", () => { + function contributing( + id: string, + contribution: { capability: string; capabilityRange: string }, + ): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + contributions: [ + { + artifactPath: `/unpublished/${id}/contributions/slide.js`, + capability: contribution.capability, + capabilityRange: contribution.capabilityRange, + id: `${id}.slide`, + version: "1.0.0", + }, + ], + }; + } + + function providing(id: string, capability: { id: string; version: string }): DiscoveredModule { + const base = discoveredModule(id); + return { + ...base, + manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"], + }; + } + + async function detailsFor(modules: readonly DiscoveredModule[]): Promise { + const report = await diagnoseEkklesia( + options(), + dependencies({ + discoverModules: vi.fn(async () => Object.freeze([...modules])), + discoverSchemas: vi.fn(async () => schemaAggregate(modules)), + }), + ); + return check(report, "modules.discovery").details; + } + + it("says nothing extra for a module that contributes nothing", async () => { + expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]); + }); + + it("reports a contribution whose provider satisfies its declared range", async () => { + const details = await detailsFor([ + providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }), + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]); + + expect(details).toEqual([ + "signage 0.1.0", + "announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)", + ]); + }); + + it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => { + expect( + await detailsFor([ + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]), + ).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]); + + expect( + await detailsFor([ + providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }), + contributing("announcements", { + capability: "signage.slide-renderer", + capabilityRange: "^1.0.0", + }), + ]), + ).toEqual([ + "signage 0.1.0", + "announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)", + ]); + }); +}); diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 3dcade6..92b6fde 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and independent. The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime -dependencies, consumed by module authors and hosts alike. Its host context is five members — +dependencies, consumed by module authors and hosts alike. Its host context was five members at that +checkpoint and is six today (ADR 0041) — `moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through `api.request(path)` with a path relative to the module's own API root, never by constructing Ekklesia's `/api/v1/modules/` prefix, which is what keeps a standalone host possible. @@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import. `src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler option in one `tooling` package so module authors write no build configuration. React is bundled rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host; -styling is inline style objects only, and a stylesheet import fails the build rather than emitting an +styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040), +and a stylesheet import then failed the build rather than emitting an asset nothing declares. Building remains an authoring step — Core never builds, and discovery still only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading @@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display remains deferred and the display bundle gains no Dashboard login. Public Website routing, production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling, and the missing production first-user creation path are also outside this pass. + +**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None +of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by +something other than a feature module. + +[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform +never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens — +`background.raised`, `text.danger`, `state.hover-background` — where a token's category and +`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a +registry that constructs is one that can be emitted as CSS without escaping. That validation is +exactly what makes a future theme editor implementable rather than aspirational: the values it would +edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of +elements a real module dashboard cannot be built without, every value a token reference, with real +`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens +exactly two shared roles to modules **by name**, and pins both design packages beneath everything +else. The Dashboard and display install the defaults at their document root; custom properties +inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at +run time and nothing for a host to forget to pass. + +[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's +inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports +plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is +still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting +against. Two rules are enforced with their own codes: a module stylesheet may reference +`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader +then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet +installed, unconditionally, because a conditional isolation boundary is one a module can fall out of +by editing a source file. That root is a correctness boundary for *styles* and is never described as +a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach +`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by +`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`. + +[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser +renderer to a capability another module provides — the browser half of the capability system Core has +had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target +capability, a compatible range, and one artifact. That is the complete list: there is no payload +schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a +"slide" is because the declaration cannot express it. Discovery resolves the artifact through the +identical loop every other compiled entrypoint uses; publication requires that *another* eligible +module provides the capability within the declared range, and a contribution that fails is absent +rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by +id — never by building a publication URL — and can only see contributions aimed at a capability it +itself provides. The input is opaque JSON validated for serializability and nothing else, so the +domain contract stays entirely between the two modules. A contributed renderer receives its own id +and that input: no API client, realtime, principal, configuration, database, storage, or credential, +and loading one grants its module nothing. + +[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left +open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined +signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at +1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route +meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to +32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather +than inferred from the size, because inferring it would silently change how an author's own handler +receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one +25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's +own limiter replaces the buffering dependency: a declared oversize is refused before authorization, +the buffered path now counts what actually arrived rather than trusting a header, and a streamed +overflow errors the stream, cancels the source, and replaces the response with the same sanitized +`413` even if the handler swallowed the error. + +[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline +resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to +`/display/` caches the page, the host script, the module's display artifact, and every contributed +renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the +realtime stream, a pairing exchange, or any cross-origin request, which is what a signed +object-delivery URL is. The consequence is stated rather than papered over: a display cannot render +module content while offline, because mounting needs an authorization-bearing descriptor that is +never cached, so a revoked device stays revoked and cached code can never manufacture current +authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline +state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is +network-first everywhere, so a screen can never be trapped on obsolete code. + +Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a +module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above +the absolute ceiling; module-owned offline *content*; and every real feature module, Signage +included. Nothing in this pass claims any of them exists. diff --git a/docs/development/api.md b/docs/development/api.md index 76c079d..1cd9b90 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1232,7 +1232,7 @@ its role-specific URL only from the protected descriptor below. ## Module display host -Manifest version 6 adds one independently optional display declaration +The manifest carries one independently optional display declaration ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)): ```json diff --git a/docs/development/authorization.md b/docs/development/authorization.md index fecce3b..9deeb50 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro ## Module permission composition -A module manifest's `permissions` array (`manifestVersion: 4`, +A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict +manifest is version 7 — see [Module development](../module-development/README.md#canonical-manifest)) declares permissions in the exact same `PermissionDeclaration` shape — but declaring one there only proves it is well-formed and correctly namespaced under the module's own id; it does not, by itself, register @@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many - `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation: every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description rejection, malformed permission id rejection. -- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version +- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version 2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above), module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue path, and default-empty-list isolation across separate parses. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 7525aad..5111686 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -37,7 +37,10 @@ the root verification command discovers them automatically. `pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A missing or empty modules directory is valid and reports zero installed modules. Installed modules -must produce the compiled JavaScript entrypoints declared by their static manifests. Optional +must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's +declared browser contributions are listed with their target capability and range, and marked +according to whether a compatible provider is installed — a contribution nobody provides is otherwise +silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and foreign-key isolation; this command does not connect to PostgreSQL or run migrations. @@ -82,7 +85,8 @@ not a broken deployment). Doctor reports a fixed, deterministic set of checks: - The active Node.js version against the repository's declared engine range. -- Static module discovery and dependency-first ordering. +- Static module discovery and dependency-first ordering. A module that declares browser + contributions also reports how many of them have a compatible provider among the installed set. - Schema discovery, including validated module and table counts. This imports compiled schema definitions, which the module contract requires to perform no configuration reads, I/O, or database connection. diff --git a/docs/development/security.md b/docs/development/security.md index d627ff5..fb88359 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -201,7 +201,7 @@ separate deployment and threat-model decision. ### A display page is authorized; its artifact bytes are not authority -Manifest version 6 binds a display artifact to one permission declared by the same module +The manifest binds a display artifact to one permission declared by the same module ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects a `public` baseline: the current display host requires an ordinary authenticated cookie principal, and public/device identity is not approximated with an anonymous exception. Both the stable diff --git a/docs/development/testing.md b/docs/development/testing.md index 4cbb0b1..218427c 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out of the separate display bundle. +Phase 8 adds evidence for the five platform prerequisites. + +**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is +deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its +name and carries a label, description, and category, that each status family pairs a colour with a +non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name, +and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`, +`!important`, an escape, a control character, or markup are each rejected — with an error that never +echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error +association, icon-button naming, tab roles and arrow-key movement, announced error and loading +states), that status never depends on colour alone, that a dialog renders in place rather than +portalling out of an isolated root, and — mechanically — that the stylesheet and every component +source contain no literal colour, font stack, or theme value and reference only registered tokens. +Both packages compile module-shaped source under the exact browser project a module uses, where +`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted. + +**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain +stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local +`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*` +variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private +alias derived from a registered token is accepted. The loader's tests prove every artifact mounts +inside an open shadow root, that the artifact receives that root rather than the host's element, that +exactly one `