feat: give every Ekklesia screen one set of design tokens and elements
Modules have owned their own UI since ADR 0021, but the platform has never said what that UI should look like. Every screen here — the Dashboard shell, the display host, the module template — invented its own hex colours, font stack, spacing, and radii, and they already disagreed. A module author had no vocabulary for "the ordinary panel background", so the only available answer was to invent one, and two modules would invent two. Add `@ekklesia/design-tokens`: one validated, enumerable registry of semantic tokens. A token declares a name, label, description, and default value; its category and `--ekklesia-*` identifier are derived from the name, so it can never claim one category while being named for another. Construction rejects duplicates, malformed names, unknown categories, empty or overlong text, and any value that is not a bounded literal — no declaration terminator, rule block, comment, at-rule, `url(`, `!important`, escape, control character, or markup. A registry that constructs can therefore be emitted as CSS without escaping, because nothing needing escaping can be in it. That validation is also what makes a future theme editor implementable rather than aspirational. Add `@ekklesia/ui`: button, icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and list primitives, badge, toast, and empty/error/loading states — every value a token reference, with real `:focus-visible` treatment and status that never depends on colour alone. Deliberately small: an element earns its place when two consumers need it. The stylesheet is a string rather than a `.css` file so the identical bytes install into a document or an isolated artifact root, and so hover, disabled, and focus-visible are real CSS rather than JavaScript approximations of them. `pnpm boundaries` opens exactly two shared roles to modules, by name — every other role stays rejected — and pins both design packages to the bottom of the presentation stack. Both are browser-safe by construction, proven by a real compile of module-shaped source in which `process.env` must fail to typecheck. The shell installs the defaults at its document root for the Dashboard and the display; custom properties inherit into module artifacts, so a module reads `var(--ekklesia-…)` with nothing to import at run time and nothing for a host to forget to pass. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b14613decd
commit
e03d38fcab
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user