feat: establish shared Ekklesia design system
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s

This commit is contained in:
ekklesia
2026-09-30 18:21:09 -05:00
parent 3ca6b2cb32
commit 3a26ef49ad
9 changed files with 336 additions and 110 deletions
+28
View File
@@ -50,6 +50,34 @@ export interface ToastProps {
readonly children?: ReactNode;
}
export interface InlineNoticeProps {
readonly tone?: EkklesiaStatusTone;
readonly title?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
}
/** A calm in-flow status or explanation that should not interrupt the current task. */
export function InlineNotice(props: InlineNoticeProps): ReactElement {
const tone = props.tone ?? "info";
return (
<div
className={classNames("ek-notice", `ek-notice--${tone}`, props.className)}
role={tone === "danger" ? "alert" : "status"}
>
<span aria-hidden="true" className="ek-notice__symbol">
{statusSymbols[tone]}
</span>
<div className="ek-notice__body">
{props.title === undefined ? undefined : (
<strong className="ek-notice__title">{props.title}</strong>
)}
{props.children}
</div>
</div>
);
}
/**
* One notification's presentation — and only its presentation.
*
+6
View File
@@ -52,6 +52,8 @@ export {
type EmptyStateProps,
ErrorState,
type ErrorStateProps,
InlineNotice,
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Toast,
@@ -66,10 +68,14 @@ export {
installEkklesiaUiStyles,
} from "./styles.js";
export {
ActionBar,
type ActionBarProps,
Dialog,
type DialogProps,
Panel,
type PanelProps,
PageHeader,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
+111 -7
View File
@@ -44,6 +44,7 @@ export const EKKLESIA_UI_STYLESHEET = `
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;
@@ -72,11 +73,11 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-button--primary:hover:not(:disabled) {
background: ${designToken("accent.strong")};
background: ${designToken("accent.hover")};
}
.ek-button--primary:active:not(:disabled) {
background: ${designToken("accent.strong")};
background: ${designToken("accent.active")};
}
.ek-button--secondary {
@@ -160,9 +161,10 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-control {
background: ${designToken("background.inset")};
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
color: ${designToken("text.primary")};
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
@@ -212,9 +214,10 @@ export const EKKLESIA_UI_STYLESHEET = `
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.md")};
padding: ${designToken("spacing.lg")};
gap: ${designToken("spacing.lg")};
padding: ${designToken("spacing.xl")};
}
.ek-panel--raised {
@@ -223,12 +226,17 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-panel__header {
align-items: baseline;
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")};
@@ -281,7 +289,7 @@ export const EKKLESIA_UI_STYLESHEET = `
.ek-tabs__list {
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
display: flex;
gap: ${designToken("spacing.xs")};
gap: ${designToken("spacing.sm")};
}
.ek-tab {
@@ -293,6 +301,7 @@ export const EKKLESIA_UI_STYLESHEET = `
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")};
}
@@ -302,6 +311,7 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-tab[aria-selected="true"] {
background: ${designToken("accent.subtle")};
border-bottom-color: ${designToken("accent.base")};
color: ${designToken("state.selected-foreground")};
}
@@ -440,6 +450,7 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-message {
background: ${designToken("background.canvas")};
align-items: center;
border: ${designToken("border.width")} dashed ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
@@ -450,6 +461,99 @@ export const EKKLESIA_UI_STYLESHEET = `
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")};
+56 -2
View File
@@ -1,9 +1,63 @@
import { type ReactElement, type ReactNode, useEffect, useId, useRef } from "react";
import {
type HTMLAttributes,
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 PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
<h1 className="ek-page-header__title">{props.title}</h1>
{props.description === undefined ? undefined : (
<p className="ek-page-header__description">{props.description}</p>
)}
</div>
{props.actions === undefined ? undefined : (
<div className="ek-page-header__actions">{props.actions}</div>
)}
</header>
);
}
export interface ActionBarProps extends HTMLAttributes<HTMLDivElement> {
readonly children?: ReactNode;
readonly label?: string;
}
/** A wrapping row of related controls with predictable spacing and alignment. */
export function ActionBar(props: ActionBarProps): ReactElement {
const { children, className, label, ...rest } = props;
return (
<div
{...rest}
{...(label === undefined ? {} : { "aria-label": label })}
className={classNames("ek-action-bar", className)}
>
{children}
</div>
);
}
export interface PanelProps {
readonly title?: ReactNode;
readonly description?: ReactNode;
@@ -26,7 +80,7 @@ export function Panel(props: PanelProps): ReactElement {
>
{hasHeader ? (
<header className="ek-panel__header">
<div>
<div className="ek-panel__heading">
{props.title === undefined ? undefined : (
<h2 className="ek-panel__title">{props.title}</h2>
)}