feat: establish shared Ekklesia design system
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
* 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 —
|
||||
* The default values are Ekklesia's calm, teal-accented light theme. They remain semantic defaults —
|
||||
* 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.
|
||||
*
|
||||
@@ -31,14 +31,14 @@ const declarations = [
|
||||
description: "The furthest-back background of a whole page or a full-viewport display.",
|
||||
label: "Page background",
|
||||
name: "background.page",
|
||||
value: "#f6f6f5",
|
||||
value: "#f3f7f6",
|
||||
},
|
||||
{
|
||||
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",
|
||||
value: "#f8faf9",
|
||||
},
|
||||
{
|
||||
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
|
||||
@@ -62,7 +62,7 @@ const declarations = [
|
||||
description: "A recessed area such as a text input, a code block, or a well.",
|
||||
label: "Inset background",
|
||||
name: "background.inset",
|
||||
value: "#f2f2f0",
|
||||
value: "#edf3f1",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -72,19 +72,19 @@ const declarations = [
|
||||
description: "Body copy and headings — the default foreground for readable content.",
|
||||
label: "Primary text",
|
||||
name: "text.primary",
|
||||
value: "#1c1917",
|
||||
value: "#182321",
|
||||
},
|
||||
{
|
||||
description: "Supporting text that is still content: captions, secondary columns, help text.",
|
||||
label: "Secondary text",
|
||||
name: "text.secondary",
|
||||
value: "#57534e",
|
||||
value: "#4f5f5b",
|
||||
},
|
||||
{
|
||||
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
|
||||
label: "Muted text",
|
||||
name: "text.muted",
|
||||
value: "#79716b",
|
||||
value: "#6c7b77",
|
||||
},
|
||||
{
|
||||
description: "Text drawn on a strongly coloured background, such as a primary button.",
|
||||
@@ -118,31 +118,43 @@ const declarations = [
|
||||
description: "The primary action colour: primary buttons, selected tabs, active links.",
|
||||
label: "Accent",
|
||||
name: "accent.base",
|
||||
value: "#1d4ed8",
|
||||
value: "#4fb3a8",
|
||||
},
|
||||
{
|
||||
description: "The accent background under a hovered primary action.",
|
||||
label: "Accent hover",
|
||||
name: "accent.hover",
|
||||
value: "#5ab9ae",
|
||||
},
|
||||
{
|
||||
description: "The accent background while a primary action is pressed.",
|
||||
label: "Accent active",
|
||||
name: "accent.active",
|
||||
value: "#45a59b",
|
||||
},
|
||||
{
|
||||
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
|
||||
label: "Strong accent",
|
||||
name: "accent.strong",
|
||||
value: "#1e3a8a",
|
||||
value: "#246b64",
|
||||
},
|
||||
{
|
||||
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
|
||||
label: "Subtle accent",
|
||||
name: "accent.subtle",
|
||||
value: "#e8effc",
|
||||
value: "#def2ef",
|
||||
},
|
||||
{
|
||||
description: "Foreground drawn on top of the accent background.",
|
||||
label: "Accent foreground",
|
||||
name: "accent.foreground",
|
||||
value: "#ffffff",
|
||||
value: "#102a27",
|
||||
},
|
||||
{
|
||||
description: "The focus ring colour. Never the only signal that something has focus.",
|
||||
label: "Focus ring",
|
||||
name: "accent.focus",
|
||||
value: "#1d4ed8",
|
||||
value: "#287a72",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -152,19 +164,19 @@ const declarations = [
|
||||
description: "The default border around panels, inputs, and table cells.",
|
||||
label: "Border",
|
||||
name: "border.base",
|
||||
value: "#d7d3d0",
|
||||
value: "#d3dedb",
|
||||
},
|
||||
{
|
||||
description: "A heavier border for emphasis, active inputs, and selected outlines.",
|
||||
label: "Strong border",
|
||||
name: "border.strong",
|
||||
value: "#a8a29e",
|
||||
value: "#91a6a1",
|
||||
},
|
||||
{
|
||||
description: "A hairline rule separating rows or sections inside one surface.",
|
||||
label: "Divider",
|
||||
name: "border.divider",
|
||||
value: "#e7e5e4",
|
||||
value: "#e2eae8",
|
||||
},
|
||||
{
|
||||
description: "The border width used by controls and panels.",
|
||||
@@ -234,13 +246,13 @@ const declarations = [
|
||||
description: "Background of a neutral informational badge or banner.",
|
||||
label: "Info background",
|
||||
name: "status.info-background",
|
||||
value: "#e8effc",
|
||||
value: "#def2ef",
|
||||
},
|
||||
{
|
||||
description: "Border of a neutral informational badge or banner.",
|
||||
label: "Info border",
|
||||
name: "status.info-border",
|
||||
value: "#bfd3f7",
|
||||
value: "#9bd5ce",
|
||||
},
|
||||
{
|
||||
description: "The non-colour signal for information, rendered beside the label.",
|
||||
@@ -256,25 +268,25 @@ const declarations = [
|
||||
description: "Background wash under a hovered control or row.",
|
||||
label: "Hover background",
|
||||
name: "state.hover-background",
|
||||
value: "#f2f2f0",
|
||||
value: "#edf3f1",
|
||||
},
|
||||
{
|
||||
description: "Background wash under a control being pressed.",
|
||||
label: "Pressed background",
|
||||
name: "state.pressed-background",
|
||||
value: "#e7e5e4",
|
||||
value: "#e1ebe8",
|
||||
},
|
||||
{
|
||||
description: "Background of a selected row, tab, or list item.",
|
||||
label: "Selected background",
|
||||
name: "state.selected-background",
|
||||
value: "#e8effc",
|
||||
value: "#def2ef",
|
||||
},
|
||||
{
|
||||
description: "Foreground of a selected row, tab, or list item.",
|
||||
label: "Selected foreground",
|
||||
name: "state.selected-foreground",
|
||||
value: "#1e3a8a",
|
||||
value: "#205f59",
|
||||
},
|
||||
{
|
||||
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
|
||||
@@ -408,7 +420,7 @@ const declarations = [
|
||||
description: "Spacing inside a compact control.",
|
||||
label: "Extra small spacing",
|
||||
name: "spacing.xs",
|
||||
value: "0.25rem",
|
||||
value: "0.375rem",
|
||||
},
|
||||
{
|
||||
description: "Spacing between a label and its field.",
|
||||
@@ -460,13 +472,13 @@ const declarations = [
|
||||
description: "Corner radius for controls, inputs, and buttons.",
|
||||
label: "Medium radius",
|
||||
name: "radius.md",
|
||||
value: "0.375rem",
|
||||
value: "0.625rem",
|
||||
},
|
||||
{
|
||||
description: "Corner radius for panels and dialogs.",
|
||||
label: "Large radius",
|
||||
name: "radius.lg",
|
||||
value: "0.75rem",
|
||||
value: "0.875rem",
|
||||
},
|
||||
{
|
||||
description: "Fully rounded ends, for pills and status dots.",
|
||||
@@ -484,19 +496,19 @@ const declarations = [
|
||||
description: "Subtle lift for a resting panel.",
|
||||
label: "Small shadow",
|
||||
name: "shadow.sm",
|
||||
value: "0 1px 2px rgba(23, 23, 21, 0.08)",
|
||||
value: "0 1px 3px rgba(24, 35, 33, 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)",
|
||||
value: "0 8px 24px rgba(24, 35, 33, 0.12)",
|
||||
},
|
||||
{
|
||||
description: "Shadow for a modal dialog.",
|
||||
label: "Large shadow",
|
||||
name: "shadow.lg",
|
||||
value: "0 16px 40px rgba(23, 23, 21, 0.22)",
|
||||
value: "0 18px 48px rgba(24, 35, 33, 0.2)",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -506,19 +518,19 @@ const declarations = [
|
||||
description: "Height of a compact control.",
|
||||
label: "Small control height",
|
||||
name: "control.height-sm",
|
||||
value: "1.75rem",
|
||||
value: "2rem",
|
||||
},
|
||||
{
|
||||
description: "Height of an ordinary button, input, or select.",
|
||||
label: "Medium control height",
|
||||
name: "control.height-md",
|
||||
value: "2.25rem",
|
||||
value: "2.5rem",
|
||||
},
|
||||
{
|
||||
description: "Height of a prominent primary control.",
|
||||
label: "Large control height",
|
||||
name: "control.height-lg",
|
||||
value: "2.75rem",
|
||||
value: "3rem",
|
||||
},
|
||||
{
|
||||
description: "Horizontal padding inside a control.",
|
||||
|
||||
@@ -60,6 +60,8 @@ describe("the Ekklesia design token registry", () => {
|
||||
"text.warning",
|
||||
"text.danger",
|
||||
"accent.base",
|
||||
"accent.hover",
|
||||
"accent.active",
|
||||
"accent.focus",
|
||||
"border.base",
|
||||
"border.divider",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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")};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user