Fix/signage runtime layout #43
@@ -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)}`;
|
||||
}
|
||||
@@ -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/<module-id>`, 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,
|
||||
|
||||
@@ -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/<id>` is Ekklesia's private deployment detail, and neither the
|
||||
@@ -170,9 +175,11 @@ async function describeModule(
|
||||
published: PublishableModuleUi,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
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) },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
<section data-testid="host-devices" style={panelStyles.section}>
|
||||
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
|
||||
|
||||
<form onSubmit={(event) => void lookUp(event)} style={{ display: "grid", gap: "0.375rem" }}>
|
||||
<label htmlFor="device-pairing-code" style={panelStyles.meta}>
|
||||
Pairing code shown on the screen
|
||||
</label>
|
||||
<input
|
||||
<form
|
||||
onSubmit={(event) => void lookUp(event)}
|
||||
style={{ display: "grid", gap: designToken("spacing.sm") }}
|
||||
>
|
||||
<Field label="Pairing code shown on the screen">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
autoComplete="off"
|
||||
data-testid="device-pairing-code-input"
|
||||
id="device-pairing-code"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
style={panelStyles.input}
|
||||
value={code}
|
||||
/>
|
||||
<button data-testid="device-pairing-lookup" style={panelStyles.action} type="submit">
|
||||
)}
|
||||
</Field>
|
||||
<Button data-testid="device-pairing-lookup" size="sm" type="submit">
|
||||
Find display
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{pairing.step === "found" ? (
|
||||
<div data-testid="device-pairing-found" style={{ display: "grid", gap: "0.375rem" }}>
|
||||
<div
|
||||
data-testid="device-pairing-found"
|
||||
style={{ display: "grid", gap: designToken("spacing.sm") }}
|
||||
>
|
||||
<p style={panelStyles.meta}>
|
||||
A display is asking to show <strong>{pairing.moduleId}</strong>.
|
||||
</p>
|
||||
<label htmlFor="device-pairing-name" style={panelStyles.meta}>
|
||||
Name this display
|
||||
</label>
|
||||
<input
|
||||
<Field label="Name this display">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
autoComplete="off"
|
||||
data-testid="device-pairing-name"
|
||||
id="device-pairing-name"
|
||||
maxLength={128}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Foyer TV"
|
||||
style={panelStyles.input}
|
||||
value={name}
|
||||
/>
|
||||
<button
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
data-testid="device-pairing-approve"
|
||||
disabled={name.trim().length === 0}
|
||||
onClick={() => void approve()}
|
||||
style={panelStyles.action}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
@@ -191,14 +193,13 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
{device.revoked ? " (revoked)" : ""}
|
||||
</span>
|
||||
{device.revoked ? undefined : (
|
||||
<button
|
||||
<Button
|
||||
data-testid={`device-revoke-${device.id}`}
|
||||
onClick={() => void revoke(device.id)}
|
||||
style={panelStyles.action}
|
||||
type="button"
|
||||
size="sm"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid="host-shell" style={shellStyles.layout}>
|
||||
<header style={shellStyles.header}>
|
||||
<div style={shellStyles.brand}>
|
||||
<span aria-hidden="true" style={shellStyles.brandMark} />
|
||||
<h1 style={shellStyles.heading}>Ekklesia</h1>
|
||||
<Button data-testid="host-logout" onClick={() => void signOut()}>
|
||||
</div>
|
||||
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
|
||||
Sign out
|
||||
</Button>
|
||||
</header>
|
||||
@@ -555,7 +574,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : (
|
||||
<nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
|
||||
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface HostModuleUiDescriptor {
|
||||
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
|
||||
* unchanged; it never constructs, parses, or pattern-matches this URL. */
|
||||
readonly uiUrl: string;
|
||||
/** Server-produced page for this module's display, when it publishes one. */
|
||||
readonly displayPageUrl: string | undefined;
|
||||
/** Server-produced root of this module's own HTTP API. */
|
||||
readonly apiBaseUrl: string;
|
||||
/** Server-produced root for this module's own live channels. */
|
||||
@@ -214,8 +216,24 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
|
||||
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
||||
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined;
|
||||
const displayPageUrl =
|
||||
isRecord(entry) && entry.display !== null ? rootRelativePath(entry.display, "url") : undefined;
|
||||
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
||||
if (
|
||||
!hasExactKeys(entry, [
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"ui",
|
||||
"version",
|
||||
]) ||
|
||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||
!hasExactKeys(entry.realtime, ["baseUrl"]) ||
|
||||
!hasExactKeys(entry.ui, ["url"]) ||
|
||||
(entry.display !== null && !hasExactKeys(entry.display, ["url"])) ||
|
||||
id === undefined ||
|
||||
version === undefined ||
|
||||
uiUrl === undefined ||
|
||||
@@ -229,6 +247,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
return Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
||||
displayPageUrl,
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
@@ -323,12 +342,14 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"page",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"version",
|
||||
]) ||
|
||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||
!hasExactKeys(entry.display, ["url"]) ||
|
||||
!hasExactKeys(entry.page, ["url"]) ||
|
||||
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
@@ -338,6 +359,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
const version = nonEmptyString(entry.version);
|
||||
const apiBaseUrl = rootRelativePath(entry.api, "baseUrl");
|
||||
const uiUrl = rootRelativePath(entry.display, "url");
|
||||
const displayPageUrl = rootRelativePath(entry.page, "url");
|
||||
const realtimeBaseUrl = rootRelativePath(entry.realtime, "baseUrl");
|
||||
const permissions = entry.permissions;
|
||||
if (
|
||||
@@ -347,6 +369,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
!isSemanticVersion(version) ||
|
||||
apiBaseUrl === undefined ||
|
||||
uiUrl === undefined ||
|
||||
displayPageUrl === undefined ||
|
||||
realtimeBaseUrl === undefined ||
|
||||
!Array.isArray(permissions) ||
|
||||
permissions.some((permission) => typeof permission !== "string" || permission.length === 0) ||
|
||||
@@ -359,6 +382,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
module: Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(entry.contributions),
|
||||
displayPageUrl,
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
|
||||
@@ -350,6 +350,10 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
||||
options.descriptor,
|
||||
options.importModule,
|
||||
),
|
||||
display:
|
||||
options.descriptor.displayPageUrl === undefined
|
||||
? undefined
|
||||
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
principal: options.principal,
|
||||
|
||||
@@ -52,6 +52,7 @@ function descriptorPayload(
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||
id,
|
||||
page: { url: `/display/${id}` },
|
||||
permissions: [`${id}.slideshow.view`],
|
||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||
version,
|
||||
@@ -240,6 +241,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
@@ -334,6 +336,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
display: { url: "/display/announcements" },
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||
|
||||
@@ -22,6 +22,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||
id: "announcements",
|
||||
page: { url: "/display/announcements" },
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
|
||||
version: "1.2.0",
|
||||
@@ -45,6 +46,7 @@ describe("display host protocol — strict descriptor", () => {
|
||||
module: {
|
||||
apiBaseUrl: "/api/v1/modules/announcements",
|
||||
contributions: [],
|
||||
displayPageUrl: "/display/announcements",
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
||||
|
||||
@@ -43,6 +43,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
display: null,
|
||||
id,
|
||||
permissions,
|
||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||
@@ -978,9 +979,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
const aside = shell.querySelector("aside") as HTMLElement;
|
||||
const main = shell.querySelector("main") as HTMLElement;
|
||||
const frame = query("module-container") as HTMLElement;
|
||||
expect(shell.style.gridTemplateColumns).toBe("13rem minmax(0, 1fr)");
|
||||
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
|
||||
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
|
||||
expect(aside.style.width).toBe("13rem");
|
||||
expect(aside.style.width).toBe("15rem");
|
||||
expect(main.style.minWidth).toBe("0");
|
||||
expect(main.style.overflowX).toBe("auto");
|
||||
expect(frame.style.minWidth).toBe("0");
|
||||
|
||||
@@ -39,6 +39,7 @@ const inventoryPayload = {
|
||||
{
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
display: { url: "/display/example" },
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||
@@ -51,6 +52,7 @@ const inventoryPayload = {
|
||||
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
displayPageUrl: undefined,
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
@@ -72,6 +74,7 @@ const navigationPayload = {
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||
display: null,
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
|
||||
@@ -86,6 +89,7 @@ const navigationPayload = {
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
displayPageUrl: "/display/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
@@ -409,6 +413,7 @@ describe("module UI host context", () => {
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
displayPageUrl: undefined,
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
@@ -438,7 +443,18 @@ describe("module UI host context", () => {
|
||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes exactly the six contract members and the browser principal", () => {
|
||||
it("exposes the host-owned display page at the API origin in split-origin deployments", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
const context = createModuleUiContext({
|
||||
config: { apiBaseUrl: "https://api.example.test", fetch: implementation },
|
||||
descriptor: exampleDescriptor,
|
||||
principal: { kind: "authenticated", userId: "user-1" },
|
||||
});
|
||||
|
||||
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
||||
});
|
||||
|
||||
it("exposes exactly the seven contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
@@ -446,6 +462,7 @@ describe("module UI host context", () => {
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
|
||||
@@ -139,6 +139,7 @@ function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModule
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
displayPageUrl: "/display/signage",
|
||||
id: "signage",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/signage",
|
||||
|
||||
@@ -12,6 +12,7 @@ const apiBaseUrl = "https://ekklesia.example.test";
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
displayPageUrl: undefined,
|
||||
id: "example",
|
||||
permissions: ["example.state.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
|
||||
@@ -56,6 +56,7 @@ function recordingHost(options: {
|
||||
};
|
||||
return {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "example",
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
|
||||
Reference in New Issue
Block a user