diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index f1ff740..5a49bdc 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -1,5 +1,5 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; -import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui"; +import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui"; import { type ReactElement, useMemo, useState } from "react"; import { DesktopOnly } from "../browser/desktop-gate.js"; import { createSignageApi } from "./api.js"; @@ -23,144 +23,174 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js"; * hidden button is a courtesy, and every route is enforced regardless of what this believes. */ export function SignageDashboard({ - context, - copyText, - displaySetupUrl, + context, + copyText, }: { - readonly context: ModuleUiHostContext; - readonly copyText: (value: string) => Promise; - readonly displaySetupUrl: string; + readonly context: ModuleUiHostContext; + readonly copyText: (value: string) => Promise; }): ReactElement { - const api = useMemo(() => createSignageApi(context.api), [context.api]); - // Read before the resources below, so a viewer who may not see Signage at all asks for nothing. - // A hidden screen is a usability choice; the server is what actually refuses, and making a - // request it will certainly refuse is noise in a log rather than security. - const mayView = context.permissions.has("signage.view"); - const settings = useResource( - () => (mayView ? api.get("settings") : Promise.resolve(undefined)), - [mayView], - ); - const groups = useResource( - () => - mayView - ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups") - : Promise.resolve(undefined), - [mayView], - ); - const [tab, setTab] = useState("playback"); - const [groupId, setGroupId] = useState(undefined); + const api = useMemo(() => createSignageApi(context.api), [context.api]); + // Read before the resources below, so a viewer who may not see Signage at all asks for nothing. + // A hidden screen is a usability choice; the server is what actually refuses, and making a + // request it will certainly refuse is noise in a log rather than security. + const mayView = context.permissions.has("signage.view"); + const settings = useResource( + () => + mayView + ? api.get("settings") + : Promise.resolve(undefined), + [mayView], + ); + const groups = useResource( + () => + mayView + ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups") + : Promise.resolve(undefined), + [mayView], + ); + const [tab, setTab] = useState("playback"); + const [groupId, setGroupId] = useState(undefined); - const mayManageAssets = context.permissions.has("signage.assets.manage"); - const mayManageTimelines = context.permissions.has("signage.timelines.manage"); - const mayControl = context.permissions.has("signage.playback.control"); - const mayManageDisplays = context.permissions.has("signage.displays.manage"); + const mayManageAssets = context.permissions.has("signage.assets.manage"); + const mayManageTimelines = context.permissions.has( + "signage.timelines.manage", + ); + const mayControl = context.permissions.has("signage.playback.control"); + const mayManageDisplays = context.permissions.has("signage.displays.manage"); - if (!mayView) { - return ( - - ); - } + if (!mayView) { + return ( + + ); + } - const selectedGroupId = - groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); - const selectedGroup = - groups.state.status === "ready" - ? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) - : undefined; + const selectedGroupId = + groupId ?? + (groups.state.status === "ready" + ? groups.state.value?.groups[0]?.id + : undefined); + const selectedGroup = + groups.state.status === "ready" + ? groups.state.value?.groups.find( + (candidate) => candidate.id === selectedGroupId, + ) + : undefined; - return ( - <> - - - {renderLoadable( - settings.state, - (loaded) => - loaded === undefined ? undefined : ( - - ), - id: "playback", - label: "Playback", - }, - { - content: ( - { - await api.send("POST", `playback/${selectedGroupId}/activate`, { - timelineId, - when: "now", - }); - groups.reload(); - setTab("playback"); - } - : undefined - } - settings={loaded} - /> - ), - id: "timelines", - label: "Timelines", - }, - { - content: ( - - ), - id: "images", - label: "Images", - }, - { - content: ( - { - setGroupId(undefined); - groups.reload(); - }} - /> - ), - id: "displays", - label: "Displays", - }, - { - content: , - id: "providers", - label: "Providers", - }, - ]} - /> - ), - { label: "Loading Signage…", onRetry: settings.reload }, - )} - - - ); + return ( + <> + + + + {renderLoadable( + settings.state, + (loaded) => + loaded === undefined ? undefined : ( +
+ + ), + id: "playback", + label: "Playback", + }, + { + content: ( + { + await api.send( + "POST", + `playback/${selectedGroupId}/activate`, + { + timelineId, + when: "now", + }, + ); + groups.reload(); + setTab("playback"); + } + : undefined + } + settings={loaded} + /> + ), + id: "timelines", + label: "Timelines", + }, + { + content: ( + + ), + id: "images", + label: "Images", + }, + { + content: ( + { + setGroupId(undefined); + groups.reload(); + }} + /> + ), + id: "displays", + label: "Displays", + }, + { + content: ( + + ), + id: "providers", + label: "Providers", + }, + ]} + /> +
+ ), + { label: "Loading Signage…", onRetry: settings.reload }, + )} +
+ + ); } diff --git a/src/ui/index.tsx b/src/ui/index.tsx index ec342ce..de56c12 100644 --- a/src/ui/index.tsx +++ b/src/ui/index.tsx @@ -14,24 +14,24 @@ import ownStyles from "./styles.css"; * rather than bundled (ADR 0039). */ export default defineModuleUi({ - id: "signage", - version: "0.1.1", - styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, - mount(container, context) { - const root = createRoot(container); - const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href; - root.render( - { - if (globalThis.navigator.clipboard === undefined) { - throw new Error("Clipboard access is not available in this browser."); - } - await globalThis.navigator.clipboard.writeText(value); - }} - displaySetupUrl={displaySetupUrl} - />, - ); - return () => root.unmount(); - }, + id: "signage", + version: "0.1.1", + styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, + mount(container, context) { + const root = createRoot(container); + root.render( + { + if (globalThis.navigator.clipboard === undefined) { + throw new Error( + "Clipboard access is not available in this browser.", + ); + } + await globalThis.navigator.clipboard.writeText(value); + }} + />, + ); + return () => root.unmount(); + }, }); diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx index 4ff4a9c..7a52c36 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -1,78 +1,102 @@ -import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui"; +import { + Badge, + Button, + Field, + InlineNotice, + Panel, + Select, + TextInput, +} from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource, useTick } from "../hooks.js"; import type { ChannelGroupView, ViewerHealthView } from "../types.js"; -import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; +import { + FailureNote, + formatSince, + OnlineBadge, + renderLoadable, +} from "./shared.js"; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; function DisplayRow({ - canManage, - groups, - nowMs, - onChannelChange, - onNameChange, - viewer, + canManage, + groups, + nowMs, + onChannelChange, + onNameChange, + viewer, }: { - readonly canManage: boolean; - readonly groups: readonly ChannelGroupView[]; - readonly nowMs: number; - readonly onChannelChange: (channelId: string) => void; - readonly onNameChange: (name: string) => void; - readonly viewer: ViewerHealthView; + readonly canManage: boolean; + readonly groups: readonly ChannelGroupView[]; + readonly nowMs: number; + readonly onChannelChange: (channelId: string) => void; + readonly onNameChange: (name: string) => void; + readonly viewer: ViewerHealthView; }): ReactElement { - return ( -
-
- {canManage ? ( - event.target.value !== viewer.name && onNameChange(event.target.value)} - /> - ) : ( - {viewer.name} - )} - -
-
- {canManage ? ( - - {(control) => ( - - )} - - ) : undefined} -
-
- {`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} - - {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} - - {viewer.lastError === null ? undefined : ( - - {viewer.lastError.code} - - )} -
-
- ); + return ( +
+
+ {canManage ? ( + + event.target.value !== viewer.name && + onNameChange(event.target.value) + } + /> + ) : ( + {viewer.name} + )} + +
+
+ {canManage ? ( + + {(control) => ( + + )} + + ) : undefined} +
+
+ {`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} + + {viewer.offlineReady + ? `${viewer.cachedAssetCount} cached` + : "Not offline ready"} + + {viewer.lastError === null ? undefined : ( + + {viewer.lastError.code} + + )} +
+
+ ); } /** @@ -80,350 +104,459 @@ function DisplayRow({ * time: one group clock, one audience variant per channel, then the screens assigned to it. */ export function DisplaysPanel({ - api, - canManage, - copyText, - displaySetupUrl, - onGroupsChanged, + api, + canManage, + copyText, + displaySetupUrl, + onGroupsChanged, }: { - readonly api: SignageApi; - readonly canManage: boolean; - readonly copyText: (value: string) => Promise; - readonly displaySetupUrl: string; - readonly onGroupsChanged?: () => void; + readonly api: SignageApi; + readonly canManage: boolean; + readonly copyText: (value: string) => Promise; + readonly displaySetupUrl: string | undefined; + readonly onGroupsChanged?: () => void; }): ReactElement { - const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []); - const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []); - const edit = useAction(() => { - groups.reload(); - viewers.reload(); - onGroupsChanged?.(); - }); - const viewerEdit = useAction(viewers.reload); - const [groupName, setGroupName] = useState(""); - const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle"); - const [confirmGroupId, setConfirmGroupId] = useState(undefined); - const [channelDraft, setChannelDraft] = useState<{ - groupId: string; - key: string; - name: string; - }>({ - groupId: "", - key: "", - name: "", - }); - useTick(5000); - const nowMs = Date.now(); - const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; + const groups = useResource( + () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), + [], + ); + const viewers = useResource( + () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), + [], + ); + const edit = useAction(() => { + groups.reload(); + viewers.reload(); + onGroupsChanged?.(); + }); + const viewerEdit = useAction(viewers.reload); + const [groupName, setGroupName] = useState(""); + const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">( + "idle", + ); + const [confirmGroupId, setConfirmGroupId] = useState( + undefined, + ); + const [channelDraft, setChannelDraft] = useState<{ + groupId: string; + key: string; + name: string; + }>({ + groupId: "", + key: "", + name: "", + }); + useTick(5000); + const nowMs = Date.now(); + const readyViewers = + viewers.state.status === "ready" ? viewers.state.value.viewers : []; - const updateViewer = (viewerId: string, changes: Record): void => { - viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); - }; + const updateViewer = ( + viewerId: string, + changes: Record, + ): void => { + viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); + }; - return ( -
- - - Open display setup - - -
- } - description="Connect a screen in three short steps." - title="Pair a display" - > -
    -
  1. - 1 - Open display setup on the screen that will show signage. -
  2. -
  3. - 2 - Enter its six-digit code under Display devices in the Dashboard sidebar. -
  4. -
  5. - 3 - Name the display and choose its audience channel below. -
  6. -
- - {copyStatus === "copied" - ? "Display setup URL copied." - : copyStatus === "failed" - ? "Could not copy the URL. Copy it from the newly opened setup page." - : ""} - - {viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? ( -
- No displays paired yet - A newly approved screen appears below in its assigned channel. -
- ) : undefined} - + return ( +
+ + + Open display setup + + +
+ ) + } + description="Connect a screen in three short steps." + title="Pair a display" + > +
    +
  1. + 1 + + Open display setup on the screen that will show signage. + +
  2. +
  3. + 2 + + Enter its six-digit code under Display devices in the Dashboard + sidebar. + +
  4. +
  5. + 3 + Name the display and choose its audience channel below. +
  6. +
