Phase 8/module platform prerequisites #40
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./install.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./token.js";
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+64
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ekklesia/api-client",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "api-client"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const client = "client";
|
||||
Vendored
+14
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import { client } from "@ekklesia/api-client";
|
||||
|
||||
export const invalidDesignTokenDependency = client;
|
||||
+3
@@ -2,6 +2,9 @@
|
||||
"name": "@ekklesia/ui",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ekklesia/api-client": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { client } from "@ekklesia/api-client";
|
||||
|
||||
export const invalidUiDependency = client;
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ekklesia/ui": "workspace:*"
|
||||
"@ekklesia/surface-sdk": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { renderer } from "@ekklesia/surface-sdk";
|
||||
|
||||
export const invalidModuleSurfaceDependency = renderer;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ekklesia/surface-sdk",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "surface-sdk"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const renderer = "renderer";
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
import { button } from "@ekklesia/ui";
|
||||
|
||||
export const invalidModuleUiDependency = button;
|
||||
@@ -1 +0,0 @@
|
||||
export const button = "button";
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const calendar = "calendar";
|
||||
+5
@@ -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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ekklesia/design-tokens",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "design-tokens"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function designToken(name: string): string {
|
||||
return `var(--ekklesia-${name})`;
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
|
||||
export const Button = designToken("accent.base");
|
||||
Reference in New Issue
Block a user