feat: give every Ekklesia screen one set of design tokens and elements

Modules have owned their own UI since ADR 0021, but the platform has never
said what that UI should look like. Every screen here — the Dashboard shell,
the display host, the module template — invented its own hex colours, font
stack, spacing, and radii, and they already disagreed. A module author had no
vocabulary for "the ordinary panel background", so the only available answer
was to invent one, and two modules would invent two.

Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic
tokens. A token declares a name, label, description, and default value; its
category and `--ekklesia-*` identifier are derived from the name, so it can
never claim one category while being named for another. Construction rejects
duplicates, malformed names, unknown categories, empty or overlong text, and
any value that is not a bounded literal — no declaration terminator, rule
block, comment, at-rule, `url(`, `!important`, escape, control character, or
markup. A registry that constructs can therefore be emitted as CSS without
escaping, because nothing needing escaping can be in it. That validation is
also what makes a future theme editor implementable rather than aspirational.

Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field
wrapper, 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 depends on colour alone.
Deliberately small: an element earns its place when two consumers need it.

The stylesheet is a string rather than a `.css` file so the identical bytes
install into a document or an isolated artifact root, and so hover, disabled,
and focus-visible are real CSS rather than JavaScript approximations of them.

`pnpm boundaries` opens exactly two shared roles to modules, by name — every
other role stays rejected — and pins both design packages to the bottom of the
presentation stack. Both are browser-safe by construction, proven by a real
compile of module-shaped source in which `process.env` must fail to typecheck.

The shell installs the defaults at its document root for the Dashboard and the
display; custom properties inherit into module artifacts, so a module reads
`var(--ekklesia-…)` with nothing to import at run time and nothing for a host
to forget to pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 06:59:44 -05:00
co-authored by Claude Opus 5
parent b14613decd
commit e03d38fcab
64 changed files with 3697 additions and 105 deletions
+5
View File
@@ -42,6 +42,11 @@ pnpm infrastructure:config
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
bundler configuration, and styling is React inline style objects only for now. Both roles use the
same UI SDK context with no presentation-mode flag.
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
stays rejected inside a module. Reference registered semantic tokens; never define an
`--ekklesia-*` custom property of your own.
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
@@ -0,0 +1,156 @@
# 0039: Shared design tokens and foundational elements
- 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 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
[ADR 0026](0026-minimal-ekklesia-module-ui-host.md),
[ADR 0033](0033-display-kiosk-host-contract.md)
## Context
ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose
React as the way to author it. Since then the platform has been able to *mount* a module's screen but
has said nothing at all about what that screen should look like. The consequences are now concrete:
- Every screen in this repository — the Dashboard shell, the display host, the module template —
hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each
other.
- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour",
so the only available answer is to invent one. Two modules will invent two.
- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design
system, component library, or theme/design-token contract."
- A theme editor is a real product requirement for a church platform, and it is only implementable
if the values it would edit are enumerable with human-readable names — which is a property of the
*contract*, not something that can be retrofitted onto a pile of literals.
The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared
design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with
it Node's global declarations — into a browser program.
## Decision
Add two shared browser-safe packages and open exactly two boundary roles to module UI.
### `@ekklesia/design-tokens` — the semantic token registry
A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one
validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`,
`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be,
because the point is that a value can change without a consumer's meaning changing with it.
Each token declares a name, a human-readable label, a description, and a default value. Its
**category and CSS custom-property name are derived** from the name rather than declared, so a token
can never claim one category while being named for another, and there is exactly one spelling of
`--ekklesia-<name>`. That is what makes the registry enumerable: a future theme editor reads
`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and
needs no second source of truth.
Construction validates every rule rather than leaving it to review: an invalid or single-segment
name, an unknown category, a malformed derived identifier, an empty or overlong label or description,
a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a
declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash
escape, a control character, or markup, and is length-bounded. A registry that constructs is
therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping
can be in it.
The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a
caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the
package refuses everywhere else.
### `@ekklesia/ui` — the foundational elements
A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a
`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without:
button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and
list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible`
focus treatment on every interactive element.
Each element is a thin, accessible shell over the real HTML element rather than a re-implementation
of it, so native attributes, form association, and assistive-technology semantics pass straight
through. **This is deliberately not a large catalogue**: an element earns its place when two
independent consumers need the same one.
**Status is never signalled by colour alone.** Every status family carries a symbol token beside its
colours, and the elements render it. A status that is only red is invisible to a person who cannot
distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1).
### The stylesheet is a string, not a `.css` file
These elements are mounted in two very different places: a document (the Dashboard shell, a
standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a
bundler on both sides and a served asset on one of them. A string is installed into whichever root
the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and
`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference
between a focus treatment that works for a keyboard user and one that only looks like it does.
`<EkklesiaUiStyles />` renders that string into whichever root its React tree lives in. There is
deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated
artifact's styles into the host document is precisely the leak the platform must not have.
### Modules consume tokens; they never invent global ones
A module may reference any registered token and may derive **private, differently-prefixed aliases**
from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that
would add a global theme variable outside the registry, which a theme editor could never enumerate
and another module could unknowingly depend on. The typed `designToken(name)` reference makes an
unregistered name a compile error and a runtime failure.
### The boundary opens by name, not by category
`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a
named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk`
remain excluded from surfaces for the reason ADR 0024 measured.
The two new packages are pinned at the bottom of the presentation stack by their own rule:
`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens`
and dev-time tooling. A design package that could reach an API client would put that client inside
every module artifact that renders a button.
### The host installs the theme once, at its root
`surfaces/shell` installs the default token values at the document for both the Dashboard and the
display application. CSS custom properties inherit through a shadow boundary, so every module
artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host
reaching into the artifact or the artifact importing the host.
## Consequences
- Two packages exist where none did; both are browser-safe by construction and proven so by a real
compile of module-shaped source under the exact project a module uses for UI, where `process.env`
must fail to typecheck.
- The Dashboard shell and display host no longer hardcode a colour, and a module can match them.
- A theme editor becomes implementable without a contract change: the values it would edit are
already enumerable, labelled, described, categorised, and validated.
- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with
fixtures proving both the acceptance and the continued rejection of everything else.
- Every module artifact bundles its own copy of the elements, exactly as it already bundles React
(ADR 0023). That cost is accepted for now and is optimisable later without a contract change,
because the contract is still a mount function.
## Alternatives
- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review
surface, and most of it would be speculative. The set here is what a real module dashboard needs.
- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and
a host that renders nothing still has to satisfy it. Design values are not something a host
supplies, and merging them would make the mount ABI carry a theme.
- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map
makes every module re-implement styling from data, and makes the tokens a runtime negotiation
rather than inherited CSS. Custom properties already inherit into artifacts for free.
- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every
consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets
are a separate question — the module's build already exists to compile them — and are decided by
their own ADR.
- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs
persistence, an authorization story, per-installation overrides, and a preview surface, none of
which exist.
## Deferred
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
documentation site, and a focus-trap utility.
+1
View File
@@ -47,3 +47,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
+22 -3
View File
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| --- | --- | --- |
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
@@ -22,8 +22,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
a role modules could already depend on — owns the entire module UI build
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
real target.
@@ -31,6 +31,21 @@ These rules map platform ownership to filesystem and package boundaries. The exe
import Core, an app, a surface, a builder, or another module. It also may not import
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
and `ui` (`@ekklesia/ui`), added by
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
layer, an API/auth client, or the backend contracts barrel, because a design package that could
would put all of it inside every module artifact that renders a button.
- A module **references** registered global semantic tokens and may derive private,
differently-prefixed aliases from them inside its own artifact. It may never define an
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
which a future theme editor could not enumerate and another module could unknowingly depend on.
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
Node's global declarations — into browser code. Core still owns the containing
@@ -96,6 +111,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
columns, relations, schema entrypoint, or migration internals.
The design packages are covered the same way: one fixture proves a module may depend on both, one
proves every other shared role stays rejected, and one proves neither design package may depend
upward.
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
rejects every currently machine-checkable repository rule even before production packages fill
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
+62 -4
View File
@@ -281,9 +281,15 @@ artifact is the role selection.
| `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) |
Navigation, host theme and design tokens, module configuration, organization context, and
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
speculative field frozen into a public contract is harder to remove than to add.
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
@@ -413,6 +419,58 @@ loads several React copies, and artifacts are large (the template's is roughly 5
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 (
<>
<EkklesiaUiStyles />
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
<Badge tone="warning">Paused</Badge>
</Panel>
</>
);
}
```
Render `<EkklesiaUiStyles />` 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
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
```
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: inline style objects only, for now
Style with React's `style` prop:
@@ -437,7 +495,7 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
| --- | --- |
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.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/ui` and other shared UI-role packages |
| `@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` |
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
`@ekklesia/module-ui-build` owns the bundler entirely.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "design-tokens"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./install.js";
export * from "./registry.js";
export * from "./token.js";
+72
View File
@@ -0,0 +1,72 @@
import { ekklesiaDesignTokenStylesheet } from "./registry.js";
/**
* The attribute that marks a token stylesheet this package installed. Used to make installation
* idempotent, and to let a host remove exactly what it added.
*/
export const DESIGN_TOKEN_STYLE_ATTRIBUTE = "data-ekklesia-design-tokens";
/**
* A root that can own a `<style>` element: a whole document, or one artifact's shadow root.
*
* Structural rather than nominal (`Document | ShadowRoot`) on purpose. This package must not
* reference DOM interfaces it does not need, and the two things a caller actually passes differ in
* everything except these three members.
*/
export interface DesignTokenStyleRoot {
querySelector(selectors: string): Element | null;
appendChild<Node_ extends Node>(node: Node_): Node_;
readonly ownerDocument?: Document | null;
}
function documentFor(root: DesignTokenStyleRoot): Document | undefined {
if (typeof (root as Partial<Document>).createElement === "function") {
return root as Document;
}
return root.ownerDocument ?? undefined;
}
/**
* Installs Ekklesia's default token values at a host root, once.
*
* Idempotent by construction: a second call finds the element it inserted the first time and
* updates nothing, so a re-render, a re-mount, or two independent callers can never leave two
* competing copies of the theme in one root.
*
* CSS custom properties **inherit through shadow boundaries**, so installing this at the document
* root is what makes the same tokens available inside every isolated module artifact without the
* artifact importing anything or the host reaching into it. A caller that genuinely owns an isolated
* root with no inheriting ancestor — a standalone display, a test — installs with `scope: "host"`
* into that root instead.
*
* Returns a function that removes exactly the element this call inserted, and nothing else; calling
* it twice is safe. Returns a no-op when the tokens were already present, so a caller can never
* uninstall a theme it did not install.
*/
export function installEkklesiaDesignTokens(
root: DesignTokenStyleRoot,
options: { readonly scope?: "root" | "host" } = {},
): () => void {
if (root.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)) {
return () => undefined;
}
const ownerDocument = documentFor(root);
if (!ownerDocument) {
return () => undefined;
}
const style = ownerDocument.createElement("style");
style.setAttribute(DESIGN_TOKEN_STYLE_ATTRIBUTE, "");
style.textContent = ekklesiaDesignTokenStylesheet(options.scope ?? "root");
const target =
(ownerDocument === (root as unknown as Document) ? ownerDocument.head : undefined) ?? root;
target.appendChild(style);
let removed = false;
return () => {
if (removed) {
return;
}
removed = true;
style.remove();
};
}
+550
View File
@@ -0,0 +1,550 @@
import {
createDesignTokenRegistry,
type DesignTokenInput,
type DesignTokenScope,
} from "./token.js";
/**
* Ekklesia's global semantic theme.
*
* These are **semantic** tokens, not a palette. Nothing here is named for the colour it currently
* happens to be, because the whole point is that a future theme editor can change the value without
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
* an ordinary surface" whatever a church's brand makes it.
*
* The default values are a neutral, high-contrast light theme. They are defaults, not a brand —
* a deployment overrides them by installing different values at its host root, and this pass
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with.
*
* ## Status is never colour alone
*
* Each status family carries a `symbol` token alongside its colours. A status that is only red is
* invisible to a person who cannot distinguish it, and unreadable on a projector or a washed-out
* lobby screen. Component code renders the symbol next to the label, so the meaning survives losing
* the colour entirely (WCAG 2.2 §1.4.1).
*/
const declarations = [
// ---------------------------------------------------------------------------------------------
// Backgrounds — the elevation ladder, from the page behind everything to the overlay above it.
// ---------------------------------------------------------------------------------------------
{
description: "The furthest-back background of a whole page or a full-viewport display.",
label: "Page background",
name: "background.page",
value: "#f6f6f5",
},
{
description:
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
label: "Canvas background",
name: "background.canvas",
value: "#fbfbfa",
},
{
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
label: "Surface background",
name: "background.surface",
value: "#ffffff",
},
{
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
label: "Raised surface background",
name: "background.raised",
value: "#ffffff",
},
{
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
label: "Overlay background",
name: "background.overlay",
value: "rgba(23, 23, 21, 0.55)",
},
{
description: "A recessed area such as a text input, a code block, or a well.",
label: "Inset background",
name: "background.inset",
value: "#f2f2f0",
},
// ---------------------------------------------------------------------------------------------
// Text.
// ---------------------------------------------------------------------------------------------
{
description: "Body copy and headings — the default foreground for readable content.",
label: "Primary text",
name: "text.primary",
value: "#1c1917",
},
{
description: "Supporting text that is still content: captions, secondary columns, help text.",
label: "Secondary text",
name: "text.secondary",
value: "#57534e",
},
{
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
label: "Muted text",
name: "text.muted",
value: "#79716b",
},
{
description: "Text drawn on a strongly coloured background, such as a primary button.",
label: "Inverse text",
name: "text.inverse",
value: "#ffffff",
},
{
description: "Text reporting a successful outcome, paired with the success symbol.",
label: "Success text",
name: "text.success",
value: "#166534",
},
{
description: "Text reporting a caution or a degraded state, paired with the warning symbol.",
label: "Warning text",
name: "text.warning",
value: "#854d0e",
},
{
description: "Text reporting a failure or a destructive consequence, paired with its symbol.",
label: "Danger text",
name: "text.danger",
value: "#9f1239",
},
// ---------------------------------------------------------------------------------------------
// Accent and interactive states.
// ---------------------------------------------------------------------------------------------
{
description: "The primary action colour: primary buttons, selected tabs, active links.",
label: "Accent",
name: "accent.base",
value: "#1d4ed8",
},
{
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
label: "Strong accent",
name: "accent.strong",
value: "#1e3a8a",
},
{
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
label: "Subtle accent",
name: "accent.subtle",
value: "#e8effc",
},
{
description: "Foreground drawn on top of the accent background.",
label: "Accent foreground",
name: "accent.foreground",
value: "#ffffff",
},
{
description: "The focus ring colour. Never the only signal that something has focus.",
label: "Focus ring",
name: "accent.focus",
value: "#1d4ed8",
},
// ---------------------------------------------------------------------------------------------
// Borders.
// ---------------------------------------------------------------------------------------------
{
description: "The default border around panels, inputs, and table cells.",
label: "Border",
name: "border.base",
value: "#d7d3d0",
},
{
description: "A heavier border for emphasis, active inputs, and selected outlines.",
label: "Strong border",
name: "border.strong",
value: "#a8a29e",
},
{
description: "A hairline rule separating rows or sections inside one surface.",
label: "Divider",
name: "border.divider",
value: "#e7e5e4",
},
{
description: "The border width used by controls and panels.",
label: "Border width",
name: "border.width",
value: "1px",
},
// ---------------------------------------------------------------------------------------------
// Status families. Each carries a symbol so meaning never depends on colour.
// ---------------------------------------------------------------------------------------------
{
description: "Background of a success badge, banner, or row highlight.",
label: "Success background",
name: "status.success-background",
value: "#dcfce7",
},
{
description: "Border of a success badge or banner.",
label: "Success border",
name: "status.success-border",
value: "#86efac",
},
{
description: "The non-colour signal for success, rendered beside the label.",
label: "Success symbol",
name: "status.success-symbol",
value: '"✓"',
},
{
description: "Background of a warning badge, banner, or row highlight.",
label: "Warning background",
name: "status.warning-background",
value: "#fef3c7",
},
{
description: "Border of a warning badge or banner.",
label: "Warning border",
name: "status.warning-border",
value: "#fcd34d",
},
{
description: "The non-colour signal for a warning, rendered beside the label.",
label: "Warning symbol",
name: "status.warning-symbol",
value: '"!"',
},
{
description: "Background of a danger badge, banner, or row highlight.",
label: "Danger background",
name: "status.danger-background",
value: "#ffe4e6",
},
{
description: "Border of a danger badge or banner.",
label: "Danger border",
name: "status.danger-border",
value: "#fda4af",
},
{
description: "The non-colour signal for danger or failure, rendered beside the label.",
label: "Danger symbol",
name: "status.danger-symbol",
value: '"✕"',
},
{
description: "Background of a neutral informational badge or banner.",
label: "Info background",
name: "status.info-background",
value: "#e8effc",
},
{
description: "Border of a neutral informational badge or banner.",
label: "Info border",
name: "status.info-border",
value: "#bfd3f7",
},
{
description: "The non-colour signal for information, rendered beside the label.",
label: "Info symbol",
name: "status.info-symbol",
value: '"i"',
},
// ---------------------------------------------------------------------------------------------
// Interaction states.
// ---------------------------------------------------------------------------------------------
{
description: "Background wash under a hovered control or row.",
label: "Hover background",
name: "state.hover-background",
value: "#f2f2f0",
},
{
description: "Background wash under a control being pressed.",
label: "Pressed background",
name: "state.pressed-background",
value: "#e7e5e4",
},
{
description: "Background of a selected row, tab, or list item.",
label: "Selected background",
name: "state.selected-background",
value: "#e8effc",
},
{
description: "Foreground of a selected row, tab, or list item.",
label: "Selected foreground",
name: "state.selected-foreground",
value: "#1e3a8a",
},
{
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
label: "Disabled opacity",
name: "state.disabled-opacity",
value: "0.55",
},
{
description: "Thickness of the keyboard focus ring.",
label: "Focus ring width",
name: "state.focus-visible-width",
value: "2px",
},
{
description: "Gap between a focused control and its focus ring.",
label: "Focus ring offset",
name: "state.focus-visible-offset",
value: "2px",
},
// ---------------------------------------------------------------------------------------------
// Typography.
// ---------------------------------------------------------------------------------------------
{
description: "The interface font stack for all ordinary text.",
label: "Interface font family",
name: "typography.family-sans",
value: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
},
{
description: "The monospace stack for identifiers, keys, and code.",
label: "Monospace font family",
name: "typography.family-mono",
value: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
},
{
description: "Smallest supported text size, for dense metadata only.",
label: "Extra small font size",
name: "typography.size-xs",
value: "0.75rem",
},
{
description: "Small text: captions, table metadata, help text.",
label: "Small font size",
name: "typography.size-sm",
value: "0.875rem",
},
{
description: "The default body text size.",
label: "Body font size",
name: "typography.size-md",
value: "1rem",
},
{
description: "Large body text and small headings.",
label: "Large font size",
name: "typography.size-lg",
value: "1.125rem",
},
{
description: "Third-level heading size.",
label: "Heading 3 font size",
name: "typography.size-heading-3",
value: "1.25rem",
},
{
description: "Second-level heading size.",
label: "Heading 2 font size",
name: "typography.size-heading-2",
value: "1.5rem",
},
{
description: "First-level heading size — one per screen.",
label: "Heading 1 font size",
name: "typography.size-heading-1",
value: "1.875rem",
},
{
description: "Normal text weight.",
label: "Regular font weight",
name: "typography.weight-regular",
value: "400",
},
{
description: "Weight for labels, table headers, and emphasised body text.",
label: "Medium font weight",
name: "typography.weight-medium",
value: "500",
},
{
description: "Weight for headings and primary actions.",
label: "Bold font weight",
name: "typography.weight-bold",
value: "600",
},
{
description: "Tight line height for headings and single-line controls.",
label: "Tight line height",
name: "typography.line-height-tight",
value: "1.2",
},
{
description: "Default line height for body copy.",
label: "Normal line height",
name: "typography.line-height-normal",
value: "1.5",
},
{
description: "Relaxed line height for long-form reading.",
label: "Relaxed line height",
name: "typography.line-height-relaxed",
value: "1.7",
},
// ---------------------------------------------------------------------------------------------
// Spacing scale.
// ---------------------------------------------------------------------------------------------
{
description: "No space. Named so a component never hardcodes a bare zero.",
label: "No spacing",
name: "spacing.none",
value: "0",
},
{
description: "Hairline spacing between tightly related inline elements.",
label: "Extra extra small spacing",
name: "spacing.2xs",
value: "0.125rem",
},
{
description: "Spacing inside a compact control.",
label: "Extra small spacing",
name: "spacing.xs",
value: "0.25rem",
},
{
description: "Spacing between a label and its field.",
label: "Small spacing",
name: "spacing.sm",
value: "0.5rem",
},
{
description: "The default gap between related elements.",
label: "Medium spacing",
name: "spacing.md",
value: "0.75rem",
},
{
description: "Padding inside a panel or dialog.",
label: "Large spacing",
name: "spacing.lg",
value: "1rem",
},
{
description: "Spacing between sections of one screen.",
label: "Extra large spacing",
name: "spacing.xl",
value: "1.5rem",
},
{
description: "Spacing between major regions of a page.",
label: "Extra extra large spacing",
name: "spacing.2xl",
value: "2.5rem",
},
// ---------------------------------------------------------------------------------------------
// Radii and shadows.
// ---------------------------------------------------------------------------------------------
{
description: "Square corners.",
label: "No radius",
name: "radius.none",
value: "0",
},
{
description: "Corner radius for badges and small inline elements.",
label: "Small radius",
name: "radius.sm",
value: "0.25rem",
},
{
description: "Corner radius for controls, inputs, and buttons.",
label: "Medium radius",
name: "radius.md",
value: "0.375rem",
},
{
description: "Corner radius for panels and dialogs.",
label: "Large radius",
name: "radius.lg",
value: "0.75rem",
},
{
description: "Fully rounded ends, for pills and status dots.",
label: "Pill radius",
name: "radius.pill",
value: "9999px",
},
{
description: "No shadow.",
label: "No shadow",
name: "shadow.none",
value: "none",
},
{
description: "Subtle lift for a resting panel.",
label: "Small shadow",
name: "shadow.sm",
value: "0 1px 2px rgba(23, 23, 21, 0.08)",
},
{
description: "Shadow for a popover or a raised surface.",
label: "Medium shadow",
name: "shadow.md",
value: "0 4px 12px rgba(23, 23, 21, 0.12)",
},
{
description: "Shadow for a modal dialog.",
label: "Large shadow",
name: "shadow.lg",
value: "0 16px 40px rgba(23, 23, 21, 0.22)",
},
// ---------------------------------------------------------------------------------------------
// Control sizing.
// ---------------------------------------------------------------------------------------------
{
description: "Height of a compact control.",
label: "Small control height",
name: "control.height-sm",
value: "1.75rem",
},
{
description: "Height of an ordinary button, input, or select.",
label: "Medium control height",
name: "control.height-md",
value: "2.25rem",
},
{
description: "Height of a prominent primary control.",
label: "Large control height",
name: "control.height-lg",
value: "2.75rem",
},
{
description: "Horizontal padding inside a control.",
label: "Control padding",
name: "control.padding-inline",
value: "0.75rem",
},
] as const satisfies readonly DesignTokenInput[];
/** Every registered token name, as a union — the compile-time half of "modules consume registered
* tokens and never invent new global theme variables". */
export type EkklesiaDesignTokenName = (typeof declarations)[number]["name"];
/**
* The one global semantic token registry. Built (and therefore validated) at module evaluation, so
* an invalid declaration fails where it was written rather than in a browser.
*/
export const ekklesiaDesignTokens = createDesignTokenRegistry(declarations);
/** `var(--ekklesia-…)` for a registered token. The typed reference component and module code use;
* an unregistered name is a compile error, and also a runtime `DesignTokenError`. */
export function designToken(name: EkklesiaDesignTokenName): string {
return ekklesiaDesignTokens.reference(name);
}
/** The complete default theme as one installable CSS rule. */
export function ekklesiaDesignTokenStylesheet(scope: DesignTokenScope = "root"): string {
return ekklesiaDesignTokens.stylesheet(scope);
}
+282
View File
@@ -0,0 +1,282 @@
/**
* The design-token contract: what a semantic token is, what makes one valid, and how a validated
* registry is built from a declaration list.
*
* This package has **no dependencies at all**, exactly like `@ekklesia/module-ui-sdk` and for the
* same reason (ADR 0024): it is imported by browser programs — the Dashboard shell, a module's own
* UI artifact, a contributed renderer — and a design contract that dragged a backend type graph
* behind it would put Node's global declarations back into every one of them. Nothing here reads
* `process`, touches a filesystem, or imports a platform contract.
*/
/**
* The closed set of token families. A token's category is **derived from the first segment of its
* name** rather than declared separately, so a token can never claim one category while being named
* for another, and a future theme editor can group the registry without a second source of truth.
*/
export const DESIGN_TOKEN_CATEGORIES = [
"background",
"text",
"accent",
"border",
"status",
"state",
"typography",
"spacing",
"radius",
"shadow",
"control",
] as const;
export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number];
const categories: ReadonlySet<string> = new Set(DESIGN_TOKEN_CATEGORIES);
/** The one prefix every Ekklesia global theme variable uses. A module may reference these and may
* define its own differently-prefixed private aliases; it may never define one of these. */
export const DESIGN_TOKEN_VARIABLE_PREFIX = "--ekklesia-";
/**
* Dotted, lowercase, kebab-cased segments with at least two segments — `background.page`, not
* `page`. The first segment is the category, so a one-segment name has no family to belong to.
*/
const TOKEN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/u;
/** The CSS custom-property identifier grammar this package will emit, checked against the derived
* name rather than trusted. A value that cannot pass this can never reach a stylesheet. */
const CSS_VARIABLE_PATTERN = /^--ekklesia-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
/**
* The bound on a token's default value.
*
* A design token is a small, declarative constant — a color, a length, a font stack, a shadow — and
* every fragment rejected below is either an injection vector or a sign that arbitrary CSS is being
* smuggled through the token registry into a `<style>` element. Rejecting them is what "no unbounded
* arbitrary values" means concretely: the registry stays a value table rather than a second,
* unreviewed stylesheet.
*/
export const DESIGN_TOKEN_VALUE_MAX_LENGTH = 160;
const FORBIDDEN_VALUE_FRAGMENTS = [
";",
"{",
"}",
"<",
">",
"/*",
"*/",
"@",
"url(",
"expression(",
"!important",
] as const;
export const DESIGN_TOKEN_LABEL_MAX_LENGTH = 80;
export const DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH = 240;
/** What a token declaration says. Everything else about a token is derived. */
export interface DesignTokenInput {
/** `category.rest-of-name`, lowercase kebab-case segments. */
readonly name: string;
/** Short human-readable name, for a future theme editor's field label. */
readonly label: string;
/** What the token means and when to reach for it. Enumerable, never rendered by the platform. */
readonly description: string;
/** The default CSS value installed at a host root. */
readonly value: string;
}
/** A validated token: its declaration plus the two derived facts callers actually consume. */
export interface DesignToken extends DesignTokenInput {
readonly category: DesignTokenCategory;
/** `--ekklesia-<name with dots replaced by dashes>`. */
readonly cssVariable: string;
}
export type DesignTokenErrorCode =
| "DESIGN_TOKEN_NAME_INVALID"
| "DESIGN_TOKEN_CATEGORY_UNKNOWN"
| "DESIGN_TOKEN_VARIABLE_INVALID"
| "DESIGN_TOKEN_LABEL_INVALID"
| "DESIGN_TOKEN_DESCRIPTION_INVALID"
| "DESIGN_TOKEN_VALUE_INVALID"
| "DESIGN_TOKEN_DUPLICATE"
| "DESIGN_TOKEN_UNKNOWN"
| "DESIGN_TOKEN_SCOPE_INVALID";
const errorMessages: Readonly<Record<DesignTokenErrorCode, string>> = Object.freeze({
DESIGN_TOKEN_NAME_INVALID:
"A design token name must be at least two lowercase kebab-case segments separated by dots.",
DESIGN_TOKEN_CATEGORY_UNKNOWN: "A design token's first name segment must be a known category.",
DESIGN_TOKEN_VARIABLE_INVALID:
"A design token must derive a valid --ekklesia- CSS custom property identifier.",
DESIGN_TOKEN_LABEL_INVALID: "A design token must declare a bounded, non-empty label.",
DESIGN_TOKEN_DESCRIPTION_INVALID: "A design token must declare a bounded, non-empty description.",
DESIGN_TOKEN_VALUE_INVALID:
"A design token value must be a bounded literal CSS value with no declaration, comment, at-rule, url(), or !important syntax.",
DESIGN_TOKEN_DUPLICATE: "A design token name is declared more than once.",
DESIGN_TOKEN_UNKNOWN: "That design token is not registered.",
DESIGN_TOKEN_SCOPE_INVALID: 'A design token stylesheet scope must be "root" or "host".',
});
/**
* Carries a stable code and a fixed message, never the offending token name or value — the same
* sanitization rule every other Ekklesia error boundary follows. The rejected declaration is
* visible to the developer who wrote it, in the source file that failed to load.
*/
export class DesignTokenError extends Error {
readonly code: DesignTokenErrorCode;
constructor(code: DesignTokenErrorCode) {
super(errorMessages[code]);
this.name = "DesignTokenError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isBoundedText(value: unknown, maxLength: number): value is string {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
}
/** Written as a code-point scan rather than a character-class regex: a regex spelling of this range
* has to contain the control characters themselves, which is both unreadable and exactly the kind of
* literal the linter refuses. */
function containsControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint < 0x20 || codePoint === 0x7f) {
return true;
}
}
return false;
}
function isTokenValue(value: unknown): value is string {
if (!isBoundedText(value, DESIGN_TOKEN_VALUE_MAX_LENGTH)) {
return false;
}
if (value !== value.trim() || containsControlCharacter(value)) {
return false;
}
if (value.includes("\\")) {
return false;
}
return !FORBIDDEN_VALUE_FRAGMENTS.some((fragment) => value.includes(fragment));
}
export function designTokenVariableName(name: string): string {
return `${DESIGN_TOKEN_VARIABLE_PREFIX}${name.split(".").join("-")}`;
}
function validateToken(input: DesignTokenInput): DesignToken {
if (typeof input.name !== "string" || !TOKEN_NAME_PATTERN.test(input.name)) {
throw new DesignTokenError("DESIGN_TOKEN_NAME_INVALID");
}
const category = input.name.split(".")[0] as string;
if (!categories.has(category)) {
throw new DesignTokenError("DESIGN_TOKEN_CATEGORY_UNKNOWN");
}
const cssVariable = designTokenVariableName(input.name);
if (!CSS_VARIABLE_PATTERN.test(cssVariable)) {
throw new DesignTokenError("DESIGN_TOKEN_VARIABLE_INVALID");
}
if (!isBoundedText(input.label, DESIGN_TOKEN_LABEL_MAX_LENGTH)) {
throw new DesignTokenError("DESIGN_TOKEN_LABEL_INVALID");
}
if (!isBoundedText(input.description, DESIGN_TOKEN_DESCRIPTION_MAX_LENGTH)) {
throw new DesignTokenError("DESIGN_TOKEN_DESCRIPTION_INVALID");
}
if (!isTokenValue(input.value)) {
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID");
}
return Object.freeze({
category: category as DesignTokenCategory,
cssVariable,
description: input.description,
label: input.label,
name: input.name,
value: input.value,
});
}
/** Where a generated token stylesheet installs its declarations. `root` targets a document, `host`
* targets the element a shadow root is attached to. There is deliberately no caller-chosen selector:
* an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */
export type DesignTokenScope = "root" | "host";
export interface DesignTokenRegistry<Name extends string = string> {
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
list(): readonly DesignToken[];
/** Every token in one category, in declaration order. */
listByCategory(category: DesignTokenCategory): readonly DesignToken[];
has(name: unknown): boolean;
/** The validated token, or `undefined`. Takes `unknown` so a lookup from untyped data fails
* closed rather than assuming validation already happened. */
get(name: unknown): DesignToken | undefined;
/** `--ekklesia-…` for a registered token. Throws `DesignTokenError` for anything else. */
variable(name: Name): string;
/** `var(--ekklesia-…)` for a registered token — what component code actually writes. */
reference(name: Name): string;
/** The complete default theme as one CSS rule, ready to install at a host root. */
stylesheet(scope?: DesignTokenScope): string;
}
/**
* Validates a declaration list and returns the immutable registry built from it.
*
* Every rule is enforced here rather than at review time: an invalid name, an unknown category, a
* malformed derived CSS identifier, an empty or overlong label or description, a value that is not
* a bounded literal, and a duplicate name each throw. A registry that constructs is therefore a
* registry that can be emitted as CSS without escaping, because nothing that needs escaping can be
* in it.
*/
export function createDesignTokenRegistry<const Tokens extends readonly DesignTokenInput[]>(
declarations: Tokens,
): DesignTokenRegistry<Tokens[number]["name"]> {
const byName = new Map<string, DesignToken>();
for (const declaration of declarations) {
const token = validateToken(declaration);
if (byName.has(token.name)) {
throw new DesignTokenError("DESIGN_TOKEN_DUPLICATE");
}
byName.set(token.name, token);
}
const tokens = Object.freeze([...byName.values()]);
const byCategory = new Map<DesignTokenCategory, readonly DesignToken[]>(
DESIGN_TOKEN_CATEGORIES.map((category) => [
category,
Object.freeze(tokens.filter((token) => token.category === category)),
]),
);
function requireToken(name: unknown): DesignToken {
const token = typeof name === "string" ? byName.get(name) : undefined;
if (!token) {
throw new DesignTokenError("DESIGN_TOKEN_UNKNOWN");
}
return token;
}
return Object.freeze({
get: (name: unknown): DesignToken | undefined =>
typeof name === "string" ? byName.get(name) : undefined,
has: (name: unknown): boolean => typeof name === "string" && byName.has(name),
list: (): readonly DesignToken[] => tokens,
listByCategory: (category: DesignTokenCategory): readonly DesignToken[] =>
byCategory.get(category) ?? Object.freeze([]),
reference: (name: string): string => `var(${requireToken(name).cssVariable})`,
stylesheet: (scope: DesignTokenScope = "root"): string => {
if (scope !== "root" && scope !== "host") {
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
}
const declarationText = tokens
.map((token) => ` ${token.cssVariable}: ${token.value};`)
.join("\n");
return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`;
},
variable: (name: string): string => requireToken(name).cssVariable,
});
}
@@ -0,0 +1,52 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function sourceFiles(): Promise<readonly string[]> {
const directory = path.join(packageDirectory, "src");
const entries = await readdir(directory);
return entries
.filter((entry) => entry.endsWith(".ts"))
.map((entry) => path.join(directory, entry));
}
/**
* The design contract is imported by the Dashboard shell, by module UI artifacts, and by contributed
* renderers. It inherits exactly the rule ADR 0024 established for `@ekklesia/module-ui-sdk`: it
* must not be able to pull a backend type graph — and with it Node's global declarations — into a
* browser program.
*/
describe("the design token package stays browser-safe", () => {
it("declares no runtime dependency at all", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(manifest.dependencies).toBeUndefined();
expect(manifest.peerDependencies).toBeUndefined();
});
it("imports nothing outside its own source", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(file, "utf8");
const specifiers = [...source.matchAll(/from\s+"([^"]+)"/gmu)].map(([, specifier]) =>
String(specifier),
);
expect(specifiers.every((specifier) => specifier.startsWith("."))).toBe(true);
}
});
it("never names a Node built-in or reads the environment", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(file, "utf8");
expect(source).not.toContain('"node:');
expect(source).not.toContain("process.env");
expect(source).not.toContain("globalThis.process");
}
});
});
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
DESIGN_TOKEN_CATEGORIES,
DESIGN_TOKEN_STYLE_ATTRIBUTE,
DESIGN_TOKEN_VARIABLE_PREFIX,
designToken,
ekklesiaDesignTokens,
ekklesiaDesignTokenStylesheet,
installEkklesiaDesignTokens,
} from "../src/index.js";
describe("the Ekklesia design token registry", () => {
it("is enumerable and deterministic", () => {
const first = ekklesiaDesignTokens.list();
const second = ekklesiaDesignTokens.list();
expect(first).toBe(second);
expect(Object.isFrozen(first)).toBe(true);
expect(first.length).toBeGreaterThan(0);
expect(first.map((token) => token.name)).toEqual(second.map((token) => token.name));
expect(ekklesiaDesignTokenStylesheet()).toBe(ekklesiaDesignTokenStylesheet());
});
it("declares a unique name and CSS variable for every token", () => {
const names = ekklesiaDesignTokens.list().map((token) => token.name);
const variables = ekklesiaDesignTokens.list().map((token) => token.cssVariable);
expect(new Set(names).size).toBe(names.length);
expect(new Set(variables).size).toBe(variables.length);
expect(variables.every((variable) => variable.startsWith(DESIGN_TOKEN_VARIABLE_PREFIX))).toBe(
true,
);
});
it("gives every token a human-readable label, description, and derived category", () => {
for (const token of ekklesiaDesignTokens.list()) {
expect(token.label.trim().length).toBeGreaterThan(0);
expect(token.description.trim().length).toBeGreaterThan(0);
expect(DESIGN_TOKEN_CATEGORIES).toContain(token.category);
expect(token.name.startsWith(`${token.category}.`)).toBe(true);
}
});
it("covers the semantic families the platform promises", () => {
const names = new Set(ekklesiaDesignTokens.list().map((token) => token.name));
for (const required of [
"background.page",
"background.canvas",
"background.surface",
"background.raised",
"background.overlay",
"background.inset",
"text.primary",
"text.secondary",
"text.muted",
"text.inverse",
"text.success",
"text.warning",
"text.danger",
"accent.base",
"accent.focus",
"border.base",
"border.divider",
"state.hover-background",
"state.pressed-background",
"state.selected-background",
"state.disabled-opacity",
"state.focus-visible-width",
"typography.family-sans",
"typography.size-md",
"typography.weight-bold",
"typography.line-height-normal",
"typography.size-heading-1",
"spacing.md",
"radius.md",
"shadow.md",
"control.height-md",
]) {
expect(names).toContain(required);
}
});
it("pairs every status colour with a non-colour symbol", () => {
for (const status of ["success", "warning", "danger", "info"]) {
expect(ekklesiaDesignTokens.has(`status.${status}-background`)).toBe(true);
expect(ekklesiaDesignTokens.has(`status.${status}-border`)).toBe(true);
const symbol = ekklesiaDesignTokens.get(`status.${status}-symbol`);
expect(symbol?.value.length).toBeGreaterThan(0);
}
});
it("hands component code a var() reference rather than a raw colour", () => {
expect(designToken("text.primary")).toBe("var(--ekklesia-text-primary)");
expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary");
});
it("emits a stylesheet declaring every registered token exactly once", () => {
const stylesheet = ekklesiaDesignTokenStylesheet();
for (const token of ekklesiaDesignTokens.list()) {
expect(stylesheet.split(`${token.cssVariable}:`).length).toBe(2);
}
});
it("installs the default theme at a document root exactly once and removes what it added", () => {
const remove = installEkklesiaDesignTokens(document);
const installedAgain = installEkklesiaDesignTokens(document);
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
expect(
document
.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`)
?.textContent?.includes("--ekklesia-background-page"),
).toBe(true);
installedAgain();
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(1);
remove();
remove();
expect(document.querySelectorAll(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`).length).toBe(0);
});
it("installs host-scoped values into an isolated shadow root", () => {
const host = document.createElement("div");
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
installEkklesiaDesignTokens(shadow, { scope: "host" });
const style = shadow.querySelector(`style[${DESIGN_TOKEN_STYLE_ATTRIBUTE}]`);
expect(style?.textContent?.startsWith(":host {")).toBe(true);
host.remove();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
createDesignTokenRegistry,
DESIGN_TOKEN_CATEGORIES,
DESIGN_TOKEN_VALUE_MAX_LENGTH,
DesignTokenError,
type DesignTokenInput,
} from "../src/index.js";
function tokens(...overrides: readonly Partial<DesignTokenInput>[]): readonly DesignTokenInput[] {
return overrides.map((override) => ({
description: "A token used by this test.",
label: "Test token",
name: "background.page",
value: "#ffffff",
...override,
}));
}
describe("design token validation", () => {
it("accepts a minimal well-formed declaration and derives category and CSS variable", () => {
const registry = createDesignTokenRegistry(tokens({ name: "spacing.2xs", value: "0.125rem" }));
const token = registry.get("spacing.2xs");
expect(token).toEqual({
category: "spacing",
cssVariable: "--ekklesia-spacing-2xs",
description: "A token used by this test.",
label: "Test token",
name: "spacing.2xs",
value: "0.125rem",
});
expect(Object.isFrozen(token)).toBe(true);
});
it("rejects a duplicate token name", () => {
expect(() => createDesignTokenRegistry(tokens({}, {}))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DUPLICATE" }),
);
});
it.each([
["a single segment", "page"],
["an empty name", ""],
["an uppercase segment", "background.Page"],
["an underscore", "background.page_alt"],
["a trailing dot", "background.page."],
["a space", "background.page alt"],
])("rejects %s as a token name", (_label, name) => {
expect(() => createDesignTokenRegistry(tokens({ name }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_NAME_INVALID" }),
);
});
it("rejects a name whose first segment is not a known category", () => {
expect(() => createDesignTokenRegistry(tokens({ name: "brand.primary" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_CATEGORY_UNKNOWN" }),
);
});
it.each([
["a declaration terminator", "#fff; color: red"],
["a rule block", "#fff } body { display: none"],
["a comment", "#fff /* hidden */"],
["an at-rule", "@import 'evil.css'"],
["a url reference", "url(https://example.invalid/x.png)"],
["an override", "#fff !important"],
["a markup fragment", "</style><script>"],
["an escape sequence", "\\0041"],
["an empty value", " "],
])("rejects %s as a token value", (_label, value) => {
expect(() => createDesignTokenRegistry(tokens({ value }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
});
it("rejects an unbounded value", () => {
expect(() =>
createDesignTokenRegistry(tokens({ value: "a".repeat(DESIGN_TOKEN_VALUE_MAX_LENGTH + 1) })),
).toThrowError(expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }));
});
it("rejects a control character hidden inside a value", () => {
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\u0007" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ value: "#fff\nred" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_VALUE_INVALID" }),
);
});
it("rejects an empty or overlong label and description", () => {
expect(() => createDesignTokenRegistry(tokens({ label: " " }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_LABEL_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ description: "" }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
);
expect(() => createDesignTokenRegistry(tokens({ description: "d".repeat(241) }))).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_DESCRIPTION_INVALID" }),
);
});
it("never echoes the rejected declaration in the error", () => {
let caught: unknown;
try {
createDesignTokenRegistry(tokens({ value: "#fff; content: 'leak-me'" }));
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DesignTokenError);
expect(String((caught as Error).message)).not.toContain("leak-me");
expect(String((caught as Error).stack)).not.toContain("leak-me");
});
it("fails a lookup closed for an unregistered or non-string name", () => {
const registry = createDesignTokenRegistry(tokens({}));
expect(registry.get("nope.nope")).toBeUndefined();
expect(registry.get(42)).toBeUndefined();
expect(registry.has(undefined)).toBe(false);
expect(() => registry.variable("nope.nope")).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_UNKNOWN" }),
);
});
it("rejects a stylesheet scope outside the closed set", () => {
const registry = createDesignTokenRegistry(tokens({}));
expect(() => registry.stylesheet("body" as "root")).toThrowError(
expect.objectContaining({ code: "DESIGN_TOKEN_SCOPE_INVALID" }),
);
});
it("emits one rule per scope containing every declared token", () => {
const registry = createDesignTokenRegistry(
tokens({ name: "background.page", value: "#fff" }, { name: "text.primary", value: "#000" }),
);
expect(registry.stylesheet()).toBe(
":root {\n --ekklesia-background-page: #fff;\n --ekklesia-text-primary: #000;\n}\n",
);
expect(registry.stylesheet("host").startsWith(":host {")).toBe(true);
});
it("groups tokens by category without a second source of truth", () => {
const registry = createDesignTokenRegistry(
tokens({ name: "background.page" }, { name: "text.primary", value: "#000" }),
);
expect(registry.listByCategory("background").map((token) => token.name)).toEqual([
"background.page",
]);
expect(registry.listByCategory("shadow")).toEqual([]);
expect(DESIGN_TOKEN_CATEGORIES).toContain("background");
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}
+9 -4
View File
@@ -54,10 +54,15 @@ export type ModuleUiPrincipal =
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
*
* This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, host theme and design tokens, module configuration,
* organization/platform context, and diagnostics reporting are **not** here yet: each needs a design
* that does not exist, and a speculative field frozen into a public contract is harder to remove
* than to add. See ADR 0022.
* screen will eventually want. Navigation, module configuration, organization/platform context, and
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
*
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
* root, they inherit into the artifact's own root, and module code writes `var(--ekklesia-…)`. A
* token map on this context would make styling a run-time negotiation, force every module to
* re-implement it from data, and give a host something it could forget to supply.
*
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@ekklesia/ui",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/ui",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/design-tokens": "workspace:*"
},
"peerDependencies": {
"react": "19.2.8"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"happy-dom": "20.0.11",
"react": "19.2.8",
"react-dom": "19.2.8",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "ui"
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ReactElement } from "react";
import { EKKLESIA_UI_STYLE_ATTRIBUTE, EKKLESIA_UI_STYLESHEET } from "./styles.js";
/**
* Renders the element stylesheet into whichever root this React tree happens to live in.
*
* That is the whole point: a Dashboard renders it into the document, and a module UI artifact
* renders it inside its own isolated root, with no caller having to know which one it is. React puts
* a `<style>` where the tree is, so the same component gives a document and a shadow root the
* identical rules without either reaching into the other.
*
* Render it once per root, above the elements that use it. A second copy in one root is harmless
* (the rules are identical) but pointless. There is deliberately no global side effect, no
* `document.head` hoisting, and no `precedence` attribute: hoisting a shadow root's styles into the
* host document is exactly the leak this package exists to prevent.
*/
export function EkklesiaUiStyles(): ReactElement {
const marker = { [EKKLESIA_UI_STYLE_ATTRIBUTE]: "" };
return <style {...marker}>{EKKLESIA_UI_STYLESHEET}</style>;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Joins the class names an element resolved to, dropping the ones that are not there.
*
* Deliberately not a dependency: this package has exactly one runtime dependency (the design tokens)
* and adding a second for six lines would be a poor trade for every browser program that bundles it.
*/
export function classNames(...values: readonly (string | undefined | false)[]): string {
return values
.filter((value): value is string => typeof value === "string" && value !== "")
.join(" ");
}
+152
View File
@@ -0,0 +1,152 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactElement,
ReactNode,
Ref,
SelectHTMLAttributes,
} from "react";
import { useId } from "react";
import { classNames } from "./class-names.js";
/**
* The foundational interactive elements.
*
* Each one is a thin, accessible shell over the real HTML element rather than a re-implementation of
* it: a `Button` is a `<button>`, a `TextInput` is an `<input>`, and every native attribute a caller
* needs — `type`, `name`, `autoComplete`, `aria-*`, `onChange` — passes straight through. That is
* deliberate. A component library that reinvents the control is a component library that has to
* reinvent focus, keyboard behaviour, form association, and assistive-technology semantics too, and
* it always gets some of them wrong.
*/
export type EkklesiaButtonVariant = "primary" | "secondary" | "quiet" | "danger";
export type EkklesiaControlSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
readonly variant?: EkklesiaButtonVariant;
readonly size?: EkklesiaControlSize;
readonly ref?: Ref<HTMLButtonElement>;
}
/**
* `type` defaults to `"button"`, not the HTML default of `"submit"`.
*
* A button inside a form that submits it by accident is one of the most common and most confusing
* UI defects there is. A caller that genuinely wants a submit button says so.
*/
export function Button(props: ButtonProps): ReactElement {
const { className, size = "md", variant = "secondary", type = "button", ...rest } = props;
return (
<button
{...rest}
className={classNames(
"ek-button",
"ek-focusable",
`ek-button--${variant}`,
size === "md" ? undefined : `ek-button--${size}`,
className,
)}
type={type}
/>
);
}
export interface IconButtonProps extends ButtonProps {
/** Required: an icon-only control with no accessible name is unusable with a screen reader. */
readonly label: string;
}
export function IconButton(props: IconButtonProps): ReactElement {
const { className, label, ...rest } = props;
return (
<Button {...rest} aria-label={label} className={classNames("ek-icon-button", className)} />
);
}
export interface FieldProps {
readonly label: ReactNode;
readonly hint?: ReactNode;
/** When present the field is invalid: the message is rendered with the danger status symbol and
* announced politely, and the control is wired to it through `aria-describedby`. */
readonly error?: ReactNode;
readonly className?: string;
/**
* The control. Receives the generated `id`, `aria-describedby`, and `aria-invalid` so a caller
* never has to invent identifiers or remember to associate the label — the two things hand-rolled
* form markup gets wrong most often.
*/
readonly children: (control: {
readonly id: string;
readonly "aria-describedby": string | undefined;
readonly "aria-invalid": boolean | undefined;
}) => ReactNode;
}
export function Field(props: FieldProps): ReactElement {
const generatedId = useId();
const hintId = `${generatedId}-hint`;
const errorId = `${generatedId}-error`;
const describedBy =
[props.error === undefined ? undefined : errorId, props.hint === undefined ? undefined : hintId]
.filter((value): value is string => value !== undefined)
.join(" ") || undefined;
return (
<div className={classNames("ek-field", props.className)}>
<label className="ek-field__label" htmlFor={generatedId}>
{props.label}
</label>
{props.children({
"aria-describedby": describedBy,
"aria-invalid": props.error === undefined ? undefined : true,
id: generatedId,
})}
{props.hint === undefined ? undefined : (
<span className="ek-field__hint" id={hintId}>
{props.hint}
</span>
)}
{props.error === undefined ? undefined : (
<span className="ek-field__error" id={errorId} role="alert">
{props.error}
</span>
)}
</div>
);
}
export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
readonly ref?: Ref<HTMLInputElement>;
}
export function TextInput(props: TextInputProps): ReactElement {
const { className, type = "text", ...rest } = props;
return (
<input {...rest} className={classNames("ek-control", "ek-focusable", className)} type={type} />
);
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
readonly ref?: Ref<HTMLSelectElement>;
}
export function Select(props: SelectProps): ReactElement {
const { className, ...rest } = props;
return <select {...rest} className={classNames("ek-control", "ek-focusable", className)} />;
}
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
readonly label: ReactNode;
readonly ref?: Ref<HTMLInputElement>;
}
export function Checkbox(props: CheckboxProps): ReactElement {
const { className, label, ...rest } = props;
return (
<label className={classNames("ek-checkbox", className)}>
<input {...rest} className="ek-checkbox__input ek-focusable" type="checkbox" />
<span>{label}</span>
</label>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { ReactElement, ReactNode } from "react";
import { classNames } from "./class-names.js";
/**
* Table and list primitives.
*
* These are primitives rather than a data grid on purpose: sorting, filtering, pagination, selection
* models, and virtualisation are product decisions a module owns, and a "smart" table forces every
* consumer into one answer for all of them. What the platform owns is that two modules' tables look
* and behave like one product.
*/
export interface TableProps {
/** Names the table for assistive technology when no visible caption is rendered. */
readonly label: string;
readonly className?: string;
readonly children?: ReactNode;
}
export function Table(props: TableProps): ReactElement {
return (
<table aria-label={props.label} className={classNames("ek-table", props.className)}>
{props.children}
</table>
);
}
export interface TableSectionProps {
readonly children?: ReactNode;
}
export function TableHead(props: TableSectionProps): ReactElement {
return <thead>{props.children}</thead>;
}
export function TableBody(props: TableSectionProps): ReactElement {
return <tbody>{props.children}</tbody>;
}
export interface TableRowProps {
readonly selected?: boolean;
readonly children?: ReactNode;
}
export function TableRow(props: TableRowProps): ReactElement {
return (
<tr {...(props.selected === undefined ? {} : { "aria-selected": props.selected })}>
{props.children}
</tr>
);
}
export interface TableCellProps {
readonly header?: boolean;
/** `col` for a column header, `row` for a row header. Ignored unless `header` is set. */
readonly scope?: "col" | "row";
readonly children?: ReactNode;
}
export function TableCell(props: TableCellProps): ReactElement {
if (props.header === true) {
return <th scope={props.scope ?? "col"}>{props.children}</th>;
}
return <td>{props.children}</td>;
}
export interface ListProps {
readonly label?: string;
readonly className?: string;
readonly children?: ReactNode;
}
export function List(props: ListProps): ReactElement {
return (
<ul
{...(props.label === undefined ? {} : { "aria-label": props.label })}
className={classNames("ek-list", props.className)}
>
{props.children}
</ul>
);
}
export interface ListItemProps {
readonly className?: string;
readonly children?: ReactNode;
}
export function ListItem(props: ListItemProps): ReactElement {
return <li className={classNames("ek-list__item", props.className)}>{props.children}</li>;
}
+161
View File
@@ -0,0 +1,161 @@
import type { ReactElement, ReactNode } from "react";
import { classNames } from "./class-names.js";
/**
* How the platform says something is fine, something needs attention, something broke, something is
* loading, and there is nothing here yet.
*
* ## Status never depends on colour alone
*
* Every status tone renders a symbol beside its label — `✓`, `!`, `✕`, `i` — sourced from the
* design-token registry so a theme can change the glyph without a component changing. A screen in a
* bright lobby, a projector with a washed-out gamut, and a person who cannot distinguish red from
* green all get the same information (WCAG 2.2 §1.4.1). This is a rule of the shared elements, not
* an option a caller can turn off.
*/
export type EkklesiaStatusTone = "neutral" | "info" | "success" | "warning" | "danger";
const statusSymbols: Readonly<Record<EkklesiaStatusTone, string>> = Object.freeze({
danger: "✕",
info: "i",
neutral: "•",
success: "✓",
warning: "!",
});
export interface BadgeProps {
readonly tone?: EkklesiaStatusTone;
readonly className?: string;
readonly children?: ReactNode;
}
/** A compact status indicator: a tone, its symbol, and a label. */
export function Badge(props: BadgeProps): ReactElement {
const tone = props.tone ?? "neutral";
return (
<span className={classNames("ek-badge", `ek-badge--${tone}`, props.className)}>
<span aria-hidden="true" className="ek-badge__symbol">
{statusSymbols[tone]}
</span>
{props.children}
</span>
);
}
export interface ToastProps {
readonly tone?: EkklesiaStatusTone;
readonly title: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
}
/**
* One notification's presentation — and only its presentation.
*
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
* package-level singleton would be shared mutable state reaching across every isolated artifact on
* a page. A caller renders these where it wants them, for as long as it wants them.
*/
export function Toast(props: ToastProps): ReactElement {
const tone = props.tone ?? "info";
return (
<div
aria-live={tone === "danger" ? "assertive" : "polite"}
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
role="status"
>
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
)}
</div>
);
}
export interface ToastStackProps {
readonly label?: string;
readonly className?: string;
readonly children?: ReactNode;
}
export function ToastStack(props: ToastStackProps): ReactElement {
return (
<section
aria-label={props.label ?? "Notifications"}
className={classNames("ek-toast__stack", props.className)}
>
{props.children}
</section>
);
}
export interface EmptyStateProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** The one action that would resolve the emptiness, if there is one. */
readonly action?: ReactNode;
readonly className?: string;
}
export function EmptyState(props: EmptyStateProps): ReactElement {
return (
<div className={classNames("ek-message", props.className)}>
<p className="ek-message__title">{props.title}</p>
{props.description === undefined ? undefined : (
<p className="ek-message__body">{props.description}</p>
)}
{props.action}
</div>
);
}
export interface ErrorStateProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** A retry control, or whatever else the caller can honestly offer. */
readonly action?: ReactNode;
readonly className?: string;
}
/**
* The failure a person sees.
*
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
* component is where that discipline becomes visible to a user.
*/
export function ErrorState(props: ErrorStateProps): ReactElement {
return (
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
<p className="ek-message__title">{props.title}</p>
{props.description === undefined ? undefined : (
<p className="ek-message__body">{props.description}</p>
)}
{props.action}
</div>
);
}
export interface LoadingStateProps {
readonly label?: string;
readonly className?: string;
}
export function LoadingState(props: LoadingStateProps): ReactElement {
const label = props.label ?? "Loading…";
return (
<div
aria-live="polite"
className={classNames("ek-message", "ek-message--loading", props.className)}
role="status"
>
<div aria-hidden="true" className="ek-spinner" />
<p className="ek-message__body">{label}</p>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* `@ekklesia/ui` — the foundational Ekklesia elements every Ekklesia screen is built from, whether
* that screen belongs to the Dashboard shell, to a module's own management UI, or to a renderer one
* module contributes to another.
*
* Every element draws its colour, type, spacing, radius, shadow, and interaction state from
* `@ekklesia/design-tokens`. There is no hardcoded brand value anywhere in the package, which is
* what makes a future theme editor possible rather than aspirational.
*
* This is deliberately **not** a large component catalogue, a design-system site, a brand builder,
* or a theme editor. It is the set a real module dashboard cannot be built without, and nothing
* more; a component earns its place here when two independent consumers need the same one.
*/
export { classNames } from "./class-names.js";
export {
Button,
type ButtonProps,
Checkbox,
type CheckboxProps,
type EkklesiaButtonVariant,
type EkklesiaControlSize,
Field,
type FieldProps,
IconButton,
type IconButtonProps,
Select,
type SelectProps,
TextInput,
type TextInputProps,
} from "./controls.js";
export {
List,
type ListItemProps,
ListItem,
type ListProps,
Table,
TableBody,
TableCell,
type TableCellProps,
TableHead,
type TableProps,
TableRow,
type TableRowProps,
type TableSectionProps,
} from "./data.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export {
Badge,
type BadgeProps,
type EkklesiaStatusTone,
EmptyState,
type EmptyStateProps,
ErrorState,
type ErrorStateProps,
LoadingState,
type LoadingStateProps,
Toast,
type ToastProps,
ToastStack,
type ToastStackProps,
} from "./feedback.js";
export {
EKKLESIA_UI_STYLE_ATTRIBUTE,
EKKLESIA_UI_STYLESHEET,
type EkklesiaUiStyleRoot,
installEkklesiaUiStyles,
} from "./styles.js";
export {
Dialog,
type DialogProps,
Panel,
type PanelProps,
type TabDefinition,
Tabs,
type TabsProps,
} from "./surfaces.js";
+556
View File
@@ -0,0 +1,556 @@
import { designToken } from "@ekklesia/design-tokens";
/**
* Every Ekklesia element's presentation, expressed once as a stylesheet built entirely from the
* global semantic tokens.
*
* ## Why a stylesheet string rather than a `.css` file
*
* These elements are mounted in two very different places: a document (the Dashboard shell, a
* standalone host page) and an isolated shadow root (a module's own UI artifact, a contributed
* renderer). A `.css` file would need a bundler on both sides and a served asset on one of them; a
* string is installed into whichever root the caller actually has, with the identical bytes. It is
* also what lets `:focus-visible`, `:hover`, and `:disabled` be real CSS rather than JavaScript
* approximations of them — which is the difference between a focus treatment that works for a
* keyboard user and one that only looks like it does.
*
* ## Every value here is a token reference
*
* There is no literal colour, font stack, radius, or shadow below: each is `var(--ekklesia-…)`
* resolved from the registry a host installs. That is enforced by a test, not by review, so a
* hardcoded brand colour cannot quietly enter the shared elements and make a future theme editor a
* lie. Structural values that carry no theme meaning — a `0`, a `100%`, a `flex` — stay literal on
* purpose: they are layout, not theme.
*/
export const EKKLESIA_UI_STYLESHEET = `
.ek-root {
color: ${designToken("text.primary")};
font-family: ${designToken("typography.family-sans")};
font-size: ${designToken("typography.size-md")};
line-height: ${designToken("typography.line-height-normal")};
}
.ek-stack {
display: grid;
gap: ${designToken("spacing.md")};
}
.ek-focusable:focus-visible {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-button {
align-items: center;
border: ${designToken("border.width")} solid transparent;
border-radius: ${designToken("radius.md")};
cursor: pointer;
display: inline-flex;
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
gap: ${designToken("spacing.sm")};
height: ${designToken("control.height-md")};
justify-content: center;
line-height: ${designToken("typography.line-height-tight")};
padding-inline: ${designToken("control.padding-inline")};
}
.ek-button--sm {
font-size: ${designToken("typography.size-xs")};
height: ${designToken("control.height-sm")};
}
.ek-button--lg {
font-size: ${designToken("typography.size-md")};
height: ${designToken("control.height-lg")};
}
.ek-button--primary {
background: ${designToken("accent.base")};
color: ${designToken("accent.foreground")};
}
.ek-button--primary:hover:not(:disabled) {
background: ${designToken("accent.strong")};
}
.ek-button--primary:active:not(:disabled) {
background: ${designToken("accent.strong")};
}
.ek-button--secondary {
background: ${designToken("background.surface")};
border-color: ${designToken("border.base")};
color: ${designToken("text.primary")};
}
.ek-button--secondary:hover:not(:disabled) {
background: ${designToken("state.hover-background")};
}
.ek-button--secondary:active:not(:disabled) {
background: ${designToken("state.pressed-background")};
}
.ek-button--quiet {
background: transparent;
color: ${designToken("text.secondary")};
}
.ek-button--quiet:hover:not(:disabled) {
background: ${designToken("state.hover-background")};
color: ${designToken("text.primary")};
}
.ek-button--quiet:active:not(:disabled) {
background: ${designToken("state.pressed-background")};
}
.ek-button--danger {
background: ${designToken("status.danger-background")};
border-color: ${designToken("status.danger-border")};
color: ${designToken("text.danger")};
}
.ek-button--danger:hover:not(:disabled) {
border-color: ${designToken("text.danger")};
}
.ek-button[aria-pressed="true"] {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
}
.ek-button:disabled {
cursor: not-allowed;
opacity: ${designToken("state.disabled-opacity")};
}
.ek-icon-button {
aspect-ratio: 1;
padding-inline: 0;
width: ${designToken("control.height-md")};
}
.ek-field {
display: grid;
gap: ${designToken("spacing.xs")};
}
.ek-field__label {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
}
.ek-field__hint {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
}
.ek-field__error {
color: ${designToken("text.danger")};
font-size: ${designToken("typography.size-xs")};
}
.ek-field__error::before {
content: ${designToken("status.danger-symbol")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-control {
background: ${designToken("background.inset")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.primary")};
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
height: ${designToken("control.height-md")};
padding-inline: ${designToken("control.padding-inline")};
width: 100%;
}
.ek-control:hover:not(:disabled) {
border-color: ${designToken("border.strong")};
}
.ek-control::placeholder {
color: ${designToken("text.muted")};
}
.ek-control:disabled {
cursor: not-allowed;
opacity: ${designToken("state.disabled-opacity")};
}
.ek-control[aria-invalid="true"] {
border-color: ${designToken("text.danger")};
}
.ek-checkbox {
align-items: center;
color: ${designToken("text.primary")};
display: inline-flex;
font-size: ${designToken("typography.size-sm")};
gap: ${designToken("spacing.sm")};
}
.ek-checkbox__input {
accent-color: ${designToken("accent.base")};
height: ${designToken("spacing.lg")};
width: ${designToken("spacing.lg")};
}
.ek-checkbox__input:disabled {
cursor: not-allowed;
opacity: ${designToken("state.disabled-opacity")};
}
.ek-panel {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.sm")};
display: grid;
gap: ${designToken("spacing.md")};
padding: ${designToken("spacing.lg")};
}
.ek-panel--raised {
background: ${designToken("background.raised")};
box-shadow: ${designToken("shadow.md")};
}
.ek-panel__header {
align-items: baseline;
display: flex;
gap: ${designToken("spacing.md")};
justify-content: space-between;
}
.ek-panel__title {
font-size: ${designToken("typography.size-heading-3")};
font-weight: ${designToken("typography.weight-bold")};
line-height: ${designToken("typography.line-height-tight")};
margin: 0;
}
.ek-panel__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-dialog {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.lg")};
color: ${designToken("text.primary")};
display: grid;
gap: ${designToken("spacing.lg")};
max-width: 32rem;
padding: ${designToken("spacing.xl")};
width: 100%;
}
.ek-dialog__scrim {
align-items: center;
background: ${designToken("background.overlay")};
display: flex;
inset: 0;
justify-content: center;
padding: ${designToken("spacing.lg")};
position: fixed;
}
.ek-dialog__title {
font-size: ${designToken("typography.size-heading-2")};
font-weight: ${designToken("typography.weight-bold")};
line-height: ${designToken("typography.line-height-tight")};
margin: 0;
}
.ek-dialog__actions {
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: flex-end;
}
.ek-tabs__list {
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
display: flex;
gap: ${designToken("spacing.xs")};
}
.ek-tab {
background: transparent;
border: 0;
border-bottom: ${designToken("state.focus-visible-width")} solid transparent;
color: ${designToken("text.secondary")};
cursor: pointer;
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
}
.ek-tab:hover {
background: ${designToken("state.hover-background")};
color: ${designToken("text.primary")};
}
.ek-tab[aria-selected="true"] {
border-bottom-color: ${designToken("accent.base")};
color: ${designToken("state.selected-foreground")};
}
.ek-tabs__panel {
padding-block-start: ${designToken("spacing.lg")};
}
.ek-table {
border-collapse: collapse;
font-size: ${designToken("typography.size-sm")};
width: 100%;
}
.ek-table th,
.ek-table td {
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
text-align: start;
}
.ek-table th {
color: ${designToken("text.secondary")};
font-weight: ${designToken("typography.weight-medium")};
}
.ek-table tbody tr:hover {
background: ${designToken("state.hover-background")};
}
.ek-table tbody tr[aria-selected="true"] {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
}
.ek-list {
display: grid;
gap: ${designToken("spacing.xs")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-list__item {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.divider")};
border-radius: ${designToken("radius.md")};
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
}
.ek-badge {
align-items: center;
border: ${designToken("border.width")} solid transparent;
border-radius: ${designToken("radius.pill")};
display: inline-flex;
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
gap: ${designToken("spacing.xs")};
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
}
.ek-badge--neutral {
background: ${designToken("background.inset")};
border-color: ${designToken("border.base")};
color: ${designToken("text.secondary")};
}
.ek-badge--info {
background: ${designToken("status.info-background")};
border-color: ${designToken("status.info-border")};
color: ${designToken("accent.strong")};
}
.ek-badge--success {
background: ${designToken("status.success-background")};
border-color: ${designToken("status.success-border")};
color: ${designToken("text.success")};
}
.ek-badge--warning {
background: ${designToken("status.warning-background")};
border-color: ${designToken("status.warning-border")};
color: ${designToken("text.warning")};
}
.ek-badge--danger {
background: ${designToken("status.danger-background")};
border-color: ${designToken("status.danger-border")};
color: ${designToken("text.danger")};
}
.ek-badge__symbol {
font-weight: ${designToken("typography.weight-bold")};
}
.ek-toast {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-inline-start-width: ${designToken("spacing.xs")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
display: grid;
gap: ${designToken("spacing.2xs")};
padding: ${designToken("spacing.md")};
}
.ek-toast--success {
border-inline-start-color: ${designToken("status.success-border")};
}
.ek-toast--warning {
border-inline-start-color: ${designToken("status.warning-border")};
}
.ek-toast--danger {
border-inline-start-color: ${designToken("status.danger-border")};
}
.ek-toast--info {
border-inline-start-color: ${designToken("status.info-border")};
}
.ek-toast__title {
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-toast__body {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
}
.ek-toast__stack {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-message {
align-items: center;
border: ${designToken("border.width")} dashed ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
display: grid;
gap: ${designToken("spacing.sm")};
justify-items: center;
padding: ${designToken("spacing.xl")};
text-align: center;
}
.ek-message__title {
font-size: ${designToken("typography.size-lg")};
font-weight: ${designToken("typography.weight-bold")};
margin: 0;
}
.ek-message__body {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
max-width: 40ch;
}
.ek-message--error {
border-color: ${designToken("status.danger-border")};
border-style: solid;
}
.ek-message--error .ek-message__title {
color: ${designToken("text.danger")};
}
.ek-message--error .ek-message__title::before {
content: ${designToken("status.danger-symbol")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-message--loading {
border-style: solid;
border-color: ${designToken("border.divider")};
}
.ek-spinner {
animation: ek-spin 1s linear infinite;
border: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")};
border-block-start-color: ${designToken("accent.base")};
border-radius: ${designToken("radius.pill")};
height: ${designToken("spacing.xl")};
width: ${designToken("spacing.xl")};
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ek-spinner {
animation: none;
}
}
`;
/** Marks a stylesheet this package installed, so installation is idempotent per root. */
export const EKKLESIA_UI_STYLE_ATTRIBUTE = "data-ekklesia-ui";
/** A document or a shadow root — the two places these elements are ever mounted. Structural rather
* than nominal for the same reason the token installer's root type is. */
export interface EkklesiaUiStyleRoot {
querySelector(selectors: string): Element | null;
appendChild<Node_ extends Node>(node: Node_): Node_;
readonly ownerDocument?: Document | null;
}
function documentFor(root: EkklesiaUiStyleRoot): Document | undefined {
if (typeof (root as Partial<Document>).createElement === "function") {
return root as Document;
}
return root.ownerDocument ?? undefined;
}
/**
* Installs the element stylesheet into one root, once, and returns a remover for exactly what it
* inserted.
*
* The React equivalent is `<EkklesiaUiStyles />`, which is what an artifact renders so its styles
* land inside its own isolated root rather than in the host's document. This imperative form exists
* for a host that owns a document directly.
*/
export function installEkklesiaUiStyles(root: EkklesiaUiStyleRoot): () => void {
if (root.querySelector(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`)) {
return () => undefined;
}
const ownerDocument = documentFor(root);
if (!ownerDocument) {
return () => undefined;
}
const style = ownerDocument.createElement("style");
style.setAttribute(EKKLESIA_UI_STYLE_ATTRIBUTE, "");
style.textContent = EKKLESIA_UI_STYLESHEET;
const target =
(ownerDocument === (root as unknown as Document) ? ownerDocument.head : undefined) ?? root;
target.appendChild(style);
let removed = false;
return () => {
if (removed) {
return;
}
removed = true;
style.remove();
};
}
+211
View File
@@ -0,0 +1,211 @@
import { type ReactElement, type ReactNode, useEffect, useId, useRef } from "react";
import { classNames } from "./class-names.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
export interface PanelProps {
readonly title?: ReactNode;
readonly description?: ReactNode;
/** Controls rendered opposite the title — a primary action, a filter, a status badge. */
readonly actions?: ReactNode;
readonly raised?: boolean;
readonly className?: string;
readonly children?: ReactNode;
}
export function Panel(props: PanelProps): ReactElement {
const hasHeader = props.title !== undefined || props.actions !== undefined;
return (
<section
className={classNames(
"ek-panel",
props.raised === true && "ek-panel--raised",
props.className,
)}
>
{hasHeader ? (
<header className="ek-panel__header">
<div>
{props.title === undefined ? undefined : (
<h2 className="ek-panel__title">{props.title}</h2>
)}
{props.description === undefined ? undefined : (
<p className="ek-panel__description">{props.description}</p>
)}
</div>
{props.actions}
</header>
) : undefined}
{props.children}
</section>
);
}
export interface DialogProps {
readonly open: boolean;
readonly title: ReactNode;
readonly onDismiss: () => void;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
}
/**
* A modal dialog rendered in place rather than portalled to `document.body`.
*
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
if (!open) {
return null;
}
return (
<div className="ek-dialog__scrim">
<div
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
)}
</div>
</div>
);
}
export interface TabDefinition {
readonly id: string;
readonly label: ReactNode;
readonly content: ReactNode;
}
export interface TabsProps {
readonly tabs: readonly TabDefinition[];
readonly selectedId: string;
readonly onSelect: (id: string) => void;
/** Names the tab strip for assistive technology. */
readonly label: string;
readonly className?: string;
}
/**
* A controlled tab strip with the WAI-ARIA keyboard behaviour: arrow keys move between tabs, `Home`
* and `End` jump to the ends, and only the selected tab is in the tab order.
*
* The panel itself carries `tabIndex={0}`, which the linter's generic "non-interactive elements do
* not take a tab stop" rule objects to. The WAI-ARIA Authoring Practices require it: without it a
* keyboard user cannot reach or scroll panel content that contains no focusable element of its own.
* Removing it would be an accessibility regression, not a fix, so the rule is suppressed at that one
* attribute rather than the behaviour being changed.
*/
export function Tabs(props: TabsProps): ReactElement {
const baseId = useId();
const selected = props.tabs.find((tab) => tab.id === props.selectedId) ?? props.tabs[0];
function move(offset: number): void {
const index = props.tabs.findIndex((tab) => tab.id === selected?.id);
if (index < 0 || props.tabs.length === 0) {
return;
}
const next = props.tabs[(index + offset + props.tabs.length) % props.tabs.length];
if (next) {
props.onSelect(next.id);
}
}
return (
<div className={classNames(props.className)}>
<div aria-label={props.label} className="ek-tabs__list" role="tablist">
{props.tabs.map((tab) => {
const isSelected = tab.id === selected?.id;
return (
<button
aria-controls={`${baseId}-${tab.id}-panel`}
aria-selected={isSelected}
className="ek-tab ek-focusable"
id={`${baseId}-${tab.id}-tab`}
key={tab.id}
onClick={() => props.onSelect(tab.id)}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(-1);
} else if (event.key === "Home") {
event.preventDefault();
const first = props.tabs[0];
if (first) {
props.onSelect(first.id);
}
} else if (event.key === "End") {
event.preventDefault();
const last = props.tabs[props.tabs.length - 1];
if (last) {
props.onSelect(last.id);
}
}
}}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
{tab.label}
</button>
);
})}
</div>
{selected === undefined ? undefined : (
<div
aria-labelledby={`${baseId}-${selected.id}-tab`}
className="ek-tabs__panel"
id={`${baseId}-${selected.id}-panel`}
role="tabpanel"
// biome-ignore lint/a11y/noNoninteractiveTabindex: a tab panel is required to be focusable (see above).
tabIndex={0}
>
{selected.content}
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtureDirectory = path.join(packageDirectory, "tests", "browser-boundary");
const typescript = path.join(packageDirectory, "node_modules", ".bin", "tsc");
async function typecheck(project: string): Promise<{ code: number; output: string }> {
try {
await run(typescript, ["--noEmit", "--project", path.join(fixtureDirectory, project)]);
return { code: 0, output: "" };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return { code: failure.code ?? 1, output: `${failure.stdout ?? ""}${failure.stderr ?? ""}` };
}
}
/**
* The design packages are consumed by module UI, so they inherit ADR 0024's browser boundary
* exactly: importing them must not make Node's global declarations visible to browser-only code.
*
* Tested the way a module author experiences it — a real compile under the exact project a module
* uses for UI source (`browser-react.json`, `"types": []`) — rather than by inspecting the
* dependency graph, because the leak ADR 0024 found was a *type graph* leak that no dependency list
* would have shown.
*/
describe("design package browser boundary", () => {
it("compiles supported module UI source that imports both design packages", async () => {
const result = await typecheck("tsconfig.supported.json");
expect(result.output).toBe("");
expect(result.code).toBe(0);
}, 60_000);
it("refuses Node globals in UI source that imports the design packages", async () => {
const result = await typecheck("tsconfig.node-global.json");
expect(result.code).not.toBe(0);
expect(result.output).toContain("Cannot find name 'process'");
expect(result.output).toContain("node-global.tsx");
}, 60_000);
});
@@ -0,0 +1,9 @@
// The negative case. Importing the approved design packages must NOT make Node globals visible. If
// this file ever typechecks, the browser boundary has regressed and `browser-boundary.test.ts`
// fails.
import { designToken } from "@ekklesia/design-tokens";
import { Button } from "../../src/index.js";
export const element = Button;
export const color = designToken("text.primary");
export const leaked: string | undefined = process.env.NODE_ENV;
@@ -0,0 +1,21 @@
// A module author's supported UI source: it imports both approved design packages and must
// typecheck cleanly under the exact browser project a module uses for its own UI
// (`browser-react.json`, `"types": []`).
import { designToken, ekklesiaDesignTokens } from "@ekklesia/design-tokens";
import type { ReactElement } from "react";
import { Badge, Button, EkklesiaUiStyles, Panel } from "../../src/index.js";
export function ModuleScreen(): ReactElement {
return (
<>
<EkklesiaUiStyles />
<Panel title="Slides">
<p style={{ color: designToken("text.secondary") }}>
{ekklesiaDesignTokens.list().length} tokens available.
</p>
<Badge tone="success">Ready</Badge>
<Button variant="primary">Add</Button>
</Panel>
</>
);
}
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["node-global.tsx"]
}
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["supported.tsx"]
}
+258
View File
@@ -0,0 +1,258 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
Badge,
Button,
Checkbox,
Dialog,
EkklesiaUiStyles,
EmptyState,
ErrorState,
Field,
IconButton,
List,
ListItem,
LoadingState,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextInput,
Toast,
ToastStack,
} from "../src/index.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(node: React.ReactNode): void {
act(() => root.render(node));
}
describe("Ekklesia elements", () => {
it("renders a button that does not submit a form by accident", () => {
render(<Button variant="primary">Save</Button>);
const button = container.querySelector("button");
expect(button?.getAttribute("type")).toBe("button");
expect(button?.className).toContain("ek-button--primary");
expect(button?.className).toContain("ek-focusable");
});
it("requires an accessible name on an icon-only button", () => {
render(<IconButton label="Remove slide">×</IconButton>);
expect(container.querySelector("button")?.getAttribute("aria-label")).toBe("Remove slide");
});
it("associates a field's label, hint, and error with its control", () => {
render(
<Field error="Enter a name." hint="Shown on the display." label="Name">
{(control) => <TextInput {...control} />}
</Field>,
);
const label = container.querySelector("label");
const input = container.querySelector("input");
const error = container.querySelector(".ek-field__error");
expect(input?.id).toBeTruthy();
expect(label?.getAttribute("for")).toBe(input?.id);
expect(input?.getAttribute("aria-invalid")).toBe("true");
expect(input?.getAttribute("aria-describedby")?.split(" ")).toContain(error?.id);
expect(error?.getAttribute("role")).toBe("alert");
});
it("renders a select and a labelled checkbox as real form controls", () => {
render(
<>
<Select aria-label="Mode">
<option value="a">A</option>
</Select>
<Checkbox label="Enabled" />
</>,
);
expect(container.querySelector("select")?.className).toContain("ek-control");
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
expect(container.querySelector(".ek-checkbox")?.textContent).toContain("Enabled");
});
it("renders a panel with an accessible heading and its actions", () => {
render(
<Panel actions={<Button>New</Button>} description="What this panel is." title="Slides">
<p>Body</p>
</Panel>,
);
expect(container.querySelector("h2")?.textContent).toBe("Slides");
expect(container.querySelector(".ek-panel__description")?.textContent).toBe(
"What this panel is.",
);
expect(container.querySelector(".ek-panel__header button")?.textContent).toBe("New");
});
it("renders a dialog in place, labels it, and dismisses it on Escape", () => {
const onDismiss = vi.fn();
render(
<Dialog onDismiss={onDismiss} open title="Delete slide">
Are you sure?
</Dialog>,
);
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${dialog?.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete slide");
// Rendered inside the caller's own subtree, never portalled to document.body: an artifact's
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("renders nothing for a closed dialog", () => {
render(
<Dialog onDismiss={() => undefined} open={false} title="Hidden">
Body
</Dialog>,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("exposes tabs with ARIA roles and arrow-key movement", () => {
const onSelect = vi.fn();
const tabs = [
{ content: <p>One</p>, id: "one", label: "One" },
{ content: <p>Two</p>, id: "two", label: "Two" },
];
render(<Tabs label="Sections" onSelect={onSelect} selectedId="one" tabs={tabs} />);
const tabButtons = [...container.querySelectorAll('[role="tab"]')];
expect(tabButtons.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false"]);
expect(tabButtons.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1"]);
expect(container.querySelector('[role="tabpanel"]')?.textContent).toBe("One");
act(() => {
tabButtons[0]?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
);
});
expect(onSelect).toHaveBeenCalledWith("two");
});
it("renders table and list primitives with header semantics", () => {
render(
<>
<Table label="Slides">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow selected>
<TableCell>Welcome</TableCell>
</TableRow>
</TableBody>
</Table>
<List label="Recent">
<ListItem>First</ListItem>
</List>
</>,
);
expect(container.querySelector("table")?.getAttribute("aria-label")).toBe("Slides");
expect(container.querySelector("th")?.getAttribute("scope")).toBe("col");
expect(container.querySelector("tbody tr")?.getAttribute("aria-selected")).toBe("true");
expect(container.querySelector(".ek-list__item")?.textContent).toBe("First");
});
it("never signals status with colour alone", () => {
render(
<>
<Badge tone="success">Running</Badge>
<ToastStack>
<Toast title="Saved" tone="success">
Everything went through.
</Toast>
<Toast title="Failed" tone="danger" />
</ToastStack>
</>,
);
expect(container.querySelector(".ek-badge__symbol")?.textContent).toBe("✓");
expect(container.querySelector(".ek-badge__symbol")?.getAttribute("aria-hidden")).toBe("true");
const toasts = [...container.querySelectorAll(".ek-toast")];
expect(toasts[0]?.textContent).toContain("✓");
expect(toasts[0]?.getAttribute("aria-live")).toBe("polite");
expect(toasts[1]?.textContent).toContain("✕");
expect(toasts[1]?.getAttribute("aria-live")).toBe("assertive");
});
it("renders empty, error, and loading states with the right announcements", () => {
render(
<>
<EmptyState description="Add one to begin." title="No slides yet" />
<ErrorState description="Try again." title="This could not load" />
<LoadingState label="Loading slides…" />
</>,
);
expect(container.querySelector(".ek-message")?.textContent).toContain("No slides yet");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("This could not load");
const loading = container.querySelector('[role="status"]');
expect(loading?.getAttribute("aria-live")).toBe("polite");
expect(loading?.textContent).toContain("Loading slides…");
expect(container.querySelector(".ek-spinner")?.getAttribute("aria-hidden")).toBe("true");
});
it("puts its stylesheet in whichever root it is rendered into", () => {
const host = document.createElement("div");
container.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const shadowRoot = createRoot(shadow);
act(() =>
shadowRoot.render(
<>
<EkklesiaUiStyles />
<Button>Inside</Button>
</>,
),
);
expect(shadow.querySelector("style[data-ekklesia-ui]")?.textContent).toContain(".ek-button");
// The host document never receives the artifact's copy.
expect(document.head.querySelector("style[data-ekklesia-ui]")).toBeNull();
act(() => shadowRoot.unmount());
});
});
+84
View File
@@ -0,0 +1,84 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ekklesiaDesignTokens } from "@ekklesia/design-tokens";
import { describe, expect, it } from "vitest";
import {
EKKLESIA_UI_STYLE_ATTRIBUTE,
EKKLESIA_UI_STYLESHEET,
installEkklesiaUiStyles,
} from "../src/index.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function sourceFiles(): Promise<readonly string[]> {
const directory = path.join(packageDirectory, "src");
return (await readdir(directory))
.filter((entry) => entry.endsWith(".ts") || entry.endsWith(".tsx"))
.map((entry) => path.join(directory, entry));
}
/** Any literal that would be a theme decision rather than a layout one. */
const THEME_LITERAL_PATTERN =
/#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\boklch\s*\(|\bsystem-ui\b|\bsans-serif\b/giu;
describe("the shared elements consume design tokens, never hardcoded theme values", () => {
it("expresses every theme value in the stylesheet as a token reference", () => {
expect(EKKLESIA_UI_STYLESHEET.match(THEME_LITERAL_PATTERN)).toBeNull();
expect(EKKLESIA_UI_STYLESHEET).toContain("var(--ekklesia-");
});
it("references only registered tokens", () => {
const referenced = [...EKKLESIA_UI_STYLESHEET.matchAll(/var\((--ekklesia-[a-z0-9-]+)\)/gu)].map(
([, variable]) => String(variable),
);
const registered = new Set(ekklesiaDesignTokens.list().map((token) => token.cssVariable));
expect(referenced.length).toBeGreaterThan(0);
expect([...new Set(referenced)].filter((variable) => !registered.has(variable))).toEqual([]);
});
it("never defines a global Ekklesia theme variable of its own", () => {
// Referencing `--ekklesia-*` is the contract; *declaring* one would invent a global theme
// variable outside the registry, which is precisely what the design-token contract forbids.
expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
});
it("hardcodes no theme value in component source either", async () => {
for (const file of await sourceFiles()) {
if (path.basename(file) === "styles.ts") {
continue;
}
expect(await readFile(file, "utf8")).not.toMatch(THEME_LITERAL_PATTERN);
}
});
it("never names a Node built-in or reads the environment", async () => {
for (const file of await sourceFiles()) {
const source = await readFile(file, "utf8");
expect(source).not.toContain('"node:');
expect(source).not.toContain("process.env");
}
});
it("depends only on the design tokens and React", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
});
it("installs its stylesheet into a document root exactly once", () => {
const remove = installEkklesiaUiStyles(document);
installEkklesiaUiStyles(document);
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(1);
remove();
remove();
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts", "tests/**/*.tsx"],
"exclude": ["tests/browser-boundary"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
passWithNoTests: false,
},
});
+64
View File
@@ -169,6 +169,27 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/design-tokens:
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: 24.13.3
version: 24.13.3
happy-dom:
specifier: 20.0.11
version: 20.0.11
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-sdk:
dependencies:
'@ekklesia/contracts':
@@ -255,14 +276,57 @@ importers:
packages/typescript-config: {}
packages/ui:
dependencies:
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../design-tokens
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: 24.13.3
version: 24.13.3
'@types/react':
specifier: 19.2.18
version: 19.2.18
'@types/react-dom':
specifier: 19.2.4
version: 19.2.4(@types/[email protected])
happy-dom:
specifier: 20.0.11
version: 20.0.11
react:
specifier: 19.2.8
version: 19.2.8
react-dom:
specifier: 19.2.8
version: 19.2.8([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
surfaces/shell:
dependencies:
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../../packages/design-tokens
'@ekklesia/module-ui-host':
specifier: workspace:*
version: link:../../packages/module-ui-host
'@ekklesia/module-ui-sdk':
specifier: workspace:*
version: link:../../packages/module-ui-sdk
'@ekklesia/ui':
specifier: workspace:*
version: link:../../packages/ui
react:
specifier: 19.2.8
version: 19.2.8
+36 -1
View File
@@ -220,9 +220,24 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (to.layer === "module" && from.owner !== to.owner) {
return "modules-do-not-import-other-modules";
}
// The approved shared roles a module may reach, and nothing else. `design-tokens` and `ui` are
// named individually rather than by opening "browser-safe shared packages" generally: a module
// may consume Ekklesia's global semantic tokens and foundational elements (ADR 0039) precisely
// so two modules' screens look like one product, but it still may not reach `api-client`,
// `auth-client`, `surface-sdk`, `module-ui-host`, or any other shared role. Both approved
// packages are browser-safe by construction — neither has a backend dependency — so this adds
// no path from module UI to Node's globals.
if (
to.layer === "shared" &&
!["contracts", "module-sdk", "module-ui-sdk", "testing", "tooling"].includes(to.role ?? "")
![
"contracts",
"design-tokens",
"module-sdk",
"module-ui-sdk",
"testing",
"tooling",
"ui",
].includes(to.role ?? "")
) {
return "modules-use-public-platform-contracts";
}
@@ -287,6 +302,14 @@ function boundaryRule(from: Location, to: Location): string | undefined {
return "sdks-do-not-depend-on-implementations";
}
if (
from.layer === "shared" &&
["design-tokens", "ui"].includes(from.role ?? "") &&
["app", "builder", "core", "module", "surface"].includes(to.layer)
) {
return "design-packages-depend-downward-only";
}
if (from.layer === "shared" && to.layer === "shared") {
if (from.role === "contracts" && to.role !== "contracts") {
return "contracts-do-not-depend-upward";
@@ -307,6 +330,18 @@ function boundaryRule(from: Location, to: Location): string | undefined {
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
return "sdks-do-not-depend-on-implementations";
}
// The design contract is the bottom of the presentation stack: it depends on nothing, exactly
// like `module-ui-sdk`, so importing it can never drag another package's type graph into a
// browser program. The shared elements sit one layer above it and may reach it and nothing
// else — a shared element package that could reach an API client, an auth client, or the
// backend contracts barrel would put all three inside every module artifact that renders a
// button (ADR 0039).
if (from.role === "design-tokens" && to.role !== "tooling") {
return "design-packages-depend-downward-only";
}
if (from.role === "ui" && !["design-tokens", "tooling"].includes(to.role ?? "")) {
return "design-packages-depend-downward-only";
}
}
return undefined;
+2
View File
@@ -24,8 +24,10 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/ui": "workspace:*",
"react": "19.2.8",
"react-dom": "19.2.8"
},
+40 -65
View File
@@ -1,4 +1,6 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
HostAuthError,
@@ -103,51 +105,53 @@ export interface HostAppProps {
readonly importModule?: ModuleUiImporter;
}
/**
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
* colours (ADR 0039). Nothing here is a brand decision any more: changing
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
* artifact together, because custom properties inherit through the artifacts' isolated roots.
*/
const shellStyles = {
action: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
minWidth: "13rem",
padding: designToken("spacing.lg"),
},
actions: { display: "flex", gap: "0.5rem", justifyContent: "center" },
aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" },
groupLabel: {
color: "#78716c",
fontSize: "0.75rem",
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
letterSpacing: "0.05em",
margin: "0 0 0.25rem",
margin: `0 0 ${designToken("spacing.xs")}`,
textTransform: "uppercase",
},
header: {
alignItems: "center",
borderBottom: "1px solid #e7e5e4",
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: 600,
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
padding: "0.75rem 1rem",
padding: `${designToken("spacing.md")} ${designToken("spacing.lg")}`,
},
heading: { font: "inherit", margin: 0 },
layout: {
color: "#1c1917",
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: "system-ui, sans-serif",
fontFamily: designToken("typography.family-sans"),
gridTemplateColumns: "auto 1fr",
minHeight: "100vh",
},
main: { padding: "1rem" },
main: { padding: designToken("spacing.lg") },
message: {
color: "#57534e",
color: designToken("text.secondary"),
display: "grid",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
fontFamily: designToken("typography.family-sans"),
gap: designToken("spacing.md"),
justifyItems: "center",
margin: 0,
padding: "1rem",
padding: designToken("spacing.lg"),
textAlign: "center",
},
} as const;
@@ -170,24 +174,16 @@ function ModuleButton(props: {
readonly onSelect: () => void;
}): ReactElement {
return (
<button
<Button
aria-current={props.selected ? "page" : undefined}
aria-pressed={props.selected}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{
background: props.selected ? "#e7e5e4" : "transparent",
border: "none",
borderRadius: "0.25rem",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.5rem",
textAlign: "left",
width: "100%",
}}
type="button"
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
>
{props.entry.name}
</button>
</Button>
);
}
@@ -491,14 +487,7 @@ export function HostApp(props: HostAppProps): ReactElement {
}
if (state.status === "failed") {
return (
<HostMessage
actions={
<button onClick={retryBootstrap} style={shellStyles.action} type="button">
Retry
</button>
}
testId="host-error"
>
<HostMessage actions={<Button onClick={retryBootstrap}>Retry</Button>} testId="host-error">
Ekklesia could not be reached.
</HostMessage>
);
@@ -511,12 +500,8 @@ export function HostApp(props: HostAppProps): ReactElement {
<HostMessage
actions={
<div style={shellStyles.actions}>
<button onClick={() => void signOut()} style={shellStyles.action} type="button">
Retry sign out
</button>
<button onClick={retryBootstrap} style={shellStyles.action} type="button">
Return to Ekklesia
</button>
<Button onClick={() => void signOut()}>Retry sign out</Button>
<Button onClick={retryBootstrap}>Return to Ekklesia</Button>
</div>
}
testId="host-sign-out-error"
@@ -529,14 +514,9 @@ export function HostApp(props: HostAppProps): ReactElement {
return (
<HostMessage
actions={
<button
data-testid="host-logout"
onClick={() => void signOut()}
style={shellStyles.action}
type="button"
>
<Button data-testid="host-logout" onClick={() => void signOut()}>
Sign out
</button>
</Button>
}
testId="host-access-denied"
>
@@ -554,19 +534,14 @@ export function HostApp(props: HostAppProps): ReactElement {
<div style={shellStyles.layout}>
<header style={shellStyles.header}>
<h1 style={shellStyles.heading}>Ekklesia</h1>
<button
data-testid="host-logout"
onClick={() => void signOut()}
style={shellStyles.action}
type="button"
>
<Button data-testid="host-logout" onClick={() => void signOut()}>
Sign out
</button>
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
+21 -1
View File
@@ -1,3 +1,5 @@
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js";
@@ -60,6 +62,24 @@ if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
}
/**
* The host root installs the platform's default theme once, at the document (ADR 0039).
*
* This is what makes the tokens available to *everything* on the page, including each module UI
* artifact mounted inside its own isolated root: CSS custom properties inherit through a shadow
* boundary, so a module reads `var(--ekklesia-…)` without the host reaching into the artifact or the
* artifact importing the host. Only the values live here — the platform ships no theme editor, and
* an installation that wants different values overrides these declarations at its own root.
*/
installEkklesiaDesignTokens(document);
createRoot(container).render(
authLanding ? <HostApp authLanding={authLanding} config={config} /> : <HostApp config={config} />,
<>
<EkklesiaUiStyles />
{authLanding ? (
<HostApp authLanding={authLanding} config={config} />
) : (
<HostApp config={config} />
)}
</>,
);
+29 -20
View File
@@ -1,3 +1,5 @@
import { designToken, installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
@@ -21,25 +23,32 @@ try {
moduleId = undefined;
}
/** The display is its own host root, so it installs the same default theme the Dashboard does; a
* module's display artifact then inherits the tokens into its isolated root (ADR 0039). */
installEkklesiaDesignTokens(document);
createRoot(container).render(
moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: "#57534e",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
}}
>
This display is unavailable.
</div>
),
<>
<EkklesiaUiStyles />
{moduleId ? (
<DisplayApp config={config} moduleId={moduleId} />
) : (
<div
data-testid="display-route-invalid"
style={{
alignItems: "center",
color: designToken("text.secondary"),
display: "flex",
fontFamily: designToken("typography.family-sans"),
height: "100%",
justifyContent: "center",
minHeight: "100dvh",
padding: designToken("spacing.lg"),
textAlign: "center",
}}
>
This display is unavailable.
</div>
)}
</>,
);
+11 -1
View File
@@ -22,7 +22,17 @@ const sourceDirectory = path.join(packageDirectory, "src");
/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic
* loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package
* — and no module — may appear. */
const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]);
/**
* The complete set of Ekklesia packages the shell may reach: the two browser contracts it mounts
* modules with, and the two design packages every Ekklesia screen is built from (ADR 0039). All
* four are browser-safe by construction and none of them names a module.
*/
const ALLOWED_EKKLESIA_IMPORTS = new Set([
"@ekklesia/design-tokens",
"@ekklesia/module-ui-host",
"@ekklesia/module-ui-sdk",
"@ekklesia/ui",
]);
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
+36 -1
View File
@@ -52,7 +52,8 @@ describe("repository dependency boundaries", () => {
["module-ui-imports-core", "modules-use-public-platform-contracts"],
["module-ui-bundler-plugin", "modules-do-not-own-ui"],
["module-imports-ui-host", "modules-use-public-platform-contracts"],
["module-imports-ui-package", "modules-use-public-platform-contracts"],
["module-imports-surface-sdk", "modules-use-public-platform-contracts"],
["design-package-depends-upward", "design-packages-depend-downward-only"],
["cross-package-relative", "workspace-imports-use-public-exports"],
["cycle", "no-circular-source-dependencies"],
])("rejects %s", async (fixture, expectedRule) => {
@@ -123,6 +124,40 @@ describe("repository dependency boundaries", () => {
expect(result.violations).toEqual([]);
});
it("accepts a module consuming the approved design packages", async () => {
// ADR 0039 opens exactly two shared roles to module UI — `design-tokens` and `ui` — so two
// modules' screens can look like one product. Both are browser-safe by construction, so this
// adds no path from module UI to a backend type graph.
const result = await checkBoundaries(path.join(fixtures, "module-uses-design-packages"));
expect(result.violations).toEqual([]);
});
it("keeps every other shared role out of module packages", async () => {
// The relaxation above is a named allowlist, not "browser-safe shared packages are fine": a
// surface SDK, an API client, and an auth client all stay rejected.
const result = await checkBoundaries(path.join(fixtures, "module-imports-surface-sdk"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"modules-use-public-platform-contracts",
"modules-use-public-platform-contracts",
]);
});
it("keeps the design packages at the bottom of the presentation stack", async () => {
// Neither may reach an API client, an auth client, the contracts barrel, or an implementation
// layer: a design package that could would put all of it inside every module artifact that
// renders a button.
const result = await checkBoundaries(path.join(fixtures, "design-package-depends-upward"));
expect(result.violations.map((violation) => violation.rule)).toEqual([
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
"design-packages-depend-downward-only",
]);
});
it("keeps the host loader out of module packages", async () => {
// `@ekklesia/module-ui-host` is host infrastructure. A module that depended on it would bundle
// loader code into its own artifact and blur the author/host split (ADR 0024), so the
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/api-client",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "api-client"
}
}
@@ -0,0 +1 @@
export const client = "client";
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/api-client": "workspace:*"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "design-tokens"
}
}
@@ -0,0 +1,3 @@
import { client } from "@ekklesia/api-client";
export const invalidDesignTokenDependency = client;
@@ -2,6 +2,9 @@
"name": "@ekklesia/ui",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/api-client": "workspace:*"
},
"exports": {
".": "./src/index.ts"
},
@@ -0,0 +1,3 @@
import { client } from "@ekklesia/api-client";
export const invalidUiDependency = client;
@@ -3,7 +3,7 @@
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/ui": "workspace:*"
"@ekklesia/surface-sdk": "workspace:*"
},
"exports": {
".": "./src/index.ts"
@@ -0,0 +1,3 @@
import { renderer } from "@ekklesia/surface-sdk";
export const invalidModuleSurfaceDependency = renderer;
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/surface-sdk",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "surface-sdk"
}
}
@@ -0,0 +1 @@
export const renderer = "renderer";
@@ -1,3 +0,0 @@
import { button } from "@ekklesia/ui";
export const invalidModuleUiDependency = button;
@@ -1 +0,0 @@
export const button = "button";
@@ -0,0 +1,13 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/ui": "workspace:*",
"react": "19.2.8"
},
"exports": {
".": "./src/index.ts"
}
}
@@ -0,0 +1 @@
export const calendar = "calendar";
@@ -0,0 +1,5 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button } from "@ekklesia/ui";
export const accent = designToken("accent.base");
export const action = Button;
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/design-tokens",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "design-tokens"
}
}
@@ -0,0 +1,3 @@
export function designToken(name: string): string {
return `var(--ekklesia-${name})`;
}
@@ -0,0 +1,14 @@
{
"name": "@ekklesia/ui",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@ekklesia/design-tokens": "workspace:*"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "ui"
}
}
@@ -0,0 +1,3 @@
import { designToken } from "@ekklesia/design-tokens";
export const Button = designToken("accent.base");