+ {displaySetupUrl === undefined ? ( + + This host did not provide a display setup address. + + ) : undefined} + + {copyStatus === "copied" + ? "Display setup URL copied." + : copyStatus === "failed" + ? "Could not copy the URL. Copy it from the newly opened setup page." + : ""} + + {viewers.state.status === "ready" && + viewers.state.value.viewers.length === 0 ? ( +
+ No displays paired yet + + A newly approved screen appears below in its assigned channel. + +
+ ) : undefined} + - - - {(control) => ( - setGroupName(event.target.value)} - placeholder="Nursery hallway" - value={groupName} - /> - )} - - - - ) : undefined - } - description="A channel group shares one playback clock. Each audience channel can receive a different content variant." - title="Channel groups and displays" - > - {renderLoadable( - groups.state, - (value) => { - const knownChannelIds = new Set( - value.groups.flatMap((group) => group.channels.map((channel) => channel.id)), - ); - const unassigned = readyViewers.filter( - (viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), - ); - return ( -
- {value.groups.map((group) => ( -
-
-
- Channel group -

{group.name}

-
- {group.playback.transport} - {canManage && value.groups.length > 1 ? ( - - ) : canManage ? ( - Required group - ) : undefined} -
- {confirmGroupId === group.id ? ( -
-

- Delete {group.name}? Its playback history will be removed. Displays assigned here will move to - the default channel of another group. -

- - -
- ) : undefined} + + + {(control) => ( + setGroupName(event.target.value)} + placeholder="Nursery hallway" + value={groupName} + /> + )} + + +
+ ) : undefined + } + description="A channel group shares one playback clock. Each audience channel can receive a different content variant." + title="Channel groups and displays" + > + {renderLoadable( + groups.state, + (value) => { + const knownChannelIds = new Set( + value.groups.flatMap((group) => + group.channels.map((channel) => channel.id), + ), + ); + const unassigned = readyViewers.filter( + (viewer) => + viewer.channelId === null || + !knownChannelIds.has(viewer.channelId), + ); + return ( +
+ {value.groups.map((group) => ( +
+
+
+ + Channel group + +

{group.name}

+
+ {group.playback.transport} + {canManage && value.groups.length > 1 ? ( + + ) : canManage ? ( + Required group + ) : undefined} +
+ {confirmGroupId === group.id ? ( +
+

+ Delete {group.name}? Its playback history will be + removed. Displays assigned here will move to the + default channel of another group. +

+ + +
+ ) : undefined} -
-
-
- Audience channels -

Who this group renders for

-
- Displays inherit the channel they are assigned to. -
-
- {group.channels.map((channel) => { - const assigned = readyViewers.filter((viewer) => viewer.channelId === channel.id); - return ( -
-
-
-
{channel.name}
-
- {channel.isDefault ? Default channel : undefined} - Audience key - {channel.key} - - {`${assigned.length} display${assigned.length === 1 ? "" : "s"}`} - -
-
- {canManage && !channel.isDefault ? ( - - ) : undefined} -
-
- Displays - {assigned.length === 0 ? ( -

No displays assigned.

- ) : ( - assigned.map((viewer) => ( - updateViewer(viewer.id, { channelId })} - onNameChange={(name) => updateViewer(viewer.id, { name })} - viewer={viewer} - /> - )) - )} -
-
- ); - })} -
+
+
+
+ + Audience channels + +

Who this group renders for

+
+ + Displays inherit the channel they are assigned to. + +
+
+ {group.channels.map((channel) => { + const assigned = readyViewers.filter( + (viewer) => viewer.channelId === channel.id, + ); + return ( +
+
+
+
{channel.name}
+
+ {channel.isDefault ? ( + + Default channel + + ) : undefined} + Audience key + {channel.key} + + {`${assigned.length} display${assigned.length === 1 ? "" : "s"}`} + +
+
+ {canManage && !channel.isDefault ? ( + + ) : undefined} +
+
+ + Displays + + {assigned.length === 0 ? ( +

+ No displays assigned. +

+ ) : ( + assigned.map((viewer) => ( + + updateViewer(viewer.id, { channelId }) + } + onNameChange={(name) => + updateViewer(viewer.id, { name }) + } + viewer={viewer} + /> + )) + )} +
+
+ ); + })} +
- {canManage ? ( -
-
-
Add an audience channel
- The default channel always remains available. -
- - {(control) => ( - - setChannelDraft((current) => ({ - groupId: group.id, - key: current.groupId === group.id ? current.key : "", - name: event.target.value, - })) - } - placeholder="Online audience" - value={channelDraft.groupId === group.id ? channelDraft.name : ""} - /> - )} - - - {(control) => ( - - setChannelDraft((current) => ({ - groupId: group.id, - key: event.target.value, - name: current.groupId === group.id ? current.name : "", - })) - } - pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" - placeholder="online" - value={channelDraft.groupId === group.id ? channelDraft.key : ""} - /> - )} - - -
- ) : undefined} -
-
- ))} + {canManage ? ( +
+
+
Add an audience channel
+ + The default channel always remains available. + +
+ + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: + current.groupId === group.id + ? current.key + : "", + name: event.target.value, + })) + } + placeholder="Online audience" + value={ + channelDraft.groupId === group.id + ? channelDraft.name + : "" + } + /> + )} + + + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: event.target.value, + name: + current.groupId === group.id + ? current.name + : "", + })) + } + pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" + placeholder="online" + value={ + channelDraft.groupId === group.id + ? channelDraft.key + : "" + } + /> + )} + + +
+ ) : undefined} + + + ))} - {unassigned.length === 0 ? undefined : ( -
- Needs assignment - {unassigned.map((viewer) => ( - updateViewer(viewer.id, { channelId })} - onNameChange={(name) => updateViewer(viewer.id, { name })} - viewer={viewer} - /> - ))} -
- )} -
- ); - }, - { label: "Loading channel groups…", onRetry: groups.reload }, - )} - {renderLoadable(viewers.state, () => null, { - label: "Loading displays…", - onRetry: viewers.reload, - })} - - -
- - ); + {unassigned.length === 0 ? undefined : ( +
+ + Needs assignment + + {unassigned.map((viewer) => ( + + updateViewer(viewer.id, { channelId }) + } + onNameChange={(name) => + updateViewer(viewer.id, { name }) + } + viewer={viewer} + /> + ))} +
+ )} + + ); + }, + { label: "Loading channel groups…", onRetry: groups.reload }, + )} + {renderLoadable(viewers.state, () => null, { + label: "Loading displays…", + onRetry: viewers.reload, + })} + + + + + ); } diff --git a/src/ui/panels/PlaybackPanel.tsx b/src/ui/panels/PlaybackPanel.tsx index 2024068..80edcf1 100644 --- a/src/ui/panels/PlaybackPanel.tsx +++ b/src/ui/panels/PlaybackPanel.tsx @@ -1,16 +1,29 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; -import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui"; +import { + ActionBar, + Badge, + Button, + EmptyState, + Field, + Panel, + Select, +} from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource, useTick } from "../hooks.js"; import type { - ChannelGroupView, - PlaybackDetailView, - SettingsResponse, - TimelineSummary, + ChannelGroupView, + PlaybackDetailView, + SettingsResponse, + TimelineSummary, } from "../types.js"; import { PreviewPanel } from "./PreviewPanel.js"; -import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; +import { + FailureNote, + formatDuration, + formatInstant, + renderLoadable, +} from "./shared.js"; /** * Live transport for one channel group. @@ -20,270 +33,325 @@ import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./sh * button, and neither can produce a state the other did not ask for. */ export function PlaybackPanel({ - api, - context, - settings, - canControl, - groupId, - onGroupChange, + api, + context, + settings, + canControl, + groupId, + onGroupChange, }: { - readonly api: SignageApi; - readonly context: ModuleUiHostContext; - readonly settings: SettingsResponse; - readonly canControl: boolean; - readonly groupId: string | undefined; - readonly onGroupChange: (groupId: string) => void; + readonly api: SignageApi; + readonly context: ModuleUiHostContext; + readonly settings: SettingsResponse; + readonly canControl: boolean; + readonly groupId: string | undefined; + readonly onGroupChange: (groupId: string) => void; }): ReactElement { - const groups = useResource( - () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), - [], - ); - const timelines = useResource( - () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), - [], - ); - const detail = useResource( - () => - groupId === undefined - ? Promise.resolve(undefined) - : api.get(`playback/${groupId}`), - [groupId], - ); - const command = useAction(() => { - detail.reload(); - groups.reload(); - }); - const [activateId, setActivateId] = useState(""); - const previewChannelId = - groups.state.status === "ready" - ? (groups.state.value.groups - .find((group) => group.id === groupId) - ?.channels.find((channel) => channel.isDefault)?.id ?? undefined) - : undefined; - // A one-second tick so the remaining time counts down rather than sitting still between refreshes. - useTick(1000); + const groups = useResource( + () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), + [], + ); + const timelines = useResource( + () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), + [], + ); + const detail = useResource( + () => + groupId === undefined + ? Promise.resolve(undefined) + : api.get(`playback/${groupId}`), + [groupId], + ); + const command = useAction(() => { + detail.reload(); + groups.reload(); + }); + const [activateId, setActivateId] = useState(""); + const previewChannelId = + groups.state.status === "ready" + ? (groups.state.value.groups + .find((group) => group.id === groupId) + ?.channels.find((channel) => channel.isDefault)?.id ?? undefined) + : undefined; + // A one-second tick so the remaining time counts down rather than sitting still between refreshes. + useTick(1000); - function run(path: string, body?: unknown): void { - if (groupId === undefined) { - return; - } - command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); - } + function run(path: string, body?: unknown): void { + if (groupId === undefined) { + return; + } + command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); + } - function timelineLabel(timelineId: string): string { - if (timelines.state.status !== "ready") { - return timelineId; - } - return ( - timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ?? - timelineId - ); - } + function timelineLabel(timelineId: string): string { + if (timelines.state.status !== "ready") { + return timelineId; + } + return ( + timelines.state.value.timelines.find( + (timeline) => timeline.id === timelineId, + )?.name ?? timelineId + ); + } - return ( -
- ( - - {(control) => ( - - )} - - ), - { label: "Loading groups…" }, - )} - description="One clock for every channel in the group." - title="Playback" - > - {groupId === undefined ? ( - - ) : ( - renderLoadable( - detail.state, - (value) => - value === undefined ? ( - - ) : ( -
-
- {value.snapshot.activeTimelineId === null ? undefined : ( - - {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} - - )} - - {value.snapshot.transport} - - {value.snapshot.blackout ? Blackout : undefined} - {value.current?.stale === true ? ( - Stale - ) : undefined} - {value.current?.withdrawn === true ? ( - Withdrawn - ) : undefined} - {value.snapshot.pendingTimelineId === null ? undefined : ( - Activation queued - )} -
+ return ( +
+ ( + + {(control) => ( + + )} + + ), + { label: "Loading groups…" }, + )} + description="One clock for every channel in the group." + title="Playback" + > + {groupId === undefined ? ( + + ) : ( + renderLoadable( + detail.state, + (value) => + value === undefined ? ( + + ) : ( +
+
+ {value.snapshot.activeTimelineId === null ? undefined : ( + + {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} + + )} + + {value.snapshot.transport} + + {value.snapshot.blackout ? ( + Blackout + ) : undefined} + {value.current?.stale === true ? ( + Stale + ) : undefined} + {value.current?.withdrawn === true ? ( + Withdrawn + ) : undefined} + {value.snapshot.pendingTimelineId === null ? undefined : ( + Activation queued + )} +
- {value.current === null ? ( - - ) : ( -
-
Provider
-
{value.current.providerId}
-
Item
-
{value.current.itemId}
-
Renderer
-
{value.current.rendererContributionId ?? "—"}
-
Duration
-
{formatDuration(value.current.durationMs)}
-
Remaining
-
- {value.snapshot.remainingMs === null - ? "—" - : formatDuration(value.snapshot.remainingMs)} -
-
Started
-
{formatInstant(value.current.startedAtMs, settings.timezone)}
-
- )} + {value.current === null ? ( + + ) : ( +
+

Now showing

+
+
Provider
+
{value.current.providerId}
+
Item
+
{value.current.itemId}
+
Renderer
+
{value.current.rendererContributionId ?? "—"}
+
Duration
+
{formatDuration(value.current.durationMs)}
+
Remaining
+
+ {value.snapshot.remainingMs === null + ? "—" + : formatDuration(value.snapshot.remainingMs)} +
+
Started
+
+ {formatInstant( + value.current.startedAtMs, + settings.timezone, + )} +
+
+
+ )} - {canControl ? ( -
- - {value.snapshot.transport === "paused" ? ( - - ) : ( - - )} - - - {value.snapshot.staticAssetId === null ? undefined : ( - - )} - -
- ) : undefined} + {canControl ? ( + + + {value.snapshot.transport === "paused" ? ( + + ) : ( + + )} + + + {value.snapshot.staticAssetId === null ? undefined : ( + + )} + + + ) : undefined} - {canControl ? ( -
- {renderLoadable( - timelines.state, - (loaded) => ( - <> - - {(control) => ( - - )} - - - - - ), - { label: "Loading timelines…" }, - )} -
- ) : undefined} + {canControl ? ( +
+ {renderLoadable( + timelines.state, + (loaded) => ( + <> + + {(control) => ( + + )} + + + + + ), + { label: "Loading timelines…" }, + )} +
+ ) : undefined} - {value.history.length === 0 ? undefined : ( -
-

Recently played

-
    - {value.history.slice(0, 8).map((entry) => ( -
  • - {entry.itemId} - - {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} - -
  • - ))} -
-
- )} -
- ), - { label: "Loading playback…", onRetry: detail.reload }, - ) - )} - -
+ {value.history.length === 0 ? undefined : ( +
+

+ Recently played +

+
    + {value.history.slice(0, 8).map((entry) => ( +
  • + + {entry.itemId} + + + {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} + +
  • + ))} +
+
+ )} +
+ ), + { label: "Loading playback…", onRetry: detail.reload }, + ) + )} + + - -
- ); + +
+ ); } diff --git a/src/ui/panels/ProvidersPanel.tsx b/src/ui/panels/ProvidersPanel.tsx index 928ce99..a69e0aa 100644 --- a/src/ui/panels/ProvidersPanel.tsx +++ b/src/ui/panels/ProvidersPanel.tsx @@ -1,13 +1,13 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { - Badge, - EmptyState, - Panel, - Table, - TableBody, - TableCell, - TableHead, - TableRow, + Badge, + EmptyState, + Panel, + Table, + TableBody, + TableCell, + TableHead, + TableRow, } from "@ekklesia/ui"; import type { ReactElement } from "react"; import type { SignageApi } from "../api.js"; @@ -31,86 +31,101 @@ const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer"; * exactly that, which is the difference between "this is broken" and "this needs an update". */ export function ProvidersPanel({ - api, - context, - settings, + api, + context, + settings, }: { - readonly api: SignageApi; - readonly context: ModuleUiHostContext; - readonly settings: SettingsResponse; + readonly api: SignageApi; + readonly context: ModuleUiHostContext; + readonly settings: SettingsResponse; }): ReactElement { - const providers = useResource( - () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), - [], - ); - const published = new Set( - context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), - ); + const providers = useResource( + () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), + [], + ); + const published = new Set( + context.contributions + .list(SLIDE_RENDERER_CAPABILITY) + .map((contribution) => contribution.id), + ); - return ( - - {renderLoadable( - providers.state, - (value) => - value.providers.length === 0 ? ( - - ) : ( - - - - Provider - Contract - Renderer - Health - Last success - - - - {value.providers.map((provider) => { - const rendererPublished = published.has(provider.renderer.contributionId); - const isNative = provider.renderer.contributionId === "signage.native-image"; - return ( - - - {provider.providerId} - {`v${provider.providerVersion}`} - - {provider.contractVersion} - - {isNative ? ( - Signage renders this - ) : rendererPublished ? ( - {provider.renderer.contributionId} - ) : ( - Renderer not published - )} - - - {provider.consecutiveFailures === 0 ? ( - Healthy - ) : ( - - {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} - - )} - - - {formatInstant(provider.lastSuccessAt, settings.timezone)} - - - ); - })} - -
- ), - { label: "Loading providers…", onRetry: providers.reload }, - )} -
- ); + return ( + {`Contract ${settings.rendererInputVersion}`} + } + description="Running content sources available to timelines." + title="Content providers" + > + {renderLoadable( + providers.state, + (value) => + value.providers.length === 0 ? ( + + ) : ( + + + + Provider + Contract + Renderer + Health + Last success + + + + {value.providers.map((provider) => { + const rendererPublished = published.has( + provider.renderer.contributionId, + ); + const isNative = + provider.renderer.contributionId === "signage.native-image"; + return ( + + + + {provider.providerId} + + {`v${provider.providerVersion}`} + + {provider.contractVersion} + + {isNative ? ( + Built in + ) : rendererPublished ? ( + + {provider.renderer.contributionId} + + ) : ( + Renderer not published + )} + + + {provider.consecutiveFailures === 0 ? ( + Healthy + ) : ( + + {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} + + )} + + + {formatInstant( + provider.lastSuccessAt, + settings.timezone, + )} + + + ); + })} + +
+ ), + { label: "Loading providers…", onRetry: providers.reload }, + )} +
+ ); } diff --git a/src/ui/panels/TimelinesPanel.tsx b/src/ui/panels/TimelinesPanel.tsx index 9e02849..70911de 100644 --- a/src/ui/panels/TimelinesPanel.tsx +++ b/src/ui/panels/TimelinesPanel.tsx @@ -1,31 +1,36 @@ import { - Badge, - Button, - Checkbox, - EmptyState, - Field, - Panel, - Select, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextInput, + Badge, + Button, + Checkbox, + EmptyState, + Field, + Panel, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextInput, } from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource } from "../hooks.js"; import type { - ImageRecord, - ProviderHealth, - ProviderItemDescriptor, - ProviderSourceDescriptor, - SettingsResponse, - TimelineDetail, - TimelineSummary, + ImageRecord, + ProviderHealth, + ProviderItemDescriptor, + ProviderSourceDescriptor, + SettingsResponse, + TimelineDetail, + TimelineSummary, } from "../types.js"; -import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; +import { + FailureNote, + formatDuration, + formatInstant, + renderLoadable, +} from "./shared.js"; const NATIVE_PROVIDER_ID = "signage.native-images"; @@ -38,454 +43,523 @@ const NATIVE_PROVIDER_ID = "signage.native-images"; * that visible by naming the source on every row rather than hiding it. */ export function TimelinesPanel({ - api, - settings, - canManage, - onActivate, - activeTimelineId, - activeGroupName, + api, + settings, + canManage, + onActivate, + activeTimelineId, + activeGroupName, }: { - readonly api: SignageApi; - readonly settings: SettingsResponse; - readonly canManage: boolean; - readonly onActivate: ((timelineId: string) => Promise) | undefined; - readonly activeTimelineId?: string | null; - readonly activeGroupName?: string; + readonly api: SignageApi; + readonly settings: SettingsResponse; + readonly canManage: boolean; + readonly onActivate: ((timelineId: string) => Promise) | undefined; + readonly activeTimelineId?: string | null; + readonly activeGroupName?: string; }): ReactElement { - const timelines = useResource( - () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), - [], - ); - const providers = useResource( - () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), - [], - ); - const [selectedId, setSelectedId] = useState(undefined); - const [newName, setNewName] = useState(""); - const create = useAction(() => { - setNewName(""); - timelines.reload(); - }); - const activation = useAction(); + const timelines = useResource( + () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), + [], + ); + const providers = useResource( + () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), + [], + ); + const [selectedId, setSelectedId] = useState(undefined); + const [newName, setNewName] = useState(""); + const create = useAction(() => { + setNewName(""); + timelines.reload(); + }); + const activation = useAction(); - return ( -
- - - {(control) => ( - setNewName(event.target.value)} - placeholder="Sunday morning" - value={newName} - /> - )} - - -
- ) : undefined - } - description="Saved patterns an operator builds on demand. Signage ships none." - title="Timelines" - > - {renderLoadable( - timelines.state, - (value) => - value.timelines.length === 0 ? ( - - ) : ( - - - - Name - Eligible - Revision - Actions - - - - {value.timelines.map((timeline) => ( - - - - {timeline.id === activeTimelineId ? ( - - {activeGroupName ? `Active in ${activeGroupName}` : "Active"} - - ) : undefined} - - - {timeline.eligibleFrom === null && timeline.eligibleUntil === null - ? "Always" - : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} - - {timeline.revision} - - {onActivate === undefined ? undefined : ( - - )} - - - ))} - -
- ), - { label: "Loading timelines…", onRetry: timelines.reload }, - )} - - - + return ( +
+ + + {(control) => ( + setNewName(event.target.value)} + placeholder="Sunday morning" + value={newName} + /> + )} + + +
+ ) : undefined + } + description="Saved patterns an operator builds on demand. Signage ships none." + title="Timelines" + > + {renderLoadable( + timelines.state, + (value) => + value.timelines.length === 0 ? ( + + ) : ( + + + + Name + Eligible + Revision + Actions + + + + {value.timelines.map((timeline) => ( + + +
+ + {timeline.id === activeTimelineId ? ( + + {activeGroupName + ? `Active in ${activeGroupName}` + : "Active"} + + ) : undefined} +
+
+ + {timeline.eligibleFrom === null && + timeline.eligibleUntil === null + ? "Always" + : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} + + {timeline.revision} + + {onActivate === undefined ? undefined : ( + + )} + +
+ ))} +
+
+ ), + { label: "Loading timelines…", onRetry: timelines.reload }, + )} + + + - {selectedId === undefined ? undefined : ( - setSelectedId(undefined)} - providers={providers.state.status === "ready" ? providers.state.value.providers : []} - settings={settings} - timelineId={selectedId} - /> - )} - - ); + {selectedId === undefined ? undefined : ( + setSelectedId(undefined)} + providers={ + providers.state.status === "ready" + ? providers.state.value.providers + : [] + } + settings={settings} + timelineId={selectedId} + /> + )} + + ); } function TimelineEditor({ - api, - canManage, - onChanged, - onClose, - providers, - settings, - timelineId, + api, + canManage, + onChanged, + onClose, + providers, + settings, + timelineId, }: { - readonly api: SignageApi; - readonly canManage: boolean; - readonly onChanged: () => void; - readonly onClose: () => void; - readonly providers: readonly ProviderHealth[]; - readonly settings: SettingsResponse; - readonly timelineId: string; + readonly api: SignageApi; + readonly canManage: boolean; + readonly onChanged: () => void; + readonly onClose: () => void; + readonly providers: readonly ProviderHealth[]; + readonly settings: SettingsResponse; + readonly timelineId: string; }): ReactElement { - const detail = useResource( - () => api.get(`timelines/${timelineId}`), - [timelineId], - ); - const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); - const edit = useAction(() => { - detail.reload(); - onChanged(); - }); - const [providerId, setProviderId] = useState(""); - const [sources, setSources] = useState([]); - const [items, setItems] = useState([]); + const detail = useResource( + () => api.get(`timelines/${timelineId}`), + [timelineId], + ); + const images = useResource( + () => api.get<{ images: readonly ImageRecord[] }>("images"), + [], + ); + const edit = useAction(() => { + detail.reload(); + onChanged(); + }); + const [providerId, setProviderId] = useState(""); + const [sources, setSources] = useState( + [], + ); + const [items, setItems] = useState([]); - function loadProvider(nextProviderId: string): void { - setProviderId(nextProviderId); - setSources([]); - setItems([]); - if (nextProviderId === "") { - return; - } - void api - .get<{ sources: readonly ProviderSourceDescriptor[] }>( - `providers/${encodeURIComponent(nextProviderId)}/sources`, - ) - .then((response) => setSources(response.sources)) - .catch(() => setSources([])); - void api - .get<{ items: readonly ProviderItemDescriptor[] }>( - `providers/${encodeURIComponent(nextProviderId)}/items`, - ) - .then((response) => setItems(response.items)) - .catch(() => setItems([])); - } + function loadProvider(nextProviderId: string): void { + setProviderId(nextProviderId); + setSources([]); + setItems([]); + if (nextProviderId === "") { + return; + } + void api + .get<{ sources: readonly ProviderSourceDescriptor[] }>( + `providers/${encodeURIComponent(nextProviderId)}/sources`, + ) + .then((response) => setSources(response.sources)) + .catch(() => setSources([])); + void api + .get<{ items: readonly ProviderItemDescriptor[] }>( + `providers/${encodeURIComponent(nextProviderId)}/items`, + ) + .then((response) => setItems(response.items)) + .catch(() => setItems([])); + } - return ( - Close} - description="Slots run in order and repeat. Repeating one source is one rotation, not several." - title="Timeline pattern" - > - {renderLoadable( - detail.state, - (value) => ( -
-

{value.timeline.name}

+ return ( + Close} + description="Slots run in order and repeat. Repeating one source is one rotation, not several." + title="Timeline pattern" + > + {renderLoadable( + detail.state, + (value) => ( +
+

{value.timeline.name}

- {canManage ? ( -
- - {(control) => ( - - )} - - {sources.map((source) => ( - - ))} - {items.slice(0, 20).map((item) => ( - - ))} -
- ) : undefined} + {canManage ? ( +
+ + {(control) => ( + + )} + + {sources.map((source) => ( + + ))} + {items.slice(0, 20).map((item) => ( + + ))} +
+ ) : undefined} - {value.sources.length === 0 ? undefined : ( -
-

Configured sources

- {value.sources.map((source) => ( -
- {source.label} - {source.providerId} - {canManage ? ( - <> - - - - ) : undefined} -
- ))} -
- )} + {value.sources.length === 0 ? undefined : ( +
+

Configured sources

+ {value.sources.map((source) => ( +
+ {source.label} + {source.providerId} + {canManage ? ( + <> + + + + ) : undefined} +
+ ))} +
+ )} - {value.slots.length === 0 ? ( - - ) : ( - - - - # - Content - Duration - Enabled - Order - - - - {value.slots.map((slot, index) => { - const source = value.sources.find( - (candidate) => candidate.id === slot.sourceId, - ); - const image = - slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready" - ? images.state.value.images.find( - (candidate) => candidate.id === slot.itemId, - ) - : undefined; - return ( - - {slot.position + 1} - - {slot.kind === "provider-source" - ? (source?.label ?? "Removed source") - : (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)} - - - {slot.durationOverrideMs === null - ? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)` - : formatDuration(slot.durationOverrideMs)} - - - - edit.run(() => - api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, { - enabled: event.target.checked, - }), - ) - } - /> - - - {canManage ? ( -
- - - -
- ) : undefined} -
-
- ); - })} -
-
- )} + {value.slots.length === 0 ? ( + + ) : ( + + + + # + Content + Duration + Enabled + Order + + + + {value.slots.map((slot, index) => { + const source = value.sources.find( + (candidate) => candidate.id === slot.sourceId, + ); + const image = + slot.providerId === NATIVE_PROVIDER_ID && + images.state.status === "ready" + ? images.state.value.images.find( + (candidate) => candidate.id === slot.itemId, + ) + : undefined; + return ( + + {slot.position + 1} + + {slot.kind === "provider-source" + ? (source?.label ?? "Removed source") + : (image?.displayName ?? + `${slot.providerId} · ${slot.itemId}`)} + + + {slot.durationOverrideMs === null + ? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)` + : formatDuration(slot.durationOverrideMs)} + + + + edit.run(() => + api.send( + "PATCH", + `timelines/${timelineId}/slots/${slot.id}`, + { + enabled: event.target.checked, + }, + ), + ) + } + /> + + + {canManage ? ( +
+ + + +
+ ) : undefined} +
+
+ ); + })} +
+
+ )} - {canManage ? ( -
- - -
- ) : undefined} -
- ), - { label: "Loading timeline…", onRetry: detail.reload }, - )} - -
- ); + {canManage ? ( +
+ + +
+ ) : undefined} +
+ ), + { label: "Loading timeline…", onRetry: detail.reload }, + )} + +
+ ); } function swap(ids: readonly string[], from: number, to: number): string[] { - const next = [...ids]; - const moved = next[from]; - const target = next[to]; - if (moved === undefined || target === undefined) { - return next; - } - next[from] = target; - next[to] = moved; - return next; + const next = [...ids]; + const moved = next[from]; + const target = next[to]; + if (moved === undefined || target === undefined) { + return next; + } + next[from] = target; + next[to] = moved; + return next; } diff --git a/src/ui/styles.css b/src/ui/styles.css index c13f4ff..21b9148 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -24,65 +24,74 @@ *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } :host { - --signage-gap: var(--ekklesia-spacing-lg); - --signage-gap-tight: var(--ekklesia-spacing-sm); - --signage-panel-radius: var(--ekklesia-radius-lg); - --signage-thumbnail-width: 220px; + --signage-gap: var(--ekklesia-spacing-xl); + --signage-gap-tight: var(--ekklesia-spacing-sm); + --signage-panel-radius: var(--ekklesia-radius-lg); + --signage-thumbnail-width: 220px; } .signage-desktop { - display: none; + display: none; } .signage-narrow-notice { - background: var(--ekklesia-background-surface); - border: var(--ekklesia-border-width) solid var(--ekklesia-border-base); - border-radius: var(--signage-panel-radius); - color: var(--ekklesia-text-primary); - display: grid; - font-family: var(--ekklesia-typography-family-sans); - gap: var(--signage-gap-tight); - padding: var(--ekklesia-spacing-xl); + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-base); + border-radius: var(--signage-panel-radius); + color: var(--ekklesia-text-primary); + display: grid; + font-family: var(--ekklesia-typography-family-sans); + gap: var(--signage-gap-tight); + padding: var(--ekklesia-spacing-xl); } .signage-narrow-notice__title { - font-size: var(--ekklesia-typography-size-heading-3); - font-weight: var(--ekklesia-typography-weight-bold); - line-height: var(--ekklesia-typography-line-height-tight); - margin: 0; + font-size: var(--ekklesia-typography-size-heading-3); + font-weight: var(--ekklesia-typography-weight-bold); + line-height: var(--ekklesia-typography-line-height-tight); + margin: 0; } .signage-narrow-notice__body { - color: var(--ekklesia-text-secondary); - line-height: var(--ekklesia-typography-line-height-normal); - margin: 0; + color: var(--ekklesia-text-secondary); + line-height: var(--ekklesia-typography-line-height-normal); + margin: 0; } @media (min-width: 1024px) { - .signage-narrow-notice { - display: none; - } + .signage-narrow-notice { + display: none; + } - .signage-desktop { - display: grid; - gap: var(--signage-gap); - } + .signage-desktop { + display: grid; + gap: var(--ekklesia-spacing-xl); + } +} + +.signage-workspace { + display: grid; + min-width: 0; +} + +.signage-workspace > .ek-tabs__list { + padding-inline: var(--ekklesia-spacing-xs); } .signage-columns { - display: grid; - gap: var(--signage-gap); + display: grid; + gap: var(--signage-gap); } .signage-inline-form { - align-items: end; - display: flex; - flex-wrap: wrap; - gap: var(--signage-gap-tight); + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); } /* @@ -91,12 +100,12 @@ * the button on top of it. */ .signage-inline-form > .ek-field { - min-width: 0; - position: relative; + min-width: 0; + position: relative; } .signage-inline-form .ek-control { - min-width: 0; + min-width: 0; } /* @@ -105,20 +114,20 @@ * its own control above the others. */ .signage-inline-form > .ek-field > .ek-field__hint { - inset-block-start: 100%; - inset-inline-start: 0; - padding-block-start: var(--ekklesia-spacing-2xs); - position: absolute; + inset-block-start: 100%; + inset-inline-start: 0; + padding-block-start: var(--ekklesia-spacing-2xs); + position: absolute; } .signage-inline-form:has(.ek-field__hint) { - padding-block-end: var(--ekklesia-spacing-lg); + padding-block-end: var(--ekklesia-spacing-lg); } .signage-failure { - color: var(--ekklesia-text-danger); - font-size: var(--ekklesia-typography-size-sm); - margin: var(--signage-gap-tight) 0 0; + color: var(--ekklesia-text-danger); + font-size: var(--ekklesia-typography-size-sm); + margin: var(--signage-gap-tight) 0 0; } /* @@ -127,14 +136,14 @@ * assume a flex line. */ .signage-row { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--ekklesia-spacing-sm); + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-sm); } .signage-row__name { - flex: 1 1 auto; + flex: 1 1 auto; } /* --------------------------------------------------------------------------------------------- @@ -142,100 +151,103 @@ * ------------------------------------------------------------------------------------------- */ .signage-upload-trigger { - cursor: pointer; - display: inline-flex; + cursor: pointer; + display: inline-flex; } .signage-upload-input { - height: 1px; - opacity: 0; - position: absolute; - width: 1px; + height: 1px; + opacity: 0; + position: absolute; + width: 1px; } .signage-uploads { - display: grid; - gap: var(--signage-gap-tight); - margin-bottom: var(--signage-gap); + display: grid; + gap: var(--signage-gap-tight); + margin-bottom: var(--signage-gap); } .signage-uploads__message { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); } .signage-image-grid { - display: grid; - gap: var(--signage-gap); - grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr)); - list-style: none; - margin: 0; - padding: 0; + display: grid; + gap: var(--signage-gap); + grid-template-columns: repeat( + auto-fill, + minmax(var(--signage-thumbnail-width), 1fr) + ); + list-style: none; + margin: 0; + padding: 0; } .signage-image { - /* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather + /* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather than spread between the thumbnail and the name. */ - align-content: start; - background: var(--ekklesia-background-canvas); - border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - border-radius: var(--ekklesia-radius-md); - display: grid; - gap: var(--ekklesia-spacing-xs); - overflow: hidden; - padding: var(--ekklesia-spacing-sm); + align-content: start; + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--ekklesia-spacing-sm); + overflow: hidden; + padding: var(--ekklesia-spacing-md); } /* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and a tall image is letterboxed here rather than reflowing the card it sits in. */ .signage-image__thumbnail { - aspect-ratio: 16 / 9; - background: var(--ekklesia-background-inset); - border-radius: var(--ekklesia-radius-sm); - display: block; - height: auto; - max-width: 100%; - object-fit: contain; - width: 100%; + aspect-ratio: 16 / 9; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + display: block; + height: auto; + max-width: 100%; + object-fit: contain; + width: 100%; } .signage-image__missing { - align-items: center; - aspect-ratio: 16 / 9; - background: var(--ekklesia-background-inset); - border-radius: var(--ekklesia-radius-sm); - color: var(--ekklesia-text-muted); - display: flex; - font-size: var(--ekklesia-typography-size-sm); - justify-content: center; + align-items: center; + aspect-ratio: 16 / 9; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + color: var(--ekklesia-text-muted); + display: flex; + font-size: var(--ekklesia-typography-size-sm); + justify-content: center; } .signage-image__meta { - display: grid; - gap: var(--ekklesia-spacing-2xs); + display: grid; + gap: var(--ekklesia-spacing-2xs); } /* A badge is sized by what it says, not by the column it lands in. */ .signage-image__expiry { - justify-self: start; + justify-self: start; } .signage-image__name { - font-weight: var(--ekklesia-typography-weight-medium); - margin: 0; - overflow-wrap: anywhere; + font-weight: var(--ekklesia-typography-weight-medium); + margin: 0; + overflow-wrap: anywhere; } .signage-image__detail { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); - margin: 0; + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); + margin: 0; } .signage-image__actions { - display: flex; - flex-wrap: wrap; - gap: var(--ekklesia-spacing-2xs); + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-2xs); } /* --------------------------------------------------------------------------------------------- @@ -243,46 +255,59 @@ * ------------------------------------------------------------------------------------------- */ .signage-editor { - display: grid; - gap: var(--signage-gap); + display: grid; + gap: var(--signage-gap); } .signage-editor__title { - font-size: var(--ekklesia-typography-size-heading-3); - font-weight: var(--ekklesia-typography-weight-bold); - margin: 0; + font-size: var(--ekklesia-typography-size-heading-3); + font-weight: var(--ekklesia-typography-weight-bold); + margin: 0; } .signage-editor__subtitle { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); - font-weight: var(--ekklesia-typography-weight-medium); - margin: 0; - text-transform: uppercase; + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); + font-weight: var(--ekklesia-typography-weight-medium); + margin: 0; + text-transform: uppercase; } .signage-editor__add, .signage-editor__sources, .signage-editor__footer { - display: flex; - flex-wrap: wrap; - gap: var(--signage-gap-tight); + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); } .signage-editor__sources { - display: grid; - gap: var(--ekklesia-spacing-xs); + display: grid; + gap: var(--ekklesia-spacing-xs); } .signage-editor__source { - align-items: center; - display: flex; - gap: var(--signage-gap-tight); + align-items: center; + display: flex; + gap: var(--signage-gap-tight); } .signage-editor__order { - display: flex; - gap: var(--ekklesia-spacing-2xs); + display: flex; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-timeline-name { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-sm); +} + +.signage-timeline-select { + color: var(--ekklesia-text-primary); + font-weight: var(--ekklesia-typography-weight-bold); + padding-inline: var(--ekklesia-spacing-sm); } /* --------------------------------------------------------------------------------------------- @@ -290,40 +315,70 @@ * ------------------------------------------------------------------------------------------- */ .signage-playback { - display: grid; - gap: var(--signage-gap); + display: grid; + gap: var(--signage-gap); } .signage-playback__status, .signage-playback__controls, .signage-playback__activate { - align-items: end; - display: flex; - flex-wrap: wrap; - gap: var(--signage-gap-tight); + align-items: end; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); } .signage-playback__current { - column-gap: var(--signage-gap); - display: grid; - grid-template-columns: max-content 1fr; - margin: 0; - row-gap: var(--ekklesia-spacing-2xs); + column-gap: var(--signage-gap); + display: grid; + grid-template-columns: max-content 1fr; + margin: 0; + row-gap: var(--ekklesia-spacing-2xs); +} + +.signage-playback__now { + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--ekklesia-spacing-sm); + padding: var(--ekklesia-spacing-md); +} + +.signage-playback__now h3 { + font-size: var(--ekklesia-typography-size-lg); + margin: 0; } .signage-playback__current dt { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); } .signage-playback__current dd { - font-variant-numeric: tabular-nums; - margin: 0; + font-variant-numeric: tabular-nums; + margin: 0; } .signage-playback__history-meta { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-playback__history { + display: grid; + gap: var(--ekklesia-spacing-sm); +} + +.signage-playback__history .ek-list { + max-height: 14rem; + overflow: auto; +} + +@media (min-width: 1280px) { + .signage-playback-layout { + align-items: start; + grid-template-columns: minmax(0, 1fr) minmax(28rem, 1fr); + } } /* --------------------------------------------------------------------------------------------- @@ -331,297 +386,304 @@ * ------------------------------------------------------------------------------------------- */ .signage-groups { - display: grid; - gap: var(--signage-gap); + display: grid; + gap: var(--signage-gap); } .signage-pairing-steps { - display: grid; - gap: var(--signage-gap-tight); - grid-template-columns: repeat(3, minmax(0, 1fr)); - list-style: none; - margin: 0; - padding: 0; + display: grid; + gap: var(--signage-gap-tight); + grid-template-columns: repeat(3, minmax(0, 1fr)); + list-style: none; + margin: 0; + padding: 0; } .signage-pairing-actions { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--signage-gap-tight); + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); } .signage-pairing-actions a { - text-decoration: none; + text-decoration: none; } .signage-copy-status { - color: var(--ekklesia-text-secondary); - display: block; - font-size: var(--ekklesia-typography-size-sm); - min-height: 1.25em; - margin-block-start: var(--ekklesia-spacing-xs); + color: var(--ekklesia-text-secondary); + display: block; + font-size: var(--ekklesia-typography-size-sm); + min-height: 1.25em; + margin-block-start: var(--ekklesia-spacing-xs); } .signage-pairing-steps li { - align-items: center; - background: var(--ekklesia-background-inset); - border-radius: var(--ekklesia-radius-md); - display: flex; - gap: var(--signage-gap-tight); - min-width: 0; - padding: var(--ekklesia-spacing-md); + align-items: center; + background: var(--ekklesia-accent-subtle); + border-radius: var(--ekklesia-radius-md); + display: flex; + gap: var(--signage-gap-tight); + min-width: 0; + padding: var(--ekklesia-spacing-md); } .signage-pairing-steps__number { - align-items: center; - background: var(--ekklesia-accent-base); - border-radius: var(--ekklesia-radius-pill); - color: var(--ekklesia-accent-foreground); - display: inline-flex; - flex: 0 0 var(--ekklesia-control-height-sm); - font-weight: var(--ekklesia-typography-weight-bold); - height: var(--ekklesia-control-height-sm); - justify-content: center; + align-items: center; + background: var(--ekklesia-accent-base); + border-radius: var(--ekklesia-radius-pill); + color: var(--ekklesia-accent-foreground); + display: inline-flex; + flex: 0 0 var(--ekklesia-control-height-sm); + font-weight: var(--ekklesia-typography-weight-bold); + height: var(--ekklesia-control-height-sm); + justify-content: center; } .signage-display-empty { - align-items: baseline; - color: var(--ekklesia-text-secondary); - display: flex; - flex-wrap: wrap; - gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight); - margin-block-start: var(--signage-gap-tight); + align-items: baseline; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight); + margin-block-start: var(--signage-gap-tight); } .signage-display-empty strong { - color: var(--ekklesia-text-primary); + color: var(--ekklesia-text-primary); } .signage-new-group > .ek-field { - min-width: 16rem; + min-width: 16rem; } .signage-group { - background: var(--ekklesia-background-canvas); - border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - border-radius: var(--signage-panel-radius); - display: grid; - gap: var(--signage-gap); - padding: var(--ekklesia-spacing-lg); + background: var(--ekklesia-background-inset); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--signage-panel-radius); + display: grid; + gap: var(--signage-gap); + padding: var(--ekklesia-spacing-lg); } .signage-group__header { - align-items: center; - display: flex; - gap: var(--signage-gap-tight); + align-items: center; + display: flex; + gap: var(--signage-gap-tight); } .signage-group__header > .ek-button { - margin-inline-start: auto; + margin-inline-start: auto; } .signage-group__identity { - display: grid; - gap: var(--ekklesia-spacing-2xs); + display: grid; + gap: var(--ekklesia-spacing-2xs); } .signage-group__identity h3, .signage-audiences__heading h4, .signage-channel__header h5, .signage-channel-form__heading h5 { - margin: 0; + margin: 0; } .signage-hierarchy-label { - color: var(--ekklesia-text-muted); - font-size: var(--ekklesia-typography-size-xs); - font-weight: var(--ekklesia-typography-weight-medium); - letter-spacing: 0.05em; - text-transform: uppercase; + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-xs); + font-weight: var(--ekklesia-typography-weight-medium); + letter-spacing: 0.05em; + text-transform: uppercase; } .signage-confirm { - align-items: center; - background: var(--ekklesia-status-danger-background); - border-radius: var(--ekklesia-radius-md); - color: var(--ekklesia-text-danger); - display: flex; - flex-wrap: wrap; - gap: var(--signage-gap-tight); - padding: var(--ekklesia-spacing-sm); + align-items: center; + background: var(--ekklesia-status-danger-background); + border-radius: var(--ekklesia-radius-md); + color: var(--ekklesia-text-danger); + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); + padding: var(--ekklesia-spacing-sm); } .signage-confirm p { - flex: 1 1 30rem; - margin: 0; + flex: 1 1 30rem; + margin: 0; } .signage-audiences { - border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - display: grid; - gap: var(--signage-gap); - padding-block-start: var(--signage-gap); + border-block-start: var(--ekklesia-border-width) solid + var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap); + padding-block-start: var(--signage-gap); } .signage-audiences__heading { - align-items: end; - display: flex; - gap: var(--signage-gap); - justify-content: space-between; + align-items: end; + display: flex; + gap: var(--signage-gap); + justify-content: space-between; } .signage-audiences__heading > div { - display: grid; - gap: var(--ekklesia-spacing-2xs); + display: grid; + gap: var(--ekklesia-spacing-2xs); } .signage-audiences__heading > span { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); } .signage-channel-list { - display: grid; - gap: var(--signage-gap-tight); + display: grid; + gap: var(--signage-gap-tight); } .signage-channel { - background: var(--ekklesia-background-surface); - border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base); - border-radius: var(--ekklesia-radius-md); - display: grid; - gap: var(--signage-gap-tight); - margin-inline-start: var(--ekklesia-spacing-sm); - padding: var(--ekklesia-spacing-md); + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-inline-start: var(--ekklesia-spacing-2xs) solid + var(--ekklesia-accent-base); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-md); + padding: var(--ekklesia-spacing-md); } .signage-channel__header { - align-items: start; - display: flex; - gap: var(--signage-gap-tight); - justify-content: space-between; + align-items: start; + display: flex; + gap: var(--signage-gap-tight); + justify-content: space-between; } .signage-channel__header > div { - display: grid; - gap: var(--ekklesia-spacing-xs); + display: grid; + gap: var(--ekklesia-spacing-xs); } .signage-channel__meta { - align-items: center; - color: var(--ekklesia-text-secondary); - display: flex; - flex-wrap: wrap; - font-size: var(--ekklesia-typography-size-sm); - gap: var(--ekklesia-spacing-xs); + align-items: center; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + font-size: var(--ekklesia-typography-size-sm); + gap: var(--ekklesia-spacing-xs); } .signage-channel__meta code, .signage-pairing-steps code { - background: var(--ekklesia-background-inset); - border-radius: var(--ekklesia-radius-sm); - color: var(--ekklesia-text-primary); - padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs); + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + color: var(--ekklesia-text-primary); + padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs); } .signage-channel__displays { - border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - display: grid; - gap: var(--signage-gap-tight); - padding-block-start: var(--signage-gap-tight); + border-block-start: var(--ekklesia-border-width) solid + var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap-tight); + padding-block-start: var(--signage-gap-tight); } .signage-channel__empty { - color: var(--ekklesia-text-muted); - font-size: var(--ekklesia-typography-size-sm); - margin: 0; + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-sm); + margin: 0; } .signage-display-row { - align-items: end; - background: var(--ekklesia-background-canvas); - border-radius: var(--ekklesia-radius-md); - display: grid; - gap: var(--signage-gap-tight); - grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr); - padding: var(--ekklesia-spacing-sm); + align-items: end; + background: var(--ekklesia-background-canvas); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax( + 14rem, + 1.2fr + ); + padding: var(--ekklesia-spacing-sm); } .signage-display-row__identity, .signage-display-row__health { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--ekklesia-spacing-xs); + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs); } .signage-display-row__identity .ek-control, .signage-display-row__assignment .ek-field { - width: 100%; + width: 100%; } .signage-display-row__health { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); } .signage-display-row__error { - color: var(--ekklesia-text-danger); + color: var(--ekklesia-text-danger); } .signage-channel-form, .signage-unassigned { - background: var(--ekklesia-background-inset); - border-radius: var(--ekklesia-radius-md); - display: grid; - gap: var(--signage-gap-tight); - margin-inline-start: var(--ekklesia-spacing-sm); - padding: var(--ekklesia-spacing-md); + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-md); + padding: var(--ekklesia-spacing-md); } .signage-channel-form { - align-items: start; - grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr); + align-items: start; + grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr); } .signage-channel-form__heading, .signage-channel-form > .ek-button { - grid-column: 1 / -1; + grid-column: 1 / -1; } .signage-channel-form__heading { - display: grid; - gap: var(--ekklesia-spacing-2xs); + display: grid; + gap: var(--ekklesia-spacing-2xs); } .signage-channel-form__heading span { - color: var(--ekklesia-text-secondary); - font-size: var(--ekklesia-typography-size-sm); + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); } .signage-channel-form > .ek-button { - justify-self: start; + justify-self: start; } @media (max-width: 1200px) { - .signage-pairing-steps, - .signage-channel-form, - .signage-display-row { - grid-template-columns: 1fr; - } + .signage-pairing-steps, + .signage-channel-form, + .signage-display-row { + grid-template-columns: 1fr; + } } .signage-provider__id { - font-weight: var(--ekklesia-typography-weight-medium); + font-weight: var(--ekklesia-typography-weight-medium); } .signage-provider__version { - color: var(--ekklesia-text-muted); - font-size: var(--ekklesia-typography-size-sm); - /* Far enough that the version reads as a second word, not the tail of the identifier. */ - margin-left: var(--ekklesia-spacing-sm); + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-sm); + /* Far enough that the version reads as a second word, not the tail of the identifier. */ + margin-left: var(--ekklesia-spacing-sm); } /* --------------------------------------------------------------------------------------------- @@ -633,154 +695,154 @@ * ------------------------------------------------------------------------------------------- */ .signage-preview { - display: grid; - gap: var(--signage-gap-tight); + display: grid; + gap: var(--signage-gap-tight); } .signage-preview__frame { - aspect-ratio: 16 / 9; - background: #000000; - border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong); - border-radius: var(--ekklesia-radius-md); - overflow: hidden; - position: relative; - width: 100%; + aspect-ratio: 16 / 9; + background: #000000; + border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong); + border-radius: var(--ekklesia-radius-md); + overflow: hidden; + position: relative; + width: 100%; } .signage-preview .signage-stage { - height: 100%; - inset: 0; - min-height: 0; - position: absolute; - width: 100%; + height: 100%; + inset: 0; + min-height: 0; + position: absolute; + width: 100%; } .signage-preview .signage-layer { - height: 100%; - inset: 0; - opacity: 0; - position: absolute; - transition-duration: 0ms; - transition-property: opacity; - transition-timing-function: linear; - width: 100%; + height: 100%; + inset: 0; + opacity: 0; + position: absolute; + transition-duration: 0ms; + transition-property: opacity; + transition-timing-function: linear; + width: 100%; } .signage-preview .signage-layer__background { - background: #000000; - inset: 0; - position: absolute; + background: #000000; + inset: 0; + position: absolute; } .signage-preview .signage-layer__background-image { - display: none; - height: 100%; - inset: 0; - object-fit: cover; - position: absolute; - transform: scale(1.08); - width: 100%; + display: none; + height: 100%; + inset: 0; + object-fit: cover; + position: absolute; + transform: scale(1.08); + width: 100%; } .signage-preview .signage-layer__darken { - background: #000000; - inset: 0; - opacity: 0; - position: absolute; + background: #000000; + inset: 0; + opacity: 0; + position: absolute; } .signage-preview .signage-layer__foreground { - height: 100%; - inset: 0; - object-fit: contain; - position: absolute; - width: 100%; + height: 100%; + inset: 0; + object-fit: contain; + position: absolute; + width: 100%; } .signage-preview .signage-layer__renderer { - inset: 0; - position: absolute; + inset: 0; + position: absolute; } .signage-preview .signage-blackout { - background: #000000; - inset: 0; - opacity: 0; - pointer-events: none; - position: absolute; - transition: opacity 200ms linear; - z-index: 20; + background: #000000; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 200ms linear; + z-index: 20; } .signage-preview [data-blackout="true"] > .signage-blackout { - opacity: 1; + opacity: 1; } .signage-preview .signage-slate-host { - inset: 0; - opacity: 0; - pointer-events: none; - position: absolute; - z-index: 10; + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; + z-index: 10; } .signage-preview [data-slate="true"] > .signage-slate-host { - opacity: 1; + opacity: 1; } .signage-preview .signage-slate { - align-items: stretch; - display: flex; - flex-direction: column; - height: 100%; - inset: 0; - position: absolute; - width: 100%; + align-items: stretch; + display: flex; + flex-direction: column; + height: 100%; + inset: 0; + position: absolute; + width: 100%; } .signage-preview .signage-slate__bars { - display: grid; - flex: 1 1 auto; - grid-template-columns: repeat(6, 1fr); + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(6, 1fr); } .signage-preview .signage-slate__bar { - display: block; - height: 100%; + display: block; + height: 100%; } .signage-preview .signage-slate__bar--one { - background: #6f6f6f; + background: #6f6f6f; } .signage-preview .signage-slate__bar--two { - background: #6f6f2f; + background: #6f6f2f; } .signage-preview .signage-slate__bar--three { - background: #2f6f6f; + background: #2f6f6f; } .signage-preview .signage-slate__bar--four { - background: #2f6f2f; + background: #2f6f2f; } .signage-preview .signage-slate__bar--five { - background: #6f2f6f; + background: #6f2f6f; } .signage-preview .signage-slate__bar--six { - background: #6f2f2f; + background: #6f2f2f; } .signage-preview .signage-slate__message { - background: #000000; - color: #ffffff; - flex: 0 0 auto; - font-size: 2vh; - letter-spacing: 0.08em; - margin: 0; - padding: 1.5vh 0; - text-align: center; - text-transform: uppercase; + background: #000000; + color: #ffffff; + flex: 0 0 auto; + font-size: 2vh; + letter-spacing: 0.08em; + margin: 0; + padding: 1.5vh 0; + text-align: center; + text-transform: uppercase; } diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx index 049e598..386cccf 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -3,7 +3,12 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; -import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; +import { + createFakeHost, + errorResponse, + FAKE_SETTINGS, + jsonResponse, +} from "./support/fake-host.js"; /** * The management UI's behaviour, mounted as React rather than inspected as source. @@ -14,50 +19,53 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su */ // React needs this to run effects the way a browser does. -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLElement; let root: Root; -const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage"; +const DISPLAY_SETUP_URL = "https://api.example.test/display/signage"; const copiedText: string[] = []; -function dashboard(context: Parameters[0]["context"]): React.ReactElement { - return ( - { - copiedText.push(value); - return Promise.resolve(); - }} - displaySetupUrl={DISPLAY_SETUP_URL} - /> - ); +function dashboard( + context: Parameters[0]["context"], +): React.ReactElement { + return ( + { + copiedText.push(value); + return Promise.resolve(); + }} + /> + ); } async function render(element: React.ReactElement): Promise { - await act(async () => { - root.render(element); - }); - // Let the resource loads settle. - for (let index = 0; index < 6; index += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } + await act(async () => { + root.render(element); + }); + // Let the resource loads settle. + for (let index = 0; index < 6; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } } async function click(element: Element | null | undefined): Promise { - if (!element) { - throw new Error("Expected an element to click."); - } - await act(async () => { - (element as HTMLElement).click(); - }); - for (let index = 0; index < 6; index += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } + if (!element) { + throw new Error("Expected an element to click."); + } + await act(async () => { + (element as HTMLElement).click(); + }); + for (let index = 0; index < 6; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } } /** @@ -68,588 +76,831 @@ async function click(element: Element | null | undefined): Promise { * what makes React see it. */ async function type(input: HTMLInputElement, value: string): Promise { - const setter = Object.getOwnPropertyDescriptor( - globalThis.HTMLInputElement.prototype, - "value", - )?.set; - await act(async () => { - setter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); } function buttonNamed(label: string): HTMLButtonElement | undefined { - return [...container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === label, - ) as HTMLButtonElement | undefined; + return [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; } const GROUP = { - channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], - id: "group-1", - name: "Main", - playback: { - activeTimelineId: "timeline-1", - blackout: false, - currentSelectionId: "sel-1", - currentSlotId: "slot-1", - deadlineAtMs: 1_700_000_005_000, - groupId: "group-1", - pendingTimelineId: null, - remainingMs: 4000, - revision: 7, - scheduledActivationAtMs: null, - scheduledTimelineId: null, - selectionRevision: 1, - serverNowMs: 1_700_000_001_000, - startedAtMs: 1_700_000_000_000, - staticAssetId: null, - transport: "playing" as const, - }, - revision: 1, + channels: [ + { id: "chan-1", isDefault: true, key: "default", name: "Default" }, + ], + id: "group-1", + name: "Main", + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: "sel-1", + currentSlotId: "slot-1", + deadlineAtMs: 1_700_000_005_000, + groupId: "group-1", + pendingTimelineId: null, + remainingMs: 4000, + revision: 7, + scheduledActivationAtMs: null, + scheduledTimelineId: null, + selectionRevision: 1, + serverNowMs: 1_700_000_001_000, + startedAtMs: 1_700_000_000_000, + staticAssetId: null, + transport: "playing" as const, + }, + revision: 1, }; const PLAYBACK_DETAIL = { - current: { - deadlineAtMs: 1_700_000_005_000, - durationMs: 5000, - id: "sel-1", - itemId: "welcome", - itemRevision: "1", - providerId: "fixture.announcements", - rendererContributionId: "fixture.announcements.slide", - revision: 1, - stale: false, - startedAtMs: 1_700_000_000_000, - withdrawn: false, - }, - history: [ - { - id: "sel-0", - itemId: "giving", - providerId: "fixture.announcements", - sequence: 1, - startedAtMs: 1_699_999_990_000, - }, - ], - snapshot: GROUP.playback, + current: { + deadlineAtMs: 1_700_000_005_000, + durationMs: 5000, + id: "sel-1", + itemId: "welcome", + itemRevision: "1", + providerId: "fixture.announcements", + rendererContributionId: "fixture.announcements.slide", + revision: 1, + stale: false, + startedAtMs: 1_700_000_000_000, + withdrawn: false, + }, + history: [ + { + id: "sel-0", + itemId: "giving", + providerId: "fixture.announcements", + sequence: 1, + startedAtMs: 1_699_999_990_000, + }, + ], + snapshot: GROUP.playback, }; const TIMELINE = { - description: null, - eligibleFrom: null, - eligibleUntil: null, - id: "timeline-1", - name: "Sunday morning", - revision: 3, - updatedAt: "2026-08-30T15:00:00.000Z", + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", }; beforeEach(() => { - copiedText.length = 0; - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); + copiedText.length = 0; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); }); afterEach(async () => { - await act(async () => { - root.unmount(); - }); - container.remove(); + await act(async () => { + root.unmount(); + }); + container.remove(); }); describe("Signage dashboard", () => { - it("shows live transport for the default channel group and drives it with one command each", async () => { - const commands: string[] = []; - const host = createFakeHost({ - permissions: ["signage.view", "signage.playback.control"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { - method: "GET", - path: "playback/group-1", - respond: () => jsonResponse(PLAYBACK_DETAIL), - }, - { - method: "POST", - path: /^playback\/group-1\//u, - respond: ({ path }) => { - commands.push(path); - return jsonResponse(GROUP.playback); - }, - }, - ], - }); + it("shows live transport for the default channel group and drives it with one command each", async () => { + const commands: string[] = []; + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "POST", + path: /^playback\/group-1\//u, + respond: ({ path }) => { + commands.push(path); + return jsonResponse(GROUP.playback); + }, + }, + ], + }); - await render(dashboard(host.context)); + await render(dashboard(host.context)); - expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( - "welcome", - ); - expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s"); + expect( + container.querySelector("[data-testid='signage-current']")?.textContent, + ).toContain("welcome"); + expect( + container.querySelector("[data-testid='signage-remaining']")?.textContent, + ).toBe("4s"); - await click(buttonNamed("Pause")); - await click(buttonNamed("Next")); - await click(buttonNamed("Blackout")); + await click(buttonNamed("Pause")); + await click(buttonNamed("Next")); + await click(buttonNamed("Blackout")); - expect(commands).toEqual([ - "playback/group-1/pause", - "playback/group-1/next", - "playback/group-1/blackout", - ]); - }); + expect(commands).toEqual([ + "playback/group-1/pause", + "playback/group-1/next", + "playback/group-1/blackout", + ]); + }); - it("offers no transport controls to an operator who may only view", async () => { - const host = createFakeHost({ - permissions: ["signage.view"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - ], - }); + it("offers no transport controls to an operator who may only view", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + ], + }); - await render(dashboard(host.context)); + await render(dashboard(host.context)); - // The current slide is still legible; the buttons that would be refused are simply not offered. - expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); - expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); - }); + // The current slide is still legible; the buttons that would be refused are simply not offered. + expect( + container.querySelector("[data-testid='signage-current']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='signage-controls']"), + ).toBeNull(); + }); - it("shows which timeline remains active after the dashboard data is reloaded", async () => { - const host = createFakeHost({ - permissions: ["signage.view", "signage.playback.control"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { - method: "GET", - path: "timelines", - respond: () => jsonResponse({ timelines: [TIMELINE] }), - }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, - ], - }); + it("shows which timeline remains active after the dashboard data is reloaded", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "providers", + respond: () => jsonResponse({ providers: [] }), + }, + ], + }); - await render(dashboard(host.context)); - expect(container.textContent).toContain("Active: Sunday morning"); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (candidate) => candidate.textContent === "Timelines", - ), - ); + await render(dashboard(host.context)); + expect(container.textContent).toContain("Active: Sunday morning"); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); - expect(container.textContent).toContain("Active in Main"); - expect(buttonNamed("Active")?.disabled).toBe(true); - }); + expect(container.textContent).toContain("Active in Main"); + expect(buttonNamed("Active")?.disabled).toBe(true); + }); - it("keeps a failed activation visible instead of pretending it succeeded", async () => { - const inactiveGroup = { - ...GROUP, - playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null }, - }; - const host = createFakeHost({ - permissions: ["signage.view", "signage.playback.control"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { - method: "GET", - path: "channel-groups", - respond: () => jsonResponse({ groups: [inactiveGroup] }), - }, - { - method: "GET", - path: "timelines", - respond: () => jsonResponse({ timelines: [TIMELINE] }), - }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, - { - method: "POST", - path: "playback/group-1/activate", - respond: () => - errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409), - }, - ], - }); + it("keeps a failed activation visible instead of pretending it succeeded", async () => { + const inactiveGroup = { + ...GROUP, + playback: { + ...GROUP.playback, + activeTimelineId: null, + currentSelectionId: null, + }, + }; + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [inactiveGroup] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [TIMELINE] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "providers", + respond: () => jsonResponse({ providers: [] }), + }, + { + method: "POST", + path: "playback/group-1/activate", + respond: () => + errorResponse( + "SIGNAGE_CONFLICT", + "The request conflicts with the current state.", + 409, + ), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (candidate) => candidate.textContent === "Timelines", - ), - ); - await click(buttonNamed("Activate")); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Timelines", + ), + ); + await click(buttonNamed("Activate")); - expect(container.textContent).toContain("Sunday morning"); - expect(container.querySelector("[role='alert']")?.textContent).toContain( - "The request conflicts with the current state.", - ); - }); + expect(container.textContent).toContain("Sunday morning"); + expect(container.querySelector("[role='alert']")?.textContent).toContain( + "The request conflicts with the current state.", + ); + }); - it("shows Signage's own sanitized reason when a save is refused", async () => { - const host = createFakeHost({ - permissions: ["signage.view", "signage.timelines.manage"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, - { - method: "POST", - path: "timelines", - respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400), - }, - ], - }); + it("shows Signage's own sanitized reason when a save is refused", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.timelines.manage"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "providers", + respond: () => jsonResponse({ providers: [] }), + }, + { + method: "POST", + path: "timelines", + respond: () => + errorResponse( + "SIGNAGE_INVALID_REQUEST", + "The request is not valid.", + 400, + ), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (tab) => tab.textContent === "Timelines", - ), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Timelines", + ), + ); - const input = container.querySelector("input[type='text']") as HTMLInputElement; - await type(input, "Sunday morning"); - await click(buttonNamed("Create")); + const input = container.querySelector( + "input[type='text']", + ) as HTMLInputElement; + await type(input, "Sunday morning"); + await click(buttonNamed("Create")); - const alert = container.querySelector("[role='alert']"); - expect(alert?.textContent).toBe("The request is not valid."); - // The server's own fixed message, and nothing else: no status code, no URL, no request detail. - expect(alert?.textContent).not.toMatch(/400|http|timelines/u); - }); + const alert = container.querySelector("[role='alert']"); + expect(alert?.textContent).toBe("The request is not valid."); + // The server's own fixed message, and nothing else: no status code, no URL, no request detail. + expect(alert?.textContent).not.toMatch(/400|http|timelines/u); + }); - it("says a timeline has no slots rather than showing an empty table", async () => { - const host = createFakeHost({ - permissions: ["signage.view", "signage.timelines.manage"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, - { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, - { - method: "GET", - path: "timelines", - respond: () => - jsonResponse({ - timelines: [ - { - description: null, - eligibleFrom: null, - eligibleUntil: null, - id: "timeline-1", - name: "Sunday morning", - revision: 3, - updatedAt: "2026-08-30T15:00:00.000Z", - }, - ], - }), - }, - { - method: "GET", - path: "timelines/timeline-1", - respond: () => - jsonResponse({ - slots: [], - sources: [], - timeline: { - description: null, - eligibleFrom: null, - eligibleUntil: null, - id: "timeline-1", - name: "Sunday morning", - revision: 3, - updatedAt: "2026-08-30T15:00:00.000Z", - }, - }), - }, - ], - }); + it("says a timeline has no slots rather than showing an empty table", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.timelines.manage"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "providers", + respond: () => jsonResponse({ providers: [] }), + }, + { + method: "GET", + path: "images", + respond: () => jsonResponse({ images: [] }), + }, + { + method: "GET", + path: "timelines", + respond: () => + jsonResponse({ + timelines: [ + { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", + }, + ], + }), + }, + { + method: "GET", + path: "timelines/timeline-1", + respond: () => + jsonResponse({ + slots: [], + sources: [], + timeline: { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id: "timeline-1", + name: "Sunday morning", + revision: 3, + updatedAt: "2026-08-30T15:00:00.000Z", + }, + }), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (tab) => tab.textContent === "Timelines", - ), - ); - await click(buttonNamed("Sunday morning")); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Timelines", + ), + ); + await click(buttonNamed("Sunday morning")); - expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull(); - expect(container.textContent).toContain("This timeline has no slots"); - }); + expect( + container.querySelector("[data-testid='signage-timeline-editor']"), + ).not.toBeNull(); + expect(container.textContent).toContain("This timeline has no slots"); + }); - it("reports a provider whose renderer the platform did not publish", async () => { - const host = createFakeHost({ - // The platform published nothing against Signage's renderer capability — the contributing - // module is absent, disabled, or outside the compatible range. - contributions: [], - permissions: ["signage.view"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { - method: "GET", - path: "providers", - respond: () => - jsonResponse({ - providers: [ - { - consecutiveFailures: 0, - contractVersion: "1.0.0", - defaultDurationMs: 8000, - lastFailureAt: null, - lastFailureCode: null, - lastSuccessAt: "2026-08-30T15:00:00.000Z", - providerId: "fixture.announcements", - providerVersion: "1.0.0", - registeredAt: "2026-08-30T14:00:00.000Z", - renderer: { - contributionId: "fixture.announcements.slide", - contributionVersion: "1.0.0", - }, - }, - ], - }), - }, - ], - }); + it("reports a provider whose renderer the platform did not publish", async () => { + const host = createFakeHost({ + // The platform published nothing against Signage's renderer capability — the contributing + // module is absent, disabled, or outside the compatible range. + contributions: [], + permissions: ["signage.view"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "providers", + respond: () => + jsonResponse({ + providers: [ + { + consecutiveFailures: 0, + contractVersion: "1.0.0", + defaultDurationMs: 8000, + lastFailureAt: null, + lastFailureCode: null, + lastSuccessAt: "2026-08-30T15:00:00.000Z", + providerId: "fixture.announcements", + providerVersion: "1.0.0", + registeredAt: "2026-08-30T14:00:00.000Z", + renderer: { + contributionId: "fixture.announcements.slide", + contributionVersion: "1.0.0", + }, + }, + ], + }), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (tab) => tab.textContent === "Providers", - ), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Providers", + ), + ); - expect(container.textContent).toContain("Renderer not published"); - // Which is a fact only the browser holds: the server cannot know what the host published. - }); + expect(container.textContent).toContain("Renderer not published"); + // Which is a fact only the browser holds: the server cannot know what the host published. + }); - it("says Signage works without providers rather than looking broken", async () => { - const host = createFakeHost({ - permissions: ["signage.view"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, - ], - }); + it("says Signage works without providers rather than looking broken", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "providers", + respond: () => jsonResponse({ providers: [] }), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (tab) => tab.textContent === "Providers", - ), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Providers", + ), + ); - expect(container.textContent).toContain("No content providers registered"); - expect(container.textContent).toContain("Signage works on its own with native images"); - }); + expect(container.textContent).toContain("No content providers registered"); + expect(container.textContent).toContain( + "Signage works on its own with native images", + ); + }); - it("shows the display roster with online state and offline readiness", async () => { - const host = createFakeHost({ - permissions: ["signage.view", "signage.displays.manage"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { - method: "GET", - path: "viewers", - respond: () => - jsonResponse({ - viewers: [ - { - cachedAssetCount: 12, - channelId: "chan-1", - deviceId: null, - id: "viewer-1", - lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null }, - lastSeenAtMs: Date.now() - 4000, - name: "Lobby north", - offlineReady: true, - online: true, - reportedContributionId: null, - reportedProviderId: "fixture.announcements", - reportedSelectionId: "sel-1", - reportedTimelineId: "timeline-1", - }, - ], - }), - }, - ], - }); + it("shows the display roster with online state and offline readiness", async () => { + const host = createFakeHost({ + permissions: ["signage.view", "signage.displays.manage"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "viewers", + respond: () => + jsonResponse({ + viewers: [ + { + cachedAssetCount: 12, + channelId: "chan-1", + deviceId: null, + id: "viewer-1", + lastError: { + atMs: Date.now(), + code: "SLIDE_DECODE_FAILED", + detail: null, + }, + lastSeenAtMs: Date.now() - 4000, + name: "Lobby north", + offlineReady: true, + online: true, + reportedContributionId: null, + reportedProviderId: "fixture.announcements", + reportedSelectionId: "sel-1", + reportedTimelineId: "timeline-1", + }, + ], + }), + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Displays", + ), + ); - expect(container.textContent).toContain("Online"); - expect(container.textContent).toContain("12 cached"); - const display = container.querySelector("[data-testid='signage-display-viewer-1']"); - expect(display?.closest(".signage-channel")?.textContent).toContain("Default"); - // A sanitized code, never a stack or a message a browser happened to have. - expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); - }); + expect(container.textContent).toContain("Online"); + expect(container.textContent).toContain("12 cached"); + const display = container.querySelector( + "[data-testid='signage-display-viewer-1']", + ); + expect(display?.closest(".signage-channel")?.textContent).toContain( + "Default", + ); + // A sanitized code, never a stack or a message a browser happened to have. + expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); + }); - it("explains display registration and deletes a channel group only after confirmation", async () => { - const secondGroup = { - ...GROUP, - channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }], - id: "group-2", - name: "Foyer", - playback: { ...GROUP.playback, groupId: "group-2" }, - }; - let currentGroups = [GROUP, secondGroup]; - const deleted: string[] = []; - const host = createFakeHost({ - permissions: ["signage.view", "signage.displays.manage"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { - method: "GET", - path: "channel-groups", - respond: () => jsonResponse({ groups: currentGroups }), - }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) }, - { - method: "DELETE", - path: "channel-groups/group-1", - respond: ({ path }) => { - deleted.push(path); - currentGroups = [secondGroup]; - return new Response(null, { status: 204 }); - }, - }, - ], - }); + it("explains display registration and deletes a channel group only after confirmation", async () => { + const secondGroup = { + ...GROUP, + channels: [ + { id: "chan-2", isDefault: true, key: "default", name: "Default" }, + ], + id: "group-2", + name: "Foyer", + playback: { ...GROUP.playback, groupId: "group-2" }, + }; + let currentGroups = [GROUP, secondGroup]; + const deleted: string[] = []; + const host = createFakeHost({ + permissions: ["signage.view", "signage.displays.manage"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: currentGroups }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "viewers", + respond: () => jsonResponse({ viewers: [] }), + }, + { + method: "DELETE", + path: "channel-groups/group-1", + respond: ({ path }) => { + deleted.push(path); + currentGroups = [secondGroup]; + return new Response(null, { status: 204 }); + }, + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find( - (candidate) => candidate.textContent === "Displays", - ), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === "Displays", + ), + ); - const setupLink = [...container.querySelectorAll("a")].find( - (candidate) => candidate.textContent?.trim() === "Open display setup", - ); - expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); - expect(setupLink?.target).toBe("_blank"); - expect(setupLink?.rel).toContain("noopener"); - await click(buttonNamed("Copy URL")); - expect(copiedText).toEqual([DISPLAY_SETUP_URL]); - expect(container.textContent).toContain("Display setup URL copied."); - expect(container.textContent).toContain("Channel groups and displays"); - expect(container.textContent).toContain("Audience channels"); - expect(container.textContent).toContain("Channel name"); - expect(container.textContent).toContain("Audience key"); - expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers"); - await click(buttonNamed("Delete group")); - expect(deleted).toEqual([]); - expect(container.textContent).toContain("Displays assigned here will move"); - await click(buttonNamed("Confirm delete")); - expect(deleted).toEqual(["channel-groups/group-1"]); - }); + const setupLink = [...container.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Open display setup", + ); + expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); + expect(setupLink?.target).toBe("_blank"); + expect(setupLink?.rel).toContain("noopener"); + await click(buttonNamed("Copy URL")); + expect(copiedText).toEqual([DISPLAY_SETUP_URL]); + expect(container.textContent).toContain("Display setup URL copied."); + expect(container.textContent).toContain("Channel groups and displays"); + expect(container.textContent).toContain("Audience channels"); + expect(container.textContent).toContain("Channel name"); + expect(container.textContent).toContain("Audience key"); + expect(container.textContent).toContain( + "Advanced: lowercase ID sent to content providers", + ); + await click(buttonNamed("Delete group")); + expect(deleted).toEqual([]); + expect(container.textContent).toContain("Displays assigned here will move"); + await click(buttonNamed("Confirm delete")); + expect(deleted).toEqual(["channel-groups/group-1"]); + }); - it("uploads one request per file and reports each one's own outcome", async () => { - const uploaded: string[] = []; - const host = createFakeHost({ - permissions: ["signage.view", "signage.assets.manage"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, - { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, - { - method: "POST", - path: /^images\?/u, - respond: ({ path }) => { - uploaded.push(path); - return path.includes("broken") - ? errorResponse( - "SIGNAGE_IMAGE_MALFORMED", - "The uploaded file is not a decodable image.", - 422, - ) - : jsonResponse({ deduplicated: false, image: {} }, 201); - }, - }, - ], - }); + it("uploads one request per file and reports each one's own outcome", async () => { + const uploaded: string[] = []; + const host = createFakeHost({ + permissions: ["signage.view", "signage.assets.manage"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse(PLAYBACK_DETAIL), + }, + { + method: "GET", + path: "images", + respond: () => jsonResponse({ images: [] }), + }, + { + method: "POST", + path: /^images\?/u, + respond: ({ path }) => { + uploaded.push(path); + return path.includes("broken") + ? errorResponse( + "SIGNAGE_IMAGE_MALFORMED", + "The uploaded file is not a decodable image.", + 422, + ) + : jsonResponse({ deduplicated: false, image: {} }, 201); + }, + }, + ], + }); - await render(dashboard(host.context)); - await click( - [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), - ); + await render(dashboard(host.context)); + await click( + [...container.querySelectorAll("[role='tab']")].find( + (tab) => tab.textContent === "Images", + ), + ); - const input = container.querySelector( - "[data-testid='signage-upload-input']", - ) as HTMLInputElement; - // happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a - // real picker fires, with file-shaped values. - Object.defineProperty(input, "files", { - configurable: true, - value: [ - { name: "welcome.png", size: 1024, type: "image/png" }, - { name: "broken.png", size: 1024, type: "image/png" }, - ], - }); - await act(async () => { - input.dispatchEvent(new Event("change", { bubbles: true })); - }); - for (let index = 0; index < 10; index += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } + const input = container.querySelector( + "[data-testid='signage-upload-input']", + ) as HTMLInputElement; + // happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a + // real picker fires, with file-shaped values. + Object.defineProperty(input, "files", { + configurable: true, + value: [ + { name: "welcome.png", size: 1024, type: "image/png" }, + { name: "broken.png", size: 1024, type: "image/png" }, + ], + }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + for (let index = 0; index < 10; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } - // Two files, two requests — each with its own name in the query string and the bytes as the body. - expect(uploaded).toHaveLength(2); - expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true); - const uploads = container.querySelector("[data-testid='signage-uploads']"); - expect(uploads?.textContent).toContain("done"); - expect(uploads?.textContent).toContain("failed"); - expect(uploads?.textContent).toContain("The uploaded file is not a decodable image."); - }); + // Two files, two requests — each with its own name in the query string and the bytes as the body. + expect(uploaded).toHaveLength(2); + expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true); + const uploads = container.querySelector("[data-testid='signage-uploads']"); + expect(uploads?.textContent).toContain("done"); + expect(uploads?.textContent).toContain("failed"); + expect(uploads?.textContent).toContain( + "The uploaded file is not a decodable image.", + ); + }); - it("stays on screen when one panel's data cannot be loaded", async () => { - const host = createFakeHost({ - permissions: ["signage.view"], - routes: [ - { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, - { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, - { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, - { - method: "GET", - path: "playback/group-1", - respond: () => - errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500), - }, - ], - }); + it("stays on screen when one panel's data cannot be loaded", async () => { + const host = createFakeHost({ + permissions: ["signage.view"], + routes: [ + { + method: "GET", + path: "settings", + respond: () => jsonResponse(FAKE_SETTINGS), + }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [GROUP] }), + }, + { + method: "GET", + path: "timelines", + respond: () => jsonResponse({ timelines: [] }), + }, + { + method: "GET", + path: "playback/group-1", + respond: () => + errorResponse( + "SIGNAGE_OPERATION_FAILED", + "The operation could not be completed.", + 500, + ), + }, + ], + }); - await render(dashboard(host.context)); + await render(dashboard(host.context)); - expect(container.textContent).toContain("Signage could not load this"); - expect(container.textContent).toContain("The operation could not be completed."); - // The rest of the dashboard is still usable: one failing panel is not a failed screen. - expect(container.querySelectorAll("[role='tab']").length).toBe(5); - }); + expect(container.textContent).toContain("Signage could not load this"); + expect(container.textContent).toContain( + "The operation could not be completed.", + ); + // The rest of the dashboard is still usable: one failing panel is not a failed screen. + expect(container.querySelectorAll("[role='tab']").length).toBe(5); + }); }); diff --git a/tests/browser/support/fake-host.ts b/tests/browser/support/fake-host.ts index de1e3b6..ea561ab 100644 --- a/tests/browser/support/fake-host.ts +++ b/tests/browser/support/fake-host.ts @@ -1,10 +1,10 @@ import type { - ModuleContributionDescriptor, - ModuleUiApiClient, - ModuleUiContributions, - ModuleUiHostContext, - MountedModuleContribution, - MountModuleContributionOptions, + ModuleContributionDescriptor, + ModuleUiApiClient, + ModuleUiContributions, + ModuleUiHostContext, + MountedModuleContribution, + MountModuleContributionOptions, } from "@ekklesia/module-ui-sdk"; /** @@ -15,108 +15,132 @@ import type { * simply never match. */ export interface FakeHostRoute { - readonly method: string; - readonly path: string | RegExp; - readonly respond: (request: { - readonly path: string; - readonly init: RequestInit | undefined; - }) => Response | Promise; + readonly method: string; + readonly path: string | RegExp; + readonly respond: (request: { + readonly path: string; + readonly init: RequestInit | undefined; + }) => Response | Promise; } export interface FakeHost { - readonly context: ModuleUiHostContext; - readonly requests: { method: string; path: string }[]; - /** Contributions this host published against Signage's renderer capability. */ - readonly mounted: { contributionId: string; input: unknown }[]; + readonly context: ModuleUiHostContext; + readonly requests: { method: string; path: string }[]; + /** Contributions this host published against Signage's renderer capability. */ + readonly mounted: { contributionId: string; input: unknown }[]; } export function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - status, - }); + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); } -export function errorResponse(code: string, message: string, status = 400): Response { - return jsonResponse({ code, message }, status); +export function errorResponse( + code: string, + message: string, + status = 400, +): Response { + return jsonResponse({ code, message }, status); } export interface FakeHostOptions { - readonly permissions: readonly string[]; - readonly routes: readonly FakeHostRoute[]; - readonly contributions?: readonly ModuleContributionDescriptor[]; - readonly principal?: ModuleUiHostContext["principal"]; - readonly mount?: ( - options: MountModuleContributionOptions, - ) => Promise | MountedModuleContribution; + readonly permissions: readonly string[]; + readonly routes: readonly FakeHostRoute[]; + readonly contributions?: readonly ModuleContributionDescriptor[]; + readonly displayUrl?: string; + readonly principal?: ModuleUiHostContext["principal"]; + readonly mount?: ( + options: MountModuleContributionOptions, + ) => Promise | MountedModuleContribution; } export function createFakeHost(options: FakeHostOptions): FakeHost { - const requests: { method: string; path: string }[] = []; - const mounted: { contributionId: string; input: unknown }[] = []; + const requests: { method: string; path: string }[] = []; + const mounted: { contributionId: string; input: unknown }[] = []; - const api: ModuleUiApiClient = { - async request(path, init) { - const method = (init?.method ?? "GET").toUpperCase(); - const normalized = path.replace(/^\//u, ""); - requests.push({ method, path: normalized }); - for (const route of options.routes) { - const matches = - typeof route.path === "string" - ? route.path === normalized.split("?")[0] - : route.path.test(normalized); - if (route.method === method && matches) { - return route.respond({ init, path: normalized }); - } - } - return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404); - }, - }; + const api: ModuleUiApiClient = { + async request(path, init) { + const method = (init?.method ?? "GET").toUpperCase(); + const normalized = path.replace(/^\//u, ""); + requests.push({ method, path: normalized }); + for (const route of options.routes) { + const matches = + typeof route.path === "string" + ? route.path === normalized.split("?")[0] + : route.path.test(normalized); + if (route.method === method && matches) { + return route.respond({ init, path: normalized }); + } + } + return errorResponse( + "SIGNAGE_NOT_FOUND", + "The requested resource was not found.", + 404, + ); + }, + }; - const contributions: ModuleUiContributions = { - list: (capabilityId) => - capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], - async mount(mountOptions) { - mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input }); - if (options.mount) { - return options.mount(mountOptions); - } - return { - contributionId: mountOptions.contributionId, - mounted: true, - unmount: () => Promise.resolve(), - update: () => Promise.resolve(), - version: "1.0.0", - }; - }, - }; + const contributions: ModuleUiContributions = { + list: (capabilityId) => + capabilityId === "signage.slide-renderer" + ? (options.contributions ?? []) + : [], + async mount(mountOptions) { + mounted.push({ + contributionId: mountOptions.contributionId, + input: mountOptions.input, + }); + if (options.mount) { + return options.mount(mountOptions); + } + return { + contributionId: mountOptions.contributionId, + mounted: true, + unmount: () => Promise.resolve(), + update: () => Promise.resolve(), + version: "1.0.0", + }; + }, + }; - return { - context: { - api, - contributions, - moduleId: "signage", - permissions: { has: (permission) => options.permissions.includes(permission) }, - principal: options.principal ?? { kind: "anonymous" }, - realtime: { subscribe: () => ({ close: () => undefined }) }, - }, - mounted, - requests, - }; + return { + context: { + api, + contributions, + display: { + url: options.displayUrl ?? "https://api.example.test/display/signage", + }, + moduleId: "signage", + permissions: { + has: (permission) => options.permissions.includes(permission), + }, + principal: options.principal ?? { kind: "anonymous" }, + realtime: { subscribe: () => ({ close: () => undefined }) }, + }, + mounted, + requests, + }; } /** The settings payload every screen loads first. */ export const FAKE_SETTINGS = Object.freeze({ - crossfadeMs: 1000, - moduleVersion: "0.1.1", - nativeImageDurationMs: 7000, - nativePresentation: { - backgroundColor: "#000000", - backgroundDarkened: false, - backgroundMode: "blurred", - blurPixels: 20, - }, - rendererInputVersion: "1.0.0", - timezone: "America/Chicago", - uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, + crossfadeMs: 1000, + moduleVersion: "0.1.1", + nativeImageDurationMs: 7000, + nativePresentation: { + backgroundColor: "#000000", + backgroundDarkened: false, + backgroundMode: "blurred", + blurPixels: 20, + }, + rendererInputVersion: "1.0.0", + timezone: "America/Chicago", + uploads: { + batchMaxFiles: 20, + concurrency: 3, + maxBytes: 26_214_400, + maxPixels: 40_000_000, + }, });