Files
Ekklesia/packages/ui/src/styles.ts
T
ekklesiaandClaude Opus 5.5 d42b64a093
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
feat(theme): Light/Dark/System, reduced motion, and one radius scale
The theme foundation DESIGN.md requires (ADR 0060):

- A design token may carry a `dark` value and a `reduced` value. The
  token stylesheet selects them in CSS alone: dark under
  prefers-color-scheme unless the host chose light, or when it chose
  dark; motion durations zero under prefers-reduced-motion unless it
  allowed motion, or when it chose reduce. `color-scheme` travels with
  the tokens. System is live, an explicit choice wins, and every module
  root follows through custom-property inheritance, with no module
  contract change.
- @ekklesia/design-tokens adds a small appearance API: system/light/dark
  and system/reduce/allow, parsers for untrusted stored values,
  resolvers, and a controller that applies the attributes immediately.
  The shell applies it before React renders. That is the hook the
  account preferences work will feed; nothing is persisted. The
  Dashboard page declares `color-scheme: light dark` so a dark system
  never flashes white. Displays stay light.
- A designed dark theme: teal-tinted charcoal, never pure black or
  white, the same teal accent.
- WCAG AA contrast is tested for 41 element pairings in both schemes.
  Two light pairings failed and are fixed visibly: text.muted darkens,
  and form controls get a new border.control (3:1).
  background.placeholder keeps skeletons visible in dark.
- Motion tokens (duration-short, duration-ambient) replace literal
  durations and every per-element reduced-motion rule. radius.scale
  multiplies sm/md/lg, unchanged at its default of 1.
- The sign-in card and module loading/error text move from literal
  colours to tokens. Guards keep raw colours and literal radii out of
  Dashboard product source and @ekklesia/ui.
- Fixes ConfirmationDialog's phrase input, which used a class that
  does not exist (`ek-input`) and so was unstyled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:00:39 -05:00

827 lines
22 KiB
TypeScript

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")};
box-sizing: border-box;
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.hover")};
}
.ek-button--primary:active:not(:disabled) {
background: ${designToken("accent.active")};
}
.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.surface")};
border: ${designToken("border.width")} solid ${designToken("border.control")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
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")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.lg")};
padding: ${designToken("spacing.xl")};
}
.ek-panel--raised {
background: ${designToken("background.raised")};
box-shadow: ${designToken("shadow.md")};
}
.ek-panel__header {
align-items: flex-start;
display: flex;
gap: ${designToken("spacing.md")};
justify-content: space-between;
}
.ek-panel__heading {
display: grid;
gap: ${designToken("spacing.xs")};
}
.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.sm")};
}
.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")};
border-radius: ${designToken("radius.md")} ${designToken("radius.md")} 0 0;
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
}
.ek-tab:hover {
background: ${designToken("state.hover-background")};
color: ${designToken("text.primary")};
}
.ek-tab[aria-selected="true"] {
background: ${designToken("accent.subtle")};
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 {
background: ${designToken("background.canvas")};
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-page-header {
align-items: flex-end;
display: flex;
gap: ${designToken("spacing.xl")};
justify-content: space-between;
}
.ek-page-header__copy {
display: grid;
gap: ${designToken("spacing.xs")};
}
.ek-page-header__eyebrow {
color: ${designToken("accent.strong")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-bold")};
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ek-page-header__title {
font-size: ${designToken("typography.size-heading-1")};
font-weight: ${designToken("typography.weight-bold")};
line-height: ${designToken("typography.line-height-tight")};
margin: 0;
}
.ek-page-header__description {
color: ${designToken("text.secondary")};
margin: 0;
max-width: 64ch;
}
.ek-page-header__actions,
.ek-action-bar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-notice {
align-items: flex-start;
background: ${designToken("background.inset")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.secondary")};
display: flex;
font-size: ${designToken("typography.size-sm")};
gap: ${designToken("spacing.sm")};
padding: ${designToken("spacing.md")};
}
.ek-notice--info {
background: ${designToken("status.info-background")};
border-color: ${designToken("status.info-border")};
}
.ek-notice--success {
background: ${designToken("status.success-background")};
border-color: ${designToken("status.success-border")};
}
.ek-notice--warning {
background: ${designToken("status.warning-background")};
border-color: ${designToken("status.warning-border")};
}
.ek-notice--danger {
background: ${designToken("status.danger-background")};
border-color: ${designToken("status.danger-border")};
color: ${designToken("text.danger")};
}
.ek-notice__symbol,
.ek-notice__title {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-notice__body {
display: grid;
gap: ${designToken("spacing.2xs")};
}
@media (max-width: 48rem) {
.ek-page-header,
.ek-panel__header {
align-items: stretch;
flex-direction: column;
}
}
.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 {
/* Spins at 0.625 of the ambient cycle (1s by default); not at all when motion is reduced. */
animation: ek-spin calc(${designToken("motion.duration-ambient")} * 0.625) 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")};
}
.ek-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-dialog__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-confirmation {
display: grid;
gap: ${designToken("spacing.lg")};
margin: 0;
}
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
.ek-confirmation--destructive {
border-color: ${designToken("status.danger-border")};
border-width: calc(${designToken("border.width")} * 2);
}
.ek-confirmation__symbol::before {
content: ${designToken("status.warning-symbol")};
font-weight: ${designToken("typography.weight-bold")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-confirmation__phrase {
font-family: ${designToken("typography.family-mono")};
white-space: pre-wrap;
}
.ek-toast-region {
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
left: 50%;
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
pointer-events: none;
position: fixed;
top: ${designToken("spacing.lg")};
transform: translateX(-50%);
width: 28rem;
z-index: 50;
}
.ek-toast-region__list {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-toast-region .ek-toast {
animation: ek-toast-enter ${designToken("motion.duration-short")} ease-out;
pointer-events: auto;
}
.ek-toast {
align-items: start;
display: flex;
gap: ${designToken("spacing.sm")};
}
.ek-toast__content {
display: grid;
flex: 1 1 auto;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-toast__dismiss {
flex: 0 0 auto;
min-width: ${designToken("control.height-sm")};
}
.ek-message__actions {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
justify-content: center;
}
.ek-message__details {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
max-width: 100%;
text-align: start;
width: 100%;
}
.ek-message__details summary {
cursor: pointer;
min-height: ${designToken("control.height-sm")};
padding-block: ${designToken("spacing.2xs")};
}
.ek-message__details-text {
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
margin: ${designToken("spacing.xs")} 0 0;
max-height: 16rem;
overflow: auto;
padding: ${designToken("spacing.sm")};
white-space: pre-wrap;
word-break: break-word;
}
.ek-message__reference {
margin: ${designToken("spacing.xs")} 0 0;
}
.ek-page-header__back {
justify-self: start;
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
text-decoration: none;
}
.ek-skeleton-group {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-skeleton {
animation: ek-skeleton-pulse ${designToken("motion.duration-ambient")} ease-in-out infinite;
background: ${designToken("background.placeholder")};
border-radius: ${designToken("radius.sm")};
display: block;
}
.ek-skeleton--text {
height: 1em;
width: 100%;
}
.ek-skeleton--block {
height: 6rem;
width: 100%;
}
.ek-skeleton--circle {
border-radius: ${designToken("radius.pill")};
height: 2.5rem;
width: 2.5rem;
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ek-skeleton-pulse {
50% {
opacity: 0.55;
}
}
@keyframes ek-toast-enter {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
}
/* Every animation above takes its duration from a motion token, which is zero when motion is
reduced — by the system, or by a person's own preference (ADR 0060) — so none of them needs a
reduced-motion rule of its own, and nothing here depends on movement to be understood. */
`;
/** 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();
};
}