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 ( +
+ +
+ {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 ( +
+
+ {props.eyebrow === undefined ? undefined : ( + {props.eyebrow} + )} +

{props.title}

+ {props.description === undefined ? undefined : ( +

{props.description}

+ )} +
+ {props.actions === undefined ? undefined : ( +
{props.actions}
+ )} +
+ ); +} + +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

-
void lookUp(event)} style={{ display: "grid", gap: "0.375rem" }}> - - setCode(event.target.value)} - style={panelStyles.input} - value={code} - /> - +
{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 : ( - + )}
))} 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

-
@@ -555,7 +574,7 @@ export function HostApp(props: HostAppProps): ReactElement { No installed module provides a user interface.

) : ( -