diff --git a/apps/api/src/module-display-path.ts b/apps/api/src/module-display-path.ts new file mode 100644 index 0000000..b99b639 --- /dev/null +++ b/apps/api/src/module-display-path.ts @@ -0,0 +1,16 @@ +/** + * Canonical browser routes owned by the platform's display host. + * + * Kept separate from the route registration module so both the display host and module-UI + * inventory can publish the same path without introducing a dependency cycle. + */ +export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays"; +export const MODULE_DISPLAY_PAGE_PREFIX = "/display"; + +export function moduleDisplayPageUrl(moduleId: string): string { + return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; +} + +export function moduleDisplayDescriptorUrl(moduleId: string): string { + return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`; +} diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index de192c8..6a48f5c 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -7,6 +7,13 @@ import { requirePermission, } from "./authorization-middleware.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; +export { + MODULE_DISPLAY_DESCRIPTOR_PREFIX, + MODULE_DISPLAY_PAGE_PREFIX, + moduleDisplayDescriptorUrl, + moduleDisplayPageUrl, +} from "./module-display-path.js"; +import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { @@ -18,8 +25,6 @@ import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js"; /** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under * `/api/v1/modules/`, and neither string enters the module-facing UI ABI. */ -export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays"; -export const MODULE_DISPLAY_PAGE_PREFIX = "/display"; export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; /** * Where the display's offline service worker is served (ADR 0043). @@ -34,14 +39,6 @@ export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; */ export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js"; -export function moduleDisplayPageUrl(moduleId: string): string { - return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`; -} - -export function moduleDisplayDescriptorUrl(moduleId: string): string { - return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`; -} - /** The portable facts a browser host needs to mount this module's one display artifact. It mirrors * the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a * management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the @@ -50,6 +47,9 @@ export interface HostModuleDisplayDescriptor { readonly id: string; readonly version: string; readonly display: { readonly url: string }; + /** The public page a screen opens to mount this display. This is distinct from `display.url`, + * which is the compiled artifact imported by the platform host. */ + readonly page: { readonly url: string }; readonly api: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: string }; readonly permissions: readonly string[]; @@ -157,6 +157,7 @@ async function describeDisplay( contributions: describeModuleContributions(options.moduleUi, display.moduleId), display: { url: moduleDisplayUiArtifactUrl(display) }, id: display.moduleId, + page: { url: moduleDisplayPageUrl(display.moduleId) }, permissions: await grantedModulePermissionIds( options.evaluator, principal, diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index bcb680c..43e83fa 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -13,6 +13,7 @@ import { requirePermission, } from "./authorization-middleware.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; +import { moduleDisplayPageUrl } from "./module-display-path.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { MODULE_UI_ASSET_PREFIX, @@ -48,6 +49,10 @@ export interface HostModuleUiDescriptor { * canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication * routing can change without touching a single line of host or module code. */ readonly ui: { readonly url: string }; + /** Canonical page for this module's separately declared display artifact, or `null` when the + * module publishes no display. Server-produced so neither shell nor module spells platform + * routing or infers deployment topology. */ + readonly display: { readonly url: string } | null; /** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths * a module UI asks for (`context.api.request("records")`). Server-produced for the same reason * `ui.url` is: `/api/v1/modules/` is Ekklesia's private deployment detail, and neither the @@ -170,9 +175,11 @@ async function describeModule( published: PublishableModuleUi, registry: ModuleUiPublicationRegistry, ): Promise { + const display = registry.resolveDisplay(published.moduleId, published.version); return { api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, contributions: describeModuleContributions(registry, published.moduleId), + display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) }, id: published.moduleId, permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 769cecc..1a71f98 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -271,6 +271,7 @@ describe("Dashboard navigation — reading", () => { expect(livestream?.module).toEqual({ contributions: [], api: { baseUrl: "/api/v1/modules/livestream" }, + display: null, id: "livestream", permissions: [], realtime: { baseUrl: "/api/v1/module-realtime/livestream" }, diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index 8ab3783..644c6a2 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -274,6 +274,7 @@ describe("module display routes — authorization and descriptor", () => { url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), }, id: "announcements", + page: { url: moduleDisplayPageUrl("announcements") }, permissions: [displayPermission], realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") }, version: "1.4.2", @@ -351,9 +352,14 @@ describe("module display routes — role and lifecycle separation", () => { expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200); expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); expect( - ((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] }) - .modules, - ).toHaveLength(1); + ( + (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { + modules: Array<{ display: { url: string } | null }>; + } + ).modules, + ).toEqual([ + expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }), + ]); }); it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => { diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 69f7623..33c7216 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -439,6 +439,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () expect(bootstrap.descriptor).toEqual({ apiBaseUrl: "/api/v1/modules/example", contributions: [], + displayPageUrl: "/display/example", id: "example", permissions: ["example.records.read", "example.display.view"], realtimeBaseUrl: "/api/v1/module-realtime/example", @@ -454,8 +455,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () ); const artifactUrl = await publishedArtifactUrl(harness, sourceUrl); - // 4. The generic loader receives the unchanged five-member browser context and mounts the - // display artifact independently of the Dashboard artifact. + // 4. The generic loader receives the host-owned browser context, including the canonical + // display page, and mounts the display artifact independently of the Dashboard artifact. const context = createModuleUiContext({ config: harness.config, descriptor: bootstrap.descriptor, @@ -464,6 +465,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () expect(Object.keys(context).sort()).toEqual([ "api", "contributions", + "display", "moduleId", "permissions", "principal", diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 6ab8d9d..43521d2 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -244,6 +244,7 @@ describe("module UI inventory — what it lists", () => { { contributions: [], api: { baseUrl: "/api/v1/modules/example" }, + display: null, id: "example", permissions: [], realtime: { baseUrl: "/api/v1/module-realtime/example" }, @@ -340,6 +341,7 @@ describe("module UI inventory — what it lists", () => { expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ "api", "contributions", + "display", "id", "permissions", "realtime", diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 67237bc..530433d 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -947,6 +947,7 @@ describe("module UI artifact publication — the generic loader consumes what is return { api: { request: async () => new Response(null, { status: 200 }) }, contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, moduleId: "announcements", permissions: { has: () => false }, principal: { kind: "anonymous" as const }, 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/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts index cb909d9..ef9535f 100644 --- a/packages/module-ui-host/tests/load-module-ui.test.ts +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -52,6 +52,7 @@ export default { function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext { return { contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, moduleId, api: { async request(path) { diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 5d5b87b..0b1e391 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -33,6 +33,7 @@ let artifactSource: string; function ekklesiaContext(requested: string[]): ModuleUiHostContext { return { contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, moduleId: "example", api: { async request(pathname) { @@ -50,6 +51,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext { function standaloneContext(requested: string[]): ModuleUiHostContext { return { contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: { url: "https://example.test/display" }, moduleId: "example", api: { async request(pathname) { diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index e7cb760..728e7ce 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -75,6 +75,14 @@ export interface ModuleUiHostContext { /** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode * or re-derive its own identity from a URL. */ readonly moduleId: string; + /** + * The host-owned public page for this module's dedicated display, when one exists. + * + * The URL is already absolute and deployment-correct. A module may show, open, or copy it, but + * must never construct a platform route or combine its own id with a browser origin. A standalone + * host can provide its own display page here without imitating Ekklesia's routing. + */ + readonly display: ModuleUiDisplayLocation | undefined; /** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */ readonly api: ModuleUiApiClient; /** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is @@ -96,6 +104,10 @@ export interface ModuleUiHostContext { readonly contributions: ModuleUiContributions; } +export interface ModuleUiDisplayLocation { + readonly url: string; +} + /** * A module UI's access to its own module's HTTP API, addressed **relative to the module's own API * root** — never as an absolute path. diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts index 133fc7e..525a646 100644 --- a/packages/module-ui-sdk/tests/module-ui-contract.test.ts +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -186,6 +186,7 @@ describe("the same module UI artifact hosted by two different hosts", () => { requestedUrls, context: { contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, moduleId, api, principal: { kind: "anonymous" }, @@ -211,6 +212,7 @@ describe("the same module UI artifact hosted by two different hosts", () => { requestedUrls, context: { contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: { url: "https://announcements.example.org/display" }, moduleId, api, principal: { kind: "anonymous" }, 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.

) : ( -