CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
The theme foundation DESIGN.md requires (ADR 0060): - A design token may carry a `dark` value and a `reduced` value. The token stylesheet selects them in CSS alone: dark under prefers-color-scheme unless the host chose light, or when it chose dark; motion durations zero under prefers-reduced-motion unless it allowed motion, or when it chose reduce. `color-scheme` travels with the tokens. System is live, an explicit choice wins, and every module root follows through custom-property inheritance, with no module contract change. - @ekklesia/design-tokens adds a small appearance API: system/light/dark and system/reduce/allow, parsers for untrusted stored values, resolvers, and a controller that applies the attributes immediately. The shell applies it before React renders. That is the hook the account preferences work will feed; nothing is persisted. The Dashboard page declares `color-scheme: light dark` so a dark system never flashes white. Displays stay light. - A designed dark theme: teal-tinted charcoal, never pure black or white, the same teal accent. - WCAG AA contrast is tested for 41 element pairings in both schemes. Two light pairings failed and are fixed visibly: text.muted darkens, and form controls get a new border.control (3:1). background.placeholder keeps skeletons visible in dark. - Motion tokens (duration-short, duration-ambient) replace literal durations and every per-element reduced-motion rule. radius.scale multiplies sm/md/lg, unchanged at its default of 1. - The sign-in card and module loading/error text move from literal colours to tokens. Guards keep raw colours and literal radii out of Dashboard product source and @ekklesia/ui. - Fixes ConfirmationDialog's phrase input, which used a class that does not exist (`ek-input`) and so was unstyled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
161 lines
9.9 KiB
Markdown
161 lines
9.9 KiB
Markdown
# 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
|
|
|
|
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
|
|
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
|
|
> utility. The rest of this list is unchanged.
|
|
|
|
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.
|