diff --git a/packages/design-tokens/src/registry.ts b/packages/design-tokens/src/registry.ts
index 4711811..5ee8bb4 100644
--- a/packages/design-tokens/src/registry.ts
+++ b/packages/design-tokens/src/registry.ts
@@ -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.",
diff --git a/packages/design-tokens/tests/registry.test.ts b/packages/design-tokens/tests/registry.test.ts
index 24f4bbc..f964319 100644
--- a/packages/design-tokens/tests/registry.test.ts
+++ b/packages/design-tokens/tests/registry.test.ts
@@ -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",
diff --git a/packages/ui/src/feedback.tsx b/packages/ui/src/feedback.tsx
index a42b17e..4fde15e 100644
--- a/packages/ui/src/feedback.tsx
+++ b/packages/ui/src/feedback.tsx
@@ -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 (
+
+
+ {statusSymbols[tone]}
+
+
+ {props.title === undefined ? undefined : (
+ {props.title}
+ )}
+ {props.children}
+
+
+ );
+}
+
/**
* One notification's presentation — and only its presentation.
*
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 7820b8a..6a32629 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -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,
diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts
index 4753082..c8094b8 100644
--- a/packages/ui/src/styles.ts
+++ b/packages/ui/src/styles.ts
@@ -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")};
diff --git a/packages/ui/src/surfaces.tsx b/packages/ui/src/surfaces.tsx
index 71a0985..2ba1f2e 100644
--- a/packages/ui/src/surfaces.tsx
+++ b/packages/ui/src/surfaces.tsx
@@ -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 (
+
+ );
+}
+
+export interface ActionBarProps extends HTMLAttributes {
+ 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 (
+
+ {children}
+
+ );
+}
+
export interface PanelProps {
readonly title?: ReactNode;
readonly description?: ReactNode;
@@ -26,7 +80,7 @@ export function Panel(props: PanelProps): ReactElement {
>
{hasHeader ? (
-
+
{props.title === undefined ? undefined : (
{props.title}
)}
diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx
index e5ed427..0e2a44d 100644
--- a/surfaces/shell/src/browser/DevicePanel.tsx
+++ b/surfaces/shell/src/browser/DevicePanel.tsx
@@ -1,3 +1,5 @@
+import { designToken } from "@ekklesia/design-tokens";
+import { Button, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import {
approveDevicePairing,
@@ -19,33 +21,27 @@ import {
* a viewer without it is told nothing about which devices exist.
*/
const panelStyles = {
- action: {
- background: "transparent",
- border: "1px solid #d6d3d1",
- borderRadius: "0.25rem",
- cursor: "pointer",
- font: "inherit",
- padding: "0.25rem 0.5rem",
+ meta: {
+ color: designToken("text.muted"),
+ fontSize: designToken("typography.size-xs"),
+ margin: 0,
},
- input: {
- border: "1px solid #d6d3d1",
- borderRadius: "0.25rem",
- font: "inherit",
- minWidth: 0,
- padding: "0.25rem 0.5rem",
- width: "100%",
- },
- meta: { color: "#78716c", fontSize: "0.8125rem", margin: 0 },
row: {
alignItems: "center",
- borderTop: "1px solid #e7e5e4",
+ borderTop: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
- gap: "0.5rem",
+ gap: designToken("spacing.sm"),
justifyContent: "space-between",
- padding: "0.375rem 0",
+ padding: `${designToken("spacing.sm")} 0`,
+ },
+ section: { display: "grid", gap: designToken("spacing.sm") },
+ title: {
+ color: designToken("text.secondary"),
+ fontSize: designToken("typography.size-xs"),
+ fontWeight: designToken("typography.weight-bold"),
+ letterSpacing: "0.05em",
+ margin: 0,
},
- section: { display: "grid", gap: "0.5rem" },
- title: { fontSize: "0.8125rem", fontWeight: 600, letterSpacing: "0.04em", margin: 0 },
} as const;
type PairingStep =
@@ -123,52 +119,58 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
DISPLAY DEVICES
-
{pairing.step === "found" ? (
-
+
A display is asking to show {pairing.moduleId}.
-
-
setName(event.target.value)}
- placeholder="Foyer TV"
- style={panelStyles.input}
- value={name}
- />
-
) : undefined}
@@ -191,14 +193,13 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
{device.revoked ? " (revoked)" : ""}
{device.revoked ? undefined : (
-
void revoke(device.id)}
- style={panelStyles.action}
- type="button"
+ size="sm"
>
Revoke
-
+
)}
))}
diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx
index 5423e24..5824d73 100644
--- a/surfaces/shell/src/browser/HostApp.tsx
+++ b/surfaces/shell/src/browser/HostApp.tsx
@@ -114,11 +114,22 @@ export interface HostAppProps {
const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
+ background: designToken("background.canvas"),
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
boxSizing: "border-box",
- minWidth: "13rem",
- padding: designToken("spacing.lg"),
- width: "13rem",
+ minWidth: "15rem",
+ overflowY: "auto",
+ padding: designToken("spacing.xl"),
+ width: "15rem",
+ },
+ brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
+ brandMark: {
+ background: designToken("accent.base"),
+ borderRadius: designToken("radius.md"),
+ boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
+ display: "block",
+ height: designToken("spacing.xl"),
+ width: designToken("spacing.xl"),
},
groupLabel: {
color: designToken("text.muted"),
@@ -129,20 +140,25 @@ const shellStyles = {
},
header: {
alignItems: "center",
+ background: designToken("background.surface"),
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
- padding: `${designToken("spacing.md")} ${designToken("spacing.lg")}`,
+ padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
+ },
+ heading: {
+ fontSize: designToken("typography.size-lg"),
+ fontWeight: designToken("typography.weight-bold"),
+ margin: 0,
},
- heading: { font: "inherit", margin: 0 },
layout: {
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: designToken("typography.family-sans"),
- gridTemplateColumns: "13rem minmax(0, 1fr)",
+ gridTemplateColumns: "15rem minmax(0, 1fr)",
gridTemplateRows: "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
@@ -152,7 +168,7 @@ const shellStyles = {
boxSizing: "border-box",
minWidth: 0,
overflowX: "auto",
- padding: designToken("spacing.lg"),
+ padding: designToken("spacing.xl"),
},
message: {
color: designToken("text.secondary"),
@@ -543,8 +559,11 @@ export function HostApp(props: HostAppProps): ReactElement {
return (
- Ekklesia
- void signOut()}>
+
+
+
Ekklesia
+
+ void signOut()} size="sm">
Sign out
@@ -555,7 +574,7 @@ export function HostApp(props: HostAppProps): ReactElement {
No installed module provides a user interface.
) : (
-