From 6bf9167f85bffb25e4e6feb9384938c5c556a653 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:32:04 -0500 Subject: [PATCH 01/11] docs: add CLAUDE.md and mark display management as transitional Signage had no agent guidance at all. Add a CLAUDE.md that records the standalone-module architecture, that the parent ROADMAP.md and DESIGN.md are authoritative, the ownership, branding, token, dark-mode, no-native-dialog and plain-language rules, that display pairing is transitional until Stage 1 Settings -> Displays, and the branch-per-PR, no-push-to-main, no-release-tag workflow. The README listed a mobile management UI as out of scope, which contradicts the design contract's responsive requirement; it is a Stage 1 deferral, and now says so alongside how displays work today. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 23 ++++++++++++++-- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d959dec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +Guidance for Claude Code (and other coding agents) working in this repository. It points at +authoritative sources rather than restating them, because restated details drift. Inspect the actual +source, tests, `package.json`, and CI workflow before relying on anything here or on memory of a +prior session. + +## What this repository is + +Signage is a first-party Ekklesia **module** in its own repository (`Zayin/Signage`). It is not part of +the platform monorepo and vendors none of it: it builds against the published `@ekklesia/*` +module-development packages (platform ADRs 0053 and 0054), ships as one Ekklesia Module Package +(ADR 0049), and reaches PostgreSQL only through the Module Database ABI v1 executor the host hands it +(ADR 0050). Read `README.md` for what exists and the scope boundaries. + +Signage owns its domain: channel groups, audience channels, timelines, slots, signage assignments, +playback rules, native images, and **its own management and display UI**. The platform shell hosts +that UI through the generic module loader and never rebuilds it; do not move Signage screens into the +platform. + +## Product direction lives beside this repository + +The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the +parent development directory (`../ROADMAP.md` and `../DESIGN.md` in the standard +`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them in. +They are authoritative for product direction. If this repository's documentation disagrees with them, +flag the conflict and correct the repository-local documentation; never silently follow stale text. + +Rules from those documents that apply to every change here: + +- **Shared resources belong to the platform.** Displays/devices, identity, branding, mail, storage, + and module lifecycle are the platform's. Signage *consumes* displays; it assigns content to them. +- **The current display management is transitional.** Signage's viewer roster, display naming, and + pairing instructions, together with the platform's sidebar *Display devices* panel, are what exists + today. Stage 1 moves pairing and the one global display name to the platform's + **Settings → Displays**, and Signage then selects an existing display. Fix defects in the current + flow, keep existing paired displays, viewer identities, cached content, and assignments working, and + do **not** implement the Stage 1 Settings/Displays architecture early. +- **Branding.** Normal user-facing UI never hard-codes "Ekklesia" (platform ADR 0046). +- **Visual direction.** Simple and minimal; responsive; light, dark, and system modes. Normal UI + colours come only from the shared design tokens (`@ekklesia/design-tokens`) and shared elements + (`@ekklesia/ui`) — never a hard-coded colour. The display artifact's pure-black stage, blackout, + and slate are content presentation, not themed UI. +- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited. + Destructive actions use in-product confirmation proportional to impact: a two-step in-place + confirmation for moderate actions, the shared `Dialog` for significant ones. +- **Plain language.** Normal church staff must understand the UI without implementation terms; do not + expose an internal identifier merely because it exists. + +## ADRs + +`docs/architecture/decisions/` holds Signage's own ADRs; the platform's ADRs live in the Ekklesia +repository. Accepted ADRs are immutable except for status and cross-reference corrections. A changed +decision gets a new ADR that supersedes the old one; never rewrite a historical ADR to fit a change. + +## Working conventions + +- Canonical remote: `ssh://git@192.168.68.76:2222/Zayin/Signage.git` (the platform is + `Zayin/Ekklesia.git`). Local clones can be behind: `git fetch --prune --tags` and branch from + `origin/main`. +- One branch per PR off an up-to-date `origin/main`. Never push to `main`, never merge your own PR, + and never create or push a release tag — release tags belong to the human maintainer. +- A fix that needs a platform contract change is two PRs: the Ekklesia change first, then Signage, + with the compatibility boundary explicit. Do not copy platform logic into Signage to avoid it. +- Every defect fix gets a regression test. Signage never reads the environment, opens its own socket, + builds an Ekklesia URL, or applies a migration; all of that reaches it through the host. + +## Verification + +`package.json` and `.gitea/workflows/ci.yml` are the source of truth. From a clean checkout: + +```bash +pnpm install --frozen-lockfile +pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm validate +bash ci/check-cleanup.sh +``` + +The CI `integration` job installs the real Module Package into the pinned platform +(`ci/platform.env`) and drives the Dashboard and a kiosk display in real browsers, running the display +scenarios listed in `ci/display-scenarios` (`bash ci/package.sh`, `bash ci/fetch-platform.sh`, +`bash ci/integration.sh`; it needs Docker). Unit tests passing is not evidence that a display or +browser behaviour works: verify display behaviour through the integration harness. diff --git a/README.md b/README.md index f363e73..4df93ac 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,25 @@ description of the platform ABI. | Transitions, offline continuation, playback controls | Dynamic change within one slide | | The management and display UI | Its own internal multi-stage behaviour | +## Displays today, and what Stage 1 changes + +Displays are platform-owned. What exists now is transitional: a screen is paired in the Dashboard +sidebar's *Display devices* panel, and Signage keeps its own roster of the screens that have checked +in — their names, the group and audience each is assigned to, and their health. A running display +follows its assignment as soon as an administrator changes it, and a revoked display returns to its +pairing code. Entries for screens that are offline (a retired television, a test tab) can be removed +from the roster. + +The product roadmap moves pairing and the one global display name to the platform's +**Settings → Displays** in Stage 1; Signage will then select an existing display instead of naming its +own. Until then, existing paired displays, their viewer identities, cached content, and assignments are +preserved across every Signage upgrade. + +The management screen is currently desktop-only (the narrow-screen notice below 1024 px). The product +design requires phone and tablet administration and dark mode; that arrives with the Stage 1 shared +responsive Dashboard and design system rather than as a Signage-only layout. + ## Licence and scope -Deployment, the module store, a theme editor, a mobile management UI, video and audio, MQTT, Home -Assistant, Companion, and any migration of legacy Announcements data are explicitly out of scope for -this repository. +Deployment, the module store, a theme editor, video and audio, MQTT, Home Assistant, Companion, and any +migration of legacy Announcements data are explicitly out of scope for this repository. From 67d89f102026db251c0835d1e156ef7a8460d193 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:32:04 -0500 Subject: [PATCH 02/11] fix(display): keep blackout across reloads and follow the display's current assignment A kiosk that reloaded during a blackout restored its last frame from local state without the blackout, so the hidden slide flashed on the wall until the server answered. A display resolved its channel once, at registration. Moving it to another group or audience, or deleting the channel it was on, never reached a running screen: it kept showing the old channel, or fell into offline continuation against a 404 forever. The display now fetches its state by viewer, so the server resolves the current assignment on every fetch, and the heartbeat reply names the assigned channel so a reassignment lands within one heartbeat. A display removed from the roster registers again once, on backoff; only Signage's own not-found means removal, so a foreign 404 can never cause a registration loop. The channel route stays for the preview and for display code a kiosk cached earlier. A replaced realtime subscription can no longer schedule a stray resubscription when it reports closing. Co-Authored-By: Claude Opus 5.5 --- src/display/controller.ts | 174 ++++++++++++++++++++++--- src/server/routes/viewers.ts | 41 +++++- src/server/viewers/service.ts | 13 +- src/shared/display-protocol.ts | 21 ++- tests/browser/display-contract.test.ts | 47 ++++++- tests/browser/display-recovery.test.ts | 2 +- tests/browser/display.test.ts | 172 +++++++++++++++++++++++- tests/browser/offline.test.ts | 2 +- tests/manifest.test.ts | 1 + 9 files changed, 441 insertions(+), 32 deletions(-) diff --git a/src/display/controller.ts b/src/display/controller.ts index 2cdf6d3..a091fb8 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -20,6 +20,7 @@ import { signageMediaKey, } from "../browser/storage.js"; import { + assignedChannelOf, type DisplayChannelState, type DisplayHeartbeat, type DisplayRealtimeGroup, @@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp let stage: SceneStage | undefined; let session: DisplaySessionView | undefined; let subscription: { close(): void } | undefined; + /** Identifies the one subscription whose callbacks may still act. */ + let activeSubscription: symbol | undefined; let heartbeatHandle: unknown; let refreshHandle: unknown; let stopped = false; @@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp * each resets once its request works again. */ let sessionHandle: unknown; let sessionAttempt = 0; + /** One registration in flight at a time. A refresh and a heartbeat can both discover that this + * viewer was removed from the roster; registering twice would open two realtime subscriptions and + * two heartbeat timers for one screen. */ + let registering = false; + /** Consecutive times this screen found itself removed from the roster without a working fetch in + * between. Resets once a state fetch succeeds. */ + let rosterAttempt = 0; let refreshAttempt = 0; let realtimeHandle: unknown; let realtimeAttempt = 0; @@ -347,6 +357,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp // The crossfade is part of the persisted state too, so a screen that resumes offline fades the // way its operator configured rather than the way this file happened to default. stage.setCrossfade(snapshot.crossfadeMs); + // A deliberate blackout is persisted state like any other. Restoring it first means a kiosk that + // reloads mid-blackout stays black while it reconnects, instead of flashing the hidden frame. + stage.setBlackout(snapshot.playback.blackout); const atMs = serverNowFrom(snapshot, now()); const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable }); const resolution = evaluation.resolution; @@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp if (stopped || !session) { return; } + const viewerId = session.viewerId; try { - const response = await options.context.api.request( - `channels/${session.channelId}/display-state`, - ); + // Asked by viewer, not by channel: the server resolves where this screen is assigned *now*, + // so a reassignment or a deleted channel reaches a display that is already running. + const response = await options.context.api.request(`viewers/${viewerId}/display-state`); + if (await removedFromRoster(response)) { + // This screen was removed from the roster. It registers again with the identity it keeps, + // and keeps its frame meanwhile. + reregister(viewerId); + return; + } if (!response.ok) { await refreshFailed(); return; } const state = (await response.json()) as DisplayChannelStateView; refreshAttempt = 0; + rosterAttempt = 0; + adoptAssignment(state.channel); await apply(state); } catch { await refreshFailed(); } } + /** The server's answer about where this screen belongs wins, exactly as its playback state does. */ + function adoptAssignment(channel: DisplayChannelStateView["channel"]): void { + if (!session || (session.channelId === channel.id && session.groupId === channel.groupId)) { + return; + } + session = { + ...session, + channelId: channel.id, + channelKey: channel.key, + groupId: channel.groupId, + }; + } + + /** + * Only Signage's own "no such viewer" means the roster entry is gone. Any other 404 — a proxy, a + * route this server does not have — is an ordinary failure, retried with backoff like the rest; + * treating it as removal would re-register in a loop. + */ + async function removedFromRoster(response: Response): Promise { + if (response.status !== 404) { + return false; + } + const body = (await response + .clone() + .json() + .catch(() => null)) as { code?: unknown } | null; + return body?.code === "SIGNAGE_NOT_FOUND"; + } + + /** Drops a session the server no longer recognizes and registers again, once, on backoff. */ + function reregister(viewerId: string): void { + if (stopped || session?.viewerId !== viewerId) { + return; + } + endSession(); + // Backed off as well: a server that keeps forgetting this screen the moment it registers must + // cost a request a minute, not a tight loop. + const delay = rosterAttempt === 0 ? 0 : displayRetryDelayMs(rosterAttempt); + rosterAttempt += 1; + if (delay === 0) { + void register(); + return; + } + sessionHandle = setTimer(() => { + sessionHandle = undefined; + void register(); + }, delay); + } + + /** Releases everything a registration started: realtime, heartbeats, and pending retries. */ + function endSession(): void { + session = undefined; + activeSubscription = undefined; + subscription?.close(); + subscription = undefined; + if (heartbeatHandle !== undefined) { + clearInterval(heartbeatHandle as never); + heartbeatHandle = undefined; + } + if (realtimeHandle !== undefined) { + clearTimer(realtimeHandle); + realtimeHandle = undefined; + } + } + /** * The server is unreachable, or answered with an error. The frame stays and local continuation * takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried @@ -545,26 +632,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp * state — exactly as if it had worked the first time. */ async function register(): Promise { - if (stopped || session) { + if (stopped || session || registering) { return; } + registering = true; let registered: DisplaySessionView | undefined; try { - const response = await options.context.api.request("viewers/session", { - body: JSON.stringify({ viewerKey: await viewerKey() }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - if (response.ok) { - // Read, not cast: a registration the display cannot understand is no registration, and is - // tried again like any other failure. - const parsed = parseViewerSessionResponse(await response.json()); - registered = parsed.ok ? parsed.value : undefined; - } - } catch { - registered = undefined; + registered = await requestSession(); + } finally { + registering = false; } - if (stopped) { + if (stopped || session) { return; } if (!registered) { @@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp report(); } + async function requestSession(): Promise { + let registered: DisplaySessionView | undefined; + try { + const response = await options.context.api.request("viewers/session", { + body: JSON.stringify({ viewerKey: await viewerKey() }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (response.ok) { + // Read, not cast: a registration the display cannot understand is no registration, and is + // tried again like any other failure. + const parsed = parseViewerSessionResponse(await response.json()); + registered = parsed.ok ? parsed.value : undefined; + } + } catch { + registered = undefined; + } + return registered; + } + /** * Subscribes to the display's realtime channel, and again whenever the host reports that a * subscription has ended for good. The host retries ordinary disconnects itself; a stream that @@ -600,10 +698,21 @@ export function createDisplayController(options: DisplayControllerOptions): Disp if (stopped || !session) { return; } + // Only the newest subscription may act. Closing the old one can report "closed" synchronously, + // and that report must not schedule a second resubscription on top of this one. + const token = Symbol("subscription"); + activeSubscription = token; subscription?.close(); subscription = options.context.realtime.subscribe("display-state", { - onSnapshot: (state) => onRealtime(state), + onSnapshot: (state) => { + if (activeSubscription === token) { + onRealtime(state); + } + }, onStatus: (status) => { + if (activeSubscription !== token) { + return; + } online = status === "connected"; if (status === "connected") { realtimeAttempt = 0; @@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp } report(); }, - onUpdate: (state) => onRealtime(state), + onUpdate: (state) => { + if (activeSubscription === token) { + onRealtime(state); + } + }, }); } @@ -639,11 +752,29 @@ export function createDisplayController(options: DisplayControllerOptions): Disp reportedSelectionId: renderedSelectionId ?? null, reportedTimelineId: lastState?.playback.activeTimelineId ?? null, }; - await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, { + const viewerId = session.viewerId; + const response = await options.context.api.request(`viewers/${viewerId}/heartbeat`, { body: JSON.stringify(report), headers: { "content-type": "application/json" }, method: "POST", }); + if (await removedFromRoster(response)) { + reregister(viewerId); + return; + } + if (!response.ok) { + return; + } + // The heartbeat is also how a screen learns it was reassigned while its slot is long, paused, + // or held — times when no state fetch is otherwise due. + const assigned = assignedChannelOf(await response.json().catch(() => undefined)); + if ( + assigned !== undefined && + session?.viewerId === viewerId && + assigned !== session.channelId + ) { + void refresh(); + } } catch { // Telemetry is best effort and never affects what is on screen. } @@ -695,6 +826,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp async stop(): Promise { stopped = true; + activeSubscription = undefined; subscription?.close(); subscription = undefined; if (heartbeatHandle !== undefined) { diff --git a/src/server/routes/viewers.ts b/src/server/routes/viewers.ts index 0c079c7..aec2bea 100644 --- a/src/server/routes/viewers.ts +++ b/src/server/routes/viewers.ts @@ -18,6 +18,7 @@ import { describeViewerHealth, openViewerSession, recordViewerHeartbeat, + resolveViewerSession, SIGNAGE_VIEWER_NAME_MAX, updateViewerAssignment, } from "../viewers/service.js"; @@ -123,12 +124,50 @@ export function registerViewerRoutes( }); // The response says what a display needs to keep itself honest, and nothing about other // displays or the installation. - const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; + const assigned = await resolveViewerSession(store(context), result.viewer); + const response: DisplayHeartbeatResponse = { + acknowledged: true, + channelId: assigned.channel.id, + recorded: result.written, + }; return Response.json(response); }); }, }); + http.route({ + method: "GET", + path: "/viewers/:viewerId/display-state", + permission: "signage.display.view", + maxBodyBytes: 0, + handler(_request, context) { + return signageBoundary(async () => { + const viewerId = requireUuidParam(context, "viewerId"); + const signageStore = store(context); + const viewer = await signageStore.findViewer(viewerId); + if (!viewer) { + // Removed from the roster. The display registers again with the identity it keeps. + throw new SignageError("SIGNAGE_NOT_FOUND"); + } + // Resolved on every fetch, never remembered from registration: an administrator who moves + // a screen, or deletes the channel it was on, changes what it shows without a reboot. + const { channel } = await resolveViewerSession(signageStore, viewer); + const snapshot = await playback().snapshot(channel.groupId); + return Response.json( + await buildDisplayChannelState({ + atMs: Date.now(), + channel, + playback: snapshot, + registry: registry(), + settings: readSignageSettings(context.config), + storage: context.storage, + store: signageStore, + }), + ); + }); + }, + }); + http.route({ method: "GET", path: "/channels/:channelId/display-state", diff --git a/src/server/viewers/service.ts b/src/server/viewers/service.ts index 0f0a94d..0d611fb 100644 --- a/src/server/viewers/service.ts +++ b/src/server/viewers/service.ts @@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise { +/** + * Where a known viewer is assigned **now**: its own channel, or the default channel of the default + * group when that channel no longer exists. A display asks this on every state fetch, which is what + * makes an administrator's reassignment — or deleting the channel a screen was on — reach a display + * that is already running, rather than only the next time it boots. + */ +export async function resolveViewerSession( + store: SignageStore, + viewer: StoredViewer, +): Promise { const groups = await store.listChannelGroups(); for (const group of groups) { const channel = group.channels.find((candidate) => candidate.id === viewer.channelId); diff --git a/src/shared/display-protocol.ts b/src/shared/display-protocol.ts index e165d9c..0ce6413 100644 --- a/src/shared/display-protocol.ts +++ b/src/shared/display-protocol.ts @@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult 0 && channelId.length <= 64 + ? channelId + : undefined; +} + +// --- Channel state: GET viewers/:viewerId/display-state (and channels/:channelId/display-state) ----- +// +// A registered display asks by its own viewer id, so the server resolves which channel it is assigned +// to at that moment. The channel route remains for the management preview and for display code a +// kiosk cached before the viewer route existed. /** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */ export interface DisplayMedia { diff --git a/tests/browser/display-contract.test.ts b/tests/browser/display-contract.test.ts index 09e3c2a..0f5b5c7 100644 --- a/tests/browser/display-contract.test.ts +++ b/tests/browser/display-contract.test.ts @@ -200,7 +200,8 @@ describe("display ↔ server contract", () => { const sent = calls.map((call) => `${call.method} ${call.path}`); expect(sent).toContain("POST /viewers/session"); - expect(sent).toContain("GET /channels/:channelId/display-state"); + // By viewer, so the server — not the display's memory of its registration — decides the channel. + expect(sent).toContain("GET /viewers/:viewerId/display-state"); expect(sent).toContain("POST /viewers/:viewerId/heartbeat"); // Not one refusal, of any kind, for anything the display built itself. expect(calls.filter((call) => call.status >= 400)).toEqual([]); @@ -220,4 +221,48 @@ describe("display ↔ server contract", () => { expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID); expect(viewer?.reportedContributionId).toBeNull(); }); + + it("a display moved to another channel group is served that group's state on its next fetch", async () => { + const display = controller(); + await display.start(); + await settle(); + const viewerId = display.diagnostics().viewerId; + const firstChannel = display.diagnostics().channelId; + expect(viewerId).toBeDefined(); + + // An administrator creates a second group and moves this screen to it while it is running. + const groupId = newId(); + const channelId = newId(); + await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" }); + await store.updateViewer(viewerId as string, { channelId }); + + const response = await host.context.api.request(`viewers/${viewerId}/display-state`); + const state = (await response.json()) as { channel: { id: string; groupId: string } }; + expect(state.channel).toMatchObject({ groupId, id: channelId }); + expect(channelId).not.toBe(firstChannel); + + // And the heartbeat names the new assignment, which is how a display with a long slot hears. + const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST", + }); + expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId); + await display.stop(); + }); + + it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => { + const display = controller(); + await display.start(); + await settle(); + const viewerId = display.diagnostics().viewerId as string; + await store.updateViewer(viewerId, { channelId: null }); + + const response = await host.context.api.request(`viewers/${viewerId}/display-state`); + expect(response.status).toBe(200); + const [group] = await store.listChannelGroups(); + const fallback = group?.channels.find((channel) => channel.isDefault); + expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id); + await display.stop(); + }); }); diff --git a/tests/browser/display-recovery.test.ts b/tests/browser/display-recovery.test.ts index c138491..68908eb 100644 --- a/tests/browser/display-recovery.test.ts +++ b/tests/browser/display-recovery.test.ts @@ -145,7 +145,7 @@ describe("a display recovers from every failed request without a reload", () => }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => { server.stateRequests += 1; if (server.stateFailing) { diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts index 6b819f0..2ec7560 100644 --- a/tests/browser/display.test.ts +++ b/tests/browser/display.test.ts @@ -174,7 +174,7 @@ describe("display controller", () => { }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -205,7 +205,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -376,6 +376,28 @@ describe("display controller", () => { await controller.stop(); }); + it("restores a blackout before the server answers, so a reload never flashes content", async () => { + const store = createMemoryStore(); + const media = createMemoryMediaCache(); + const online = createFakeHost({ permissions: [], routes: [] }); + const before = build({ host: online, media, store, telemetry: false }); + await before.start(); + await before.apply(state({ playback: { blackout: true } as never })); + await settle(); + await before.stop(); + + // A kiosk reloads mid-blackout and its first request has not been answered yet. + root.dataset.blackout = "false"; + const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] }); + Object.assign(pending.context.api, { request: () => new Promise(() => undefined) }); + const after = build({ host: pending, media, store }); + void after.start(); + await settle(); + + expect(root.dataset.blackout).toBe("true"); + await after.stop(); + }); + it("reports a bounded, sanitized heartbeat and nothing else", async () => { const beats: unknown[] = []; const host = createFakeHost({ @@ -384,7 +406,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { @@ -428,7 +450,7 @@ describe("display controller", () => { { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => jsonResponse(state()), }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, @@ -471,4 +493,146 @@ describe("display controller", () => { expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); await controller.stop(); }); + + it("follows the server when an administrator moves it to another channel", async () => { + const moved = { groupId: "group-2", id: "chan-2", key: "online" }; + let stateFetches = 0; + let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + stateFetches += 1; + // Registered on chan-1; moved by an administrator right after its first fetch. + return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved })); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }), + }, + ], + }); + Object.assign(host.context.realtime, { + subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { + realtimeHandlers = handlers; + return { close: () => undefined }; + }, + }); + const controller = build({ host }); + await controller.start(); + await settle(); + + // The heartbeat named a different channel, so the display refetched and adopted it. + expect(stateFetches).toBe(2); + expect(controller.diagnostics().channelId).toBe("chan-2"); + + // A change in the old group no longer concerns it; a change in the new one does. + realtimeHandlers?.onUpdate?.({ + groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }], + }); + await settle(); + expect(stateFetches).toBe(2); + realtimeHandlers?.onUpdate?.({ + groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }], + }); + await settle(); + expect(stateFetches).toBe(3); + await controller.stop(); + }); + + it("registers again, exactly once, when it was removed from the roster", async () => { + let sessions = 0; + let subscriptions = 0; + let closes = 0; + let removed = false; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + sessions += 1; + removed = false; + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => + removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()), + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => + removed + ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) + : jsonResponse({ acknowledged: true }), + }, + ], + }); + let onUpdate: ((state: unknown) => void) | undefined; + Object.assign(host.context.realtime, { + subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => { + subscriptions += 1; + onUpdate = handlers.onUpdate; + return { close: () => (closes += 1) }; + }, + }); + const controller = build({ host }); + await controller.start(); + await settle(); + expect(sessions).toBe(1); + + // Removed from the roster; a realtime nudge and a heartbeat both discover it at once. + removed = true; + onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] }); + onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] }); + await settle(); + + expect(sessions).toBe(2); + // One live subscription: the old one closed, exactly one new one opened. + expect(subscriptions - closes).toBe(1); + expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); + await controller.stop(); + }); + + it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => { + let sessions = 0; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + sessions += 1; + return jsonResponse(SESSION); + }, + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + // A proxy, or a server without this route — not "this viewer is gone". + respond: () => jsonResponse({ code: "NOT_FOUND" }, 404), + }, + { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, + ], + }); + const controller = build({ host }); + await controller.start(); + await settle(); + await settle(); + + expect(sessions).toBe(1); + await controller.stop(); + }); }); diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index 65b0702..c24703e 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -391,7 +391,7 @@ describe("a display that loses its network", () => { }, { method: "GET", - path: "channels/chan-1/display-state", + path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u, respond: () => { if (!reachable) { throw new Error("network is gone"); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 07c71a4..5a8e8ab 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -177,6 +177,7 @@ describe("Signage server runtime", () => { "GET /timelines", "GET /timelines/:timelineId", "GET /viewers", + "GET /viewers/:viewerId/display-state", "PATCH /images/:imageId", "PATCH /timelines/:timelineId", "PATCH /timelines/:timelineId/slots/:slotId", From 7c172826162825796fc267d9e7e21fd3183da4d5 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:32:04 -0500 Subject: [PATCH 03/11] fix(ui): keep Signage state consistent across tabs, and confirm destructive actions Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 --- src/browser/desktop-gate.tsx | 2 +- src/ui/SignageDashboard.tsx | 333 +++-- src/ui/hooks.ts | 75 +- src/ui/index.tsx | 38 +- src/ui/panels/DisplaysPanel.tsx | 1039 +++++++------- src/ui/panels/ImagesPanel.tsx | 22 +- src/ui/panels/PlaybackPanel.tsx | 657 +++++---- src/ui/panels/PreviewPanel.tsx | 12 +- src/ui/panels/ProvidersPanel.tsx | 191 ++- src/ui/panels/TimelinesPanel.tsx | 995 +++++++------- src/ui/panels/shared.tsx | 81 +- src/ui/styles.css | 836 ++++++------ tests/browser/management-ui.test.tsx | 1566 +++++++++++----------- tests/browser/stage0-management.test.tsx | 414 ++++++ tests/browser/support/fake-host.ts | 216 ++- tests/drizzle-timelines.test.ts | 3 +- 16 files changed, 3446 insertions(+), 3034 deletions(-) create mode 100644 tests/browser/stage0-management.test.tsx diff --git a/src/browser/desktop-gate.tsx b/src/browser/desktop-gate.tsx index 0af2331..9df9423 100644 --- a/src/browser/desktop-gate.tsx +++ b/src/browser/desktop-gate.tsx @@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea

Signage needs a desktop screen

- {`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`} + {`Open this page on a screen at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, displays, and playback.`}

diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 5a49bdc..7260f12 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -1,9 +1,9 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui"; -import { type ReactElement, useMemo, useState } from "react"; +import { type ReactElement, useContext, useMemo, useState } from "react"; import { DesktopOnly } from "../browser/desktop-gate.js"; import { createSignageApi } from "./api.js"; -import { useResource } from "./hooks.js"; +import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js"; import { DisplaysPanel } from "./panels/DisplaysPanel.js"; import { ImagesPanel } from "./panels/ImagesPanel.js"; import { PlaybackPanel } from "./panels/PlaybackPanel.js"; @@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js"; * Permission checks here decide what an operator is *offered*. The server decides what happens: a * hidden button is a courtesy, and every route is enforced regardless of what this believes. */ -export function SignageDashboard({ - context, - copyText, -}: { - readonly context: ModuleUiHostContext; - readonly copyText: (value: string) => Promise; +export function SignageDashboard(props: { + 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); + // One cache for the life of this screen: everything the panels have loaded, shared between them. + const [cache] = useState(createResourceCache); + return ( + + + + ); +} - 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"); +function SignageWorkspace({ + context, + copyText, +}: { + readonly context: ModuleUiHostContext; + readonly copyText: (value: string) => Promise; +}): ReactElement { + const api = useMemo(() => createSignageApi(context.api), [context.api]); + const cache = useContext(ResourceCacheContext); + // 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], + mayView ? "settings" : undefined, + ); + const groups = useResource( + () => + mayView + ? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups") + : Promise.resolve(undefined), + [mayView], + mayView ? "channel-groups" : undefined, + ); + const [tab, setTab] = useState("playback"); + const [groupId, setGroupId] = useState(undefined); - if (!mayView) { - return ( - - ); - } + 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 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; + if (!mayView) { + return ( + + ); + } - 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 }, - )} -
- - ); + 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", + }); + // Playback opens on what is now true, not on the + // timeline it was showing before. + cache?.write( + `playback/${selectedGroupId}`, + await api.get(`playback/${selectedGroupId}`), + ); + 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/hooks.ts b/src/ui/hooks.ts index f24f70f..4830e08 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { type SignageApiFailure, SignageRequestError } from "./api.js"; /** @@ -21,15 +21,76 @@ export interface Resource { reload(): void; } +/** + * The last value each Signage resource loaded, shared by every panel on the screen. + * + * The tabs mount only the selected panel, so without this every tab switch threw its data away, + * flashed a spinner, and collapsed the page to its height before growing again — the "jumping" an + * operator saw. It also let two panels disagree: activating a timeline from Playback left Timelines + * showing the old one as inactive until a reload. Now a remounted panel starts from what was last + * known and refreshes quietly, and a reload anywhere updates every panel reading the same resource. + */ +export interface ResourceCache { + read(key: string): { readonly value: unknown } | undefined; + write(key: string, value: unknown): void; + subscribe(key: string, listener: (value: unknown) => void): () => void; +} + +export function createResourceCache(): ResourceCache { + const values = new Map(); + const listeners = new Map void>>(); + return { + read: (key) => values.get(key), + subscribe(key, listener) { + const set = listeners.get(key) ?? new Set(); + set.add(listener); + listeners.set(key, set); + return () => { + set.delete(listener); + if (set.size === 0) { + listeners.delete(key); + } + }; + }, + write(key, value) { + values.set(key, { value }); + for (const listener of [...(listeners.get(key) ?? [])]) { + listener(value); + } + }, + }; +} + +export const ResourceCacheContext = createContext(undefined); + export function useResource( load: () => Promise, dependencies: readonly unknown[], + /** Shares this resource with every other panel that names the same key. */ + key?: string, ): Resource { - const [state, setState] = useState>({ status: "loading" }); + const cache = useContext(ResourceCacheContext); + const cached = key === undefined ? undefined : cache?.read(key); + const [state, setState] = useState>( + cached === undefined + ? { status: "loading" } + : { status: "ready", value: cached.value as Value }, + ); const generation = useRef(0); const loadRef = useRef(load); loadRef.current = load; + useEffect(() => { + if (key === undefined || !cache) { + return; + } + const current = cache.read(key); + if (current !== undefined) { + setState({ status: "ready", value: current.value as Value }); + } + return cache.subscribe(key, (value) => setState({ status: "ready", value: value as Value })); + }, [cache, key]); + const run = useCallback(() => { generation.current += 1; const token = generation.current; @@ -38,7 +99,13 @@ export function useResource( .then((value) => { // A response from a request the panel has already moved on from must never overwrite a // newer one. - if (token === generation.current) { + if (token !== generation.current) { + return; + } + if (key !== undefined && cache) { + // Every panel subscribed to this key, including this one, is updated by the write. + cache.write(key, value); + } else { setState({ status: "ready", value }); } }) @@ -58,7 +125,7 @@ export function useResource( status: "failed", }); }); - }, []); + }, [cache, key]); // The caller's dependency list is spread deliberately: a resource reloads when *its* inputs // change, which is the whole reason this takes one. diff --git a/src/ui/index.tsx b/src/ui/index.tsx index 5a4cc88..42ace41 100644 --- a/src/ui/index.tsx +++ b/src/ui/index.tsx @@ -14,24 +14,22 @@ import ownStyles from "./styles.css"; * rather than bundled (ADR 0039). */ export default defineModuleUi({ - id: "signage", - version: "0.1.4", - 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(); - }, + id: "signage", + version: "0.1.4", + 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 7a52c36..9ea480e 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -1,102 +1,118 @@ -import { - Badge, - Button, - Field, - InlineNotice, - 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 { ConfirmButton, FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +/** + * The key content providers use to pick an audience's version of a slide, derived from the name an + * operator typed. It is an implementation detail: staff name an audience ("Online"), and only an + * operator who knows a provider expects a particular key needs the Advanced override. + */ +export function audienceKeyFor(name: string, taken: readonly string[]): string { + const base = + name + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 48) + .replace(/-+$/u, "") || "audience"; + const start = /^[a-z]/u.test(base) ? base : `audience-${base}`; + const reserved = new Set([...taken, "default"]); + let candidate = start; + for (let suffix = 2; reserved.has(candidate); suffix += 1) { + candidate = `${start}-${suffix}`; + } + return candidate; +} + function DisplayRow({ - canManage, - groups, - nowMs, - onChannelChange, - onNameChange, - viewer, + canManage, + groups, + nowMs, + onChannelChange, + onNameChange, + onRemove, + 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 onRemove: () => 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} + )} + + {viewer.deviceId === null ? ( + // Opened by someone signed in — a test tab on a laptop, not a paired screen. + Signed-in browser + ) : undefined} +
+
+ {canManage ? ( + + {(control) => ( + + )} + + ) : undefined} +
+
+ {`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} + + {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} + + {viewer.lastError === null ? undefined : ( + + {viewer.lastError.code} + + )} + {canManage && !viewer.online ? ( + // Only an offline entry: a screen that is still connected would simply reappear. + + ) : undefined} +
+
+ ); } /** @@ -104,459 +120,410 @@ 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 | undefined; - 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"), + [], + "channel-groups", + ); + const viewers = useResource( + () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), + [], + "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)); + }; + const removeViewer = (viewerId: string): void => { + viewerEdit.run(() => api.send("DELETE", `viewers/${viewerId}`)); + }; - 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} - + return ( +
+ + + + Preview here + +
+ ) + } + description="Connect a screen in three short steps." + title="Pair a display" + > +
    +
  1. + 1 + + On the screen that will show signage, open the display setup address. Copy it from + here; opening it on this computer only previews the display. + +
  2. +
  3. + 2 + Enter its six-digit code under Display devices in the Dashboard sidebar. +
  4. +
  5. + 3 + Name the display and choose its group below. +
  6. +
+ {displaySetupUrl === undefined ? ( + + This host did not provide a display setup address. + + ) : undefined} + + {copyStatus === "copied" + ? "Display setup address copied." + : copyStatus === "failed" + ? "Could not copy the address. Use Preview here and copy it from the address bar." + : ""} + + {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="Displays in one group play together, on the same slide at the same moment. Use another group for screens that should run their own loop." + 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} - /> - )) - )} -
-
- ); - })} -
+
+
+
+ Audiences +

Who is watching these displays

+
+ + Every display in this group shows the same slide at the same moment. Add + an audience only when some screens should get a different version of it, + such as wording for people watching online. Images look the same for every + audience. + +
+
+ {group.channels.map((channel) => { + const assigned = readyViewers.filter( + (viewer) => viewer.channelId === channel.id, + ); + return ( +
+
+
+
{channel.name}
+
+ {channel.isDefault ? ( + Default audience + ) : undefined} + + {`${assigned.length} display${assigned.length === 1 ? "" : "s"}`} + +
+
+ {canManage && !channel.isDefault ? ( + + edit.run(() => api.send("DELETE", `channels/${channel.id}`)) + } + testId={`signage-channel-remove-${channel.id}`} + /> + ) : undefined} +
+
+ Displays + {assigned.length === 0 ? ( +

No displays assigned.

+ ) : ( + assigned.map((viewer) => ( + + updateViewer(viewer.id, { channelId }) + } + onNameChange={(name) => updateViewer(viewer.id, { name })} + onRemove={() => removeViewer(viewer.id)} + 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
+ The default audience always remains. +
+ + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: current.groupId === group.id ? current.key : "", + name: event.target.value, + })) + } + placeholder="Online" + value={channelDraft.groupId === group.id ? channelDraft.name : ""} + /> + )} + +
+ Advanced + channel.key), + )}.`} + label="Content key" + > + {(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]+)*" + 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 })} + onRemove={() => removeViewer(viewer.id)} + 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/ImagesPanel.tsx b/src/ui/panels/ImagesPanel.tsx index 2febcc7..e226190 100644 --- a/src/ui/panels/ImagesPanel.tsx +++ b/src/ui/panels/ImagesPanel.tsx @@ -9,7 +9,7 @@ import { import type { SignageApi } from "../api.js"; import { useAction, useResource } from "../hooks.js"; import type { ImageRecord, SettingsResponse } from "../types.js"; -import { FailureNote, formatInstant, renderLoadable } from "./shared.js"; +import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js"; /** * The native image library and its upload queue. @@ -28,7 +28,11 @@ export function ImagesPanel({ readonly settings: SettingsResponse; readonly canManage: boolean; }): ReactElement { - const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); + const images = useResource( + () => api.get<{ images: readonly ImageRecord[] }>("images"), + [], + "images", + ); const [uploads, setUploads] = useState([]); const queueRef = useRef(undefined); const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined); @@ -204,13 +208,15 @@ export function ImagesPanel({ Rename )} - + label="Delete" + onConfirm={() => + remove.run(() => api.send("DELETE", `images/${image.id}`)) + } + testId={`signage-image-delete-${image.id}`} + /> ) : undefined} diff --git a/src/ui/panels/PlaybackPanel.tsx b/src/ui/panels/PlaybackPanel.tsx index 80edcf1..2150099 100644 --- a/src/ui/panels/PlaybackPanel.tsx +++ b/src/ui/panels/PlaybackPanel.tsx @@ -1,29 +1,16 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; -import { - ActionBar, - Badge, - Button, - EmptyState, - Field, - Panel, - Select, -} from "@ekklesia/ui"; -import { type ReactElement, useState } from "react"; +import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui"; +import { type ReactElement, useEffect, useRef, 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. @@ -33,325 +20,333 @@ import { * 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"), + [], + "channel-groups", + ); + const timelines = useResource( + () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), + [], + "timelines", + ); + const detail = useResource( + () => + groupId === undefined + ? Promise.resolve(undefined) + : api.get(`playback/${groupId}`), + [groupId], + groupId === undefined ? undefined : `playback/${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); + useManagementUpdates(context, groupId, () => { + detail.reload(); + groups.reload(); + }); - 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 ? ( - - ) : ( -
-

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, - )} -
-
-
- )} + {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 }, + ) + )} + + - -
- ); + +
+ ); +} + +/** + * Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks + * for fresh detail when this group's playback actually changed — so "Now showing" follows the wall + * instead of freezing until an operator presses something. One subscription per mounted panel, + * closed when the panel goes away. + */ +function useManagementUpdates( + context: ModuleUiHostContext, + groupId: string | undefined, + onChange: () => void, +): void { + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + useEffect(() => { + if (groupId === undefined) { + return; + } + let seen: string | undefined; + const subscription = context.realtime.subscribe("management-state", { + onSnapshot: (payload) => observe(payload), + onUpdate: (payload) => observe(payload), + }); + function observe(payload: unknown): void { + const groups = (payload as { groups?: unknown } | null)?.groups; + if (!Array.isArray(groups)) { + return; + } + const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as + | { revision?: unknown; selectionRevision?: unknown } + | undefined; + if (!mine) { + return; + } + const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`; + if (seen !== undefined && marker !== seen) { + onChangeRef.current(); + } + seen = marker; + } + return () => subscription.close(); + }, [context, groupId]); } diff --git a/src/ui/panels/PreviewPanel.tsx b/src/ui/panels/PreviewPanel.tsx index 09c8b66..ba52dcb 100644 --- a/src/ui/panels/PreviewPanel.tsx +++ b/src/ui/panels/PreviewPanel.tsx @@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { EmptyState, Panel } from "@ekklesia/ui"; import { type ReactElement, useEffect, useRef } from "react"; import { SignageSlate } from "../../browser/slate.js"; +import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js"; import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js"; import type { SignageApi } from "../api.js"; @@ -36,7 +37,16 @@ export function PreviewPanel({ return; } let stopped = false; - const controller = createDisplayController({ context, root, telemetry: false }); + // The display's own rendering code, but nothing it keeps: a management browser is not a wall, + // so it persists no snapshot and caches no image bytes, and never restores one channel's last + // frame into another channel's preview. + const controller = createDisplayController({ + context, + media: createMemoryMediaCache(), + root, + store: createMemoryStore(), + telemetry: false, + }); let handle: ReturnType | undefined; async function pump(): Promise { diff --git a/src/ui/panels/ProvidersPanel.tsx b/src/ui/panels/ProvidersPanel.tsx index a69e0aa..0adbe13 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,101 +31,88 @@ 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"), + [], + "providers", + ); + const published = new Set( + context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), + ); - 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 }, - )} -
- ); + 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 70911de..77f8f28 100644 --- a/src/ui/panels/TimelinesPanel.tsx +++ b/src/ui/panels/TimelinesPanel.tsx @@ -1,35 +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, + ConfirmButton, + FailureNote, + formatDuration, + formatInstant, + renderLoadable, } from "./shared.js"; const NATIVE_PROVIDER_ID = "signage.native-images"; @@ -43,523 +44,467 @@ 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"), + [], + "timelines", + ); + const providers = useResource( + () => api.get<{ providers: readonly ProviderHealth[] }>("providers"), + [], + "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], + `timelines/${timelineId}`, + ); + const images = useResource( + () => api.get<{ images: readonly ImageRecord[] }>("images"), + [], + "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 ? ( + <> + + + edit.run(() => + api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`), + ) + } + /> + + ) : 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 ? ( +
+ + + + edit.run(() => + api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`), + ) + } + /> +
+ ) : undefined} +
+
+ ); + })} +
+
+ )} - {canManage ? ( -
- - -
- ) : undefined} -
- ), - { label: "Loading timeline…", onRetry: detail.reload }, - )} - -
- ); + {canManage ? ( +
+ + + edit.run(async () => { + await api.send("DELETE", `timelines/${timelineId}`); + onClose(); + }) + } + testId="signage-timeline-delete" + /> +
+ ) : 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/panels/shared.tsx b/src/ui/panels/shared.tsx index 961ca61..231af63 100644 --- a/src/ui/panels/shared.tsx +++ b/src/ui/panels/shared.tsx @@ -1,5 +1,5 @@ -import { Badge, ErrorState, LoadingState } from "@ekklesia/ui"; -import type { ReactElement, ReactNode } from "react"; +import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui"; +import { type ReactElement, type ReactNode, useEffect, useState } from "react"; import type { SignageApiFailure } from "../api.js"; import type { Loadable } from "../hooks.js"; @@ -89,3 +89,80 @@ export function formatSince(atMs: number | null, nowMs: number): string { export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement { return {online ? "Online" : "Offline"}; } + +/** How long an armed destructive button waits for its second press before standing down. */ +export const CONFIRM_RESET_MS = 6000; + +/** + * A moderate destructive action, confirmed in place (DESIGN §21.3) — never with a browser dialog. + * + * The first press arms it: the button says plainly what will happen and a Cancel appears beside it. + * A second, deliberate press performs the action. Walking away, pressing Cancel, or Escape disarms + * it, so an armed button can never be left waiting for an accidental click. + */ +export function ConfirmButton({ + label, + confirmLabel, + disabled, + onConfirm, + testId, +}: { + readonly label: string; + /** What the second press does, in words: "Delete this image", not "Are you sure?". */ + readonly confirmLabel: string; + readonly disabled?: boolean; + readonly onConfirm: () => void; + readonly testId?: string; +}): ReactElement { + const [armed, setArmed] = useState(false); + useEffect(() => { + if (!armed) { + return; + } + const timer = setTimeout(() => setArmed(false), CONFIRM_RESET_MS); + return () => clearTimeout(timer); + }, [armed]); + + if (!armed) { + return ( + + ); + } + return ( +
{ + if (event.key === "Escape") { + setArmed(false); + } + }} + > + + +
+ ); +} diff --git a/src/ui/styles.css b/src/ui/styles.css index 21b9148..2237212 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -24,74 +24,74 @@ *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } :host { - --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-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(--ekklesia-spacing-xl); - } + .signage-desktop { + display: grid; + gap: var(--ekklesia-spacing-xl); + } } .signage-workspace { - display: grid; - min-width: 0; + display: grid; + min-width: 0; } .signage-workspace > .ek-tabs__list { - padding-inline: var(--ekklesia-spacing-xs); + 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); } /* @@ -100,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; } /* @@ -114,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; } /* @@ -136,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; } /* --------------------------------------------------------------------------------------------- @@ -151,103 +151,100 @@ * ------------------------------------------------------------------------------------------- */ .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-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); + 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); } /* --------------------------------------------------------------------------------------------- @@ -255,59 +252,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); + 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); + color: var(--ekklesia-text-primary); + font-weight: var(--ekklesia-typography-weight-bold); + padding-inline: var(--ekklesia-spacing-sm); } /* --------------------------------------------------------------------------------------------- @@ -315,70 +312,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); + 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; + 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); + display: grid; + gap: var(--ekklesia-spacing-sm); } .signage-playback__history .ek-list { - max-height: 14rem; - overflow: auto; + max-height: 14rem; + overflow: auto; } @media (min-width: 1280px) { - .signage-playback-layout { - align-items: start; - grid-template-columns: minmax(0, 1fr) minmax(28rem, 1fr); - } + .signage-playback-layout { + align-items: start; + grid-template-columns: minmax(0, 1fr) minmax(28rem, 1fr); + } } /* --------------------------------------------------------------------------------------------- @@ -386,304 +383,315 @@ * ------------------------------------------------------------------------------------------- */ .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-accent-subtle); - 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-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); + 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-advanced > summary { + color: var(--ekklesia-text-secondary); + cursor: pointer; + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-advanced[open] > summary { + margin-block-end: var(--signage-gap-tight); +} + +.signage-confirm-inline { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); } .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-md); - 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-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); + 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); } /* --------------------------------------------------------------------------------------------- @@ -695,154 +703,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 386cccf..45f5833 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -3,12 +3,7 @@ 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. @@ -19,53 +14,49 @@ import { */ // 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://api.example.test/display/signage"; const copiedText: string[] = []; -function dashboard( - context: Parameters[0]["context"], -): React.ReactElement { - return ( - { - copiedText.push(value); - return Promise.resolve(); - }} - /> - ); +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)); + }); + } } /** @@ -76,831 +67,790 @@ 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() === "Preview here", + ); + expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); + expect(setupLink?.target).toBe("_blank"); + expect(setupLink?.rel).toContain("noopener"); + await click(buttonNamed("Copy setup address")); + expect(copiedText).toEqual([DISPLAY_SETUP_URL]); + expect(container.textContent).toContain("Display setup address copied."); + expect(container.textContent).toContain("Channel groups and displays"); + // Plain language for the audience concept, and no internal key on the page by default. + expect(container.textContent).toContain("Audiences"); + expect(container.textContent).toContain("Images look the same for every audience."); + expect(container.textContent).toContain("Audience name"); + expect(container.textContent).not.toContain("Audience key"); + expect(container.querySelector("details.signage-advanced")?.hasAttribute("open")).toBe(false); + 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/stage0-management.test.tsx b/tests/browser/stage0-management.test.tsx new file mode 100644 index 0000000..9afe3e5 --- /dev/null +++ b/tests/browser/stage0-management.test.tsx @@ -0,0 +1,414 @@ +// @vitest-environment happy-dom +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js"; +import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; +import { + createFakeHost, + FAKE_SETTINGS, + type FakeHostRoute, + jsonResponse, +} from "./support/fake-host.js"; + +/** + * Stage 0 production-readiness regressions for the management screen. + * + * Each case is a defect an operator met: an activation that looked lost after changing tabs, a + * screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an + * internal key presented as something to fill in, and test displays nobody could remove. + */ + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +async function settle(): Promise { + for (let index = 0; index < 8; 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()); + await settle(); +} + +function button(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; +} + +function tab(label: string): Element | undefined { + return [...container.querySelectorAll("[role='tab']")].find( + (candidate) => candidate.textContent === label, + ); +} + +async function choose(select: HTMLSelectElement, value: string): Promise { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; + await act(async () => { + setter?.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +async function type(input: HTMLInputElement, value: string): Promise { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + await act(async () => { + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function timeline(id: string, name: string) { + return { + description: null, + eligibleFrom: null, + eligibleUntil: null, + id, + name, + revision: 1, + updatedAt: "2026-10-01T15:00:00.000Z", + }; +} + +function group(activeTimelineId: string | null) { + return { + channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], + id: "group-1", + name: "Main", + playback: { + activeTimelineId, + blackout: false, + currentSelectionId: null, + currentSlotId: null, + deadlineAtMs: null, + groupId: "group-1", + pendingTimelineId: null, + remainingMs: null, + revision: activeTimelineId === null ? 1 : 2, + scheduledActivationAtMs: null, + scheduledTimelineId: null, + selectionRevision: null, + serverNowMs: 1_700_000_000_000, + startedAtMs: null, + staticAssetId: null, + transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const), + }, + revision: 1, + }; +} + +/** A small, stateful Signage backend: activation really changes what later reads return. */ +function backend(extra: readonly FakeHostRoute[] = []) { + let active: string | null = null; + const sent: string[] = []; + const routes: FakeHostRoute[] = [ + ...extra, + { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, + { + method: "GET", + path: "channel-groups", + respond: () => jsonResponse({ groups: [group(active)] }), + }, + { + method: "GET", + path: "timelines", + respond: () => + jsonResponse({ + timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")], + }), + }, + { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, + { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, + { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) }, + { + method: "GET", + path: "playback/group-1", + respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }), + }, + { method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) }, + { + method: "POST", + path: "playback/group-1/activate", + respond: ({ init }) => { + active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId; + sent.push(`activate ${active}`); + return jsonResponse(group(active).playback); + }, + }, + ]; + return { routes, sent }; +} + +function mount(host: ReturnType) { + return act(async () => { + root.render( Promise.resolve()} />); + }); +} + +describe("Signage dashboard — Stage 0 regressions", () => { + it("shows a timeline activated on Playback as active on Timelines too", async () => { + const server = backend(); + const host = createFakeHost({ + permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"], + routes: server.routes, + }); + await mount(host); + await settle(); + + const activate = [...container.querySelectorAll("select")].find((select) => + [...select.options].some((option) => option.textContent === "Choose a timeline…"), + ) as HTMLSelectElement; + await choose(activate, "timeline-2"); + await click(button("Now")); + expect(server.sent).toEqual(["activate timeline-2"]); + + await click(tab("Timelines")); + const row = [...container.querySelectorAll("tr")].find((candidate) => + candidate.textContent?.includes("Easter"), + ); + expect(row?.textContent).toContain("Active in Main"); + expect(container.querySelector("tr")?.parentElement).not.toBeNull(); + }); + + it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => { + let slow = false; + const server = backend([ + { + method: "GET", + path: "timelines", + respond: () => + slow + ? new Promise(() => undefined) + : jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }), + }, + ]); + const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes }); + await mount(host); + await settle(); + await click(tab("Timelines")); + expect(container.textContent).toContain("Sunday"); + + // The next load never finishes. Coming back must still show the last known list. + slow = true; + await click(tab("Images")); + await click(tab("Timelines")); + expect(container.textContent).toContain("Sunday"); + expect(container.textContent).not.toContain("Loading timelines…"); + }); + + it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => { + const deleted: string[] = []; + const image = { + byteSize: 1000, + contentType: "image/png", + createdAt: "2026-10-01T15:00:00.000Z", + displayName: "Welcome", + expiresAt: null, + height: 1080, + id: "image-1", + originalFilename: "welcome.png", + renditions: [], + status: "ready", + width: 1920, + }; + const server = backend([ + { method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) }, + { + method: "DELETE", + path: "images/image-1", + respond: ({ path }) => { + deleted.push(path); + return new Response(null, { status: 204 }); + }, + }, + ]); + const host = createFakeHost({ + permissions: ["signage.view", "signage.assets.manage"], + routes: server.routes, + }); + await mount(host); + await settle(); + await click(tab("Images")); + + await click(container.querySelector("[data-testid='signage-image-delete-image-1']")); + expect(deleted).toEqual([]); + expect(button("Delete this image")).toBeDefined(); + await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']")); + expect(button("Delete this image")).toBeUndefined(); + expect(deleted).toEqual([]); + + await click(container.querySelector("[data-testid='signage-image-delete-image-1']")); + await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']")); + expect(deleted).toEqual(["images/image-1"]); + }); + + it("adds an audience from its name alone, deriving the content key", async () => { + const posted: unknown[] = []; + const server = backend([ + { + method: "POST", + path: "channel-groups/group-1/channels", + respond: ({ init }) => { + posted.push(JSON.parse(String(init?.body))); + return jsonResponse({}, 201); + }, + }, + ]); + const host = createFakeHost({ + permissions: ["signage.view", "signage.displays.manage"], + routes: server.routes, + }); + await mount(host); + await settle(); + await click(tab("Displays")); + + const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement; + await type(name, "Watching Online"); + await click(button("Add audience")); + expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]); + }); + + it("labels a signed-in browser and removes an offline entry only after confirmation", async () => { + const removed: string[] = []; + const viewer = { + cachedAssetCount: 0, + channelId: "chan-1", + deviceId: null, + id: "viewer-1", + lastError: null, + lastSeenAtMs: Date.now() - 600_000, + name: "New display", + offlineReady: false, + online: false, + reportedContributionId: null, + reportedProviderId: null, + reportedSelectionId: null, + reportedTimelineId: null, + }; + const server = backend([ + { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) }, + { + method: "DELETE", + path: "viewers/viewer-1", + respond: ({ path }) => { + removed.push(path); + return new Response(null, { status: 204 }); + }, + }, + ]); + const host = createFakeHost({ + permissions: ["signage.view", "signage.displays.manage"], + routes: server.routes, + }); + await mount(host); + await settle(); + await click(tab("Displays")); + + expect(container.textContent).toContain("Signed-in browser"); + await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']")); + expect(removed).toEqual([]); + await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']")); + expect(removed).toEqual(["viewers/viewer-1"]); + }); + + it("follows playback changes on the management channel, and stops when Playback is left", async () => { + const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] = + []; + const server = backend(); + const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes }); + Object.assign(host.context.realtime, { + subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) { + const entry = { closed: false, onUpdate: handlers.onUpdate }; + if (channel === "management-state") { + subscriptions.push(entry); + } + return { close: () => (entry.closed = true) }; + }, + }); + await mount(host); + await settle(); + const detailReads = () => + host.requests.filter((request) => request.path === "playback/group-1").length; + const before = detailReads(); + + const live = subscriptions.find((entry) => !entry.closed); + await act(async () => { + live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] }); + live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] }); + }); + await settle(); + expect(detailReads()).toBeGreaterThan(before); + + await click(tab("Images")); + expect(subscriptions.every((entry) => entry.closed)).toBe(true); + }); +}); + +describe("audience content keys", () => { + it("derives a valid, unique key that is never the reserved default", () => { + expect(audienceKeyFor("Online", [])).toBe("online"); + expect(audienceKeyFor("Online", ["online"])).toBe("online-2"); + expect(audienceKeyFor("Default", [])).toBe("default-2"); + expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service"); + expect(audienceKeyFor(" ", [])).toBe("audience"); + expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room"); + }); +}); + +/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */ +describe("product rules in the browser source", () => { + function sources(directory: string): string[] { + return readdirSync(directory).flatMap((entry) => { + const path = join(directory, entry); + if (statSync(path).isDirectory()) { + return sources(path); + } + return /\.(ts|tsx)$/u.test(entry) ? [path] : []; + }); + } + const files = ["src/ui", "src/browser", "src/display"].flatMap(sources); + + it("never uses a browser-native alert, confirm, or prompt", () => { + for (const file of files) { + expect(readFileSync(file, "utf8"), file).not.toMatch( + /\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u, + ); + } + }); + + it("never puts the platform's name in user-facing text", () => { + for (const file of files) { + const code = readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/^\s*\/\/.*$/gmu, "") + .replace(/\{\/\*[\s\S]*?\*\/\}/gu, ""); + // Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text. + expect(code, file).not.toMatch( + /["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u, + ); + } + }); +}); diff --git a/tests/browser/support/fake-host.ts b/tests/browser/support/fake-host.ts index fd33368..28c72fb 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,132 +15,122 @@ 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 displayUrl?: string; - 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, - 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, - }; + 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.4", - 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.4", + 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, + }, }); diff --git a/tests/drizzle-timelines.test.ts b/tests/drizzle-timelines.test.ts index 3e40448..51c1975 100644 --- a/tests/drizzle-timelines.test.ts +++ b/tests/drizzle-timelines.test.ts @@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[ }; return { database: { - transaction: async (operation: (value: typeof transaction) => Promise) => operation(transaction), + transaction: async (operation: (value: typeof transaction) => Promise) => + operation(transaction), } as unknown as ModuleDrizzleDatabase, positions, }; From 97de441b5f98223e4b94d7aab56ef2917d5dffee Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:48:13 -0500 Subject: [PATCH 04/11] ci: pin the Stage 0 platform harness and run every display scenario Pin the integration harness to Zayin/Ekklesia PR 92, whose display host keeps a live display across reconnects and returns a revoked one to pairing, and fetch it from the repository's canonical Zayin location. With those fixes the connectivity-blip, caching-probe, and revoke scenarios pass, so CI now runs them too; previously they were left out because they failed. Co-Authored-By: Claude Opus 5.5 --- ci/display-scenarios | 3 +++ ci/platform.env | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ci/display-scenarios b/ci/display-scenarios index 06ee010..08bcf83 100644 --- a/ci/display-scenarios +++ b/ci/display-scenarios @@ -5,3 +5,6 @@ two-pages session-blip stream-failure stream-recycle +connectivity-blip +caching-probe +revoke diff --git a/ci/platform.env b/ci/platform.env index 3733ed2..19eb858 100644 --- a/ci/platform.env +++ b/ci/platform.env @@ -1,5 +1,5 @@ # The companion Core harness Signage uses to install and run its real external package. The pull ref # remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. -EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git -EKKLESIA_PLATFORM_REF=refs/pull/90/head -EKKLESIA_PLATFORM_COMMIT=40ef0ff802c124ae5a3a0b9aa51a1502a7b72644 +EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git +EKKLESIA_PLATFORM_REF=refs/pull/92/head +EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a From 073fbc585579123e51fc7212630078a442871ac3 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:57:38 -0500 Subject: [PATCH 05/11] build: fetch Ekklesia packages from the registry's current Zayin location The @ekklesia package registry moved with the repositories to git.zayin.net/api/packages/Zayin/npm/. The old River_of_Glory_Church address now answers 502, so every CI job failed at install (Signage run 271). Only the address changes: a frozen install from an empty store against the new location verifies every pinned integrity hash, so the bytes are identical. Co-Authored-By: Claude Opus 5.5 --- .npmrc | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.npmrc b/.npmrc index 9368d70..9e1c0ce 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ # Ekklesia's module-development packages (@ekklesia/*) come from Ekklesia's public package registry # (platform ADRs 0053 and 0054). Reading needs no account and no token; never put a credential here. -@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ +@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccffb2a..09f4f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,13 +136,13 @@ packages: resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} '@ekklesia/contracts@0.2.3': - resolution: {integrity: sha512-h1kwuurRp2sOdrVxVtU7TbDx3NoOKEj1G7jhLRawFsWrlqkuhtcBgRXXfHgC5cbEwh5VRfXT3viLwwHnFIRjdw==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fcontracts/-/0.2.3/contracts-0.2.3.tgz} + resolution: {integrity: sha512-h1kwuurRp2sOdrVxVtU7TbDx3NoOKEj1G7jhLRawFsWrlqkuhtcBgRXXfHgC5cbEwh5VRfXT3viLwwHnFIRjdw==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fcontracts/-/0.2.3/contracts-0.2.3.tgz} '@ekklesia/design-tokens@0.2.3': - resolution: {integrity: sha512-ZN4x/qehXxnWccWrICyMMDgHXvXoKyicFc/qm8wmf3513PTBzM9lDtfrgnfBLEoc+mjeEfTTnfOyigX97U56XA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fdesign-tokens/-/0.2.3/design-tokens-0.2.3.tgz} + resolution: {integrity: sha512-ZN4x/qehXxnWccWrICyMMDgHXvXoKyicFc/qm8wmf3513PTBzM9lDtfrgnfBLEoc+mjeEfTTnfOyigX97U56XA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fdesign-tokens/-/0.2.3/design-tokens-0.2.3.tgz} '@ekklesia/module-database@0.2.3': - resolution: {integrity: sha512-xGwFPPD/wjbfWqgB45TpGVdLNC7eUb6j5l2wgRdpGCpTScnVsS30EJmgwsplPi6uHErUiNIZBwuN6YV5jVcZDg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-database/-/0.2.3/module-database-0.2.3.tgz} + resolution: {integrity: sha512-xGwFPPD/wjbfWqgB45TpGVdLNC7eUb6j5l2wgRdpGCpTScnVsS30EJmgwsplPi6uHErUiNIZBwuN6YV5jVcZDg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-database/-/0.2.3/module-database-0.2.3.tgz} peerDependencies: drizzle-orm: '>=0.45.2 <0.46.0' peerDependenciesMeta: @@ -150,24 +150,24 @@ packages: optional: true '@ekklesia/module-pack@0.2.3': - resolution: {integrity: sha512-U755u1sctmLm1bS9uaWQjcHPZw8WCYSTY5Ddi4QTbLVB3QzqYaWCaOyeeKJyEtoI+iZQNFkTAjAQPMSCVbCj8g==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-pack/-/0.2.3/module-pack-0.2.3.tgz} + resolution: {integrity: sha512-U755u1sctmLm1bS9uaWQjcHPZw8WCYSTY5Ddi4QTbLVB3QzqYaWCaOyeeKJyEtoI+iZQNFkTAjAQPMSCVbCj8g==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-pack/-/0.2.3/module-pack-0.2.3.tgz} hasBin: true '@ekklesia/module-sdk@0.2.3': - resolution: {integrity: sha512-H6g2F2xfA/akmzWBfSQTtUtgIrGkHxzPpvbJk4EqUJ6eXllHtn82hAw9r0S7vjLWE9IMD9m9KLAJ3Xil3mZsig==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-sdk/-/0.2.3/module-sdk-0.2.3.tgz} + resolution: {integrity: sha512-H6g2F2xfA/akmzWBfSQTtUtgIrGkHxzPpvbJk4EqUJ6eXllHtn82hAw9r0S7vjLWE9IMD9m9KLAJ3Xil3mZsig==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-sdk/-/0.2.3/module-sdk-0.2.3.tgz} '@ekklesia/module-ui-build@0.2.3': - resolution: {integrity: sha512-rSymIjPeoDzxpXpTC7D74/g8MV5PgUP56dHGXxe/jrvlvfdFeNa7pSMUYa4lKc3DvlcJucvq4BjwGXRuDUCdEA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-ui-build/-/0.2.3/module-ui-build-0.2.3.tgz} + resolution: {integrity: sha512-rSymIjPeoDzxpXpTC7D74/g8MV5PgUP56dHGXxe/jrvlvfdFeNa7pSMUYa4lKc3DvlcJucvq4BjwGXRuDUCdEA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-ui-build/-/0.2.3/module-ui-build-0.2.3.tgz} hasBin: true '@ekklesia/module-ui-sdk@0.2.3': - resolution: {integrity: sha512-zSvI3gfnqBiBt7h0Xbs6xmsLYP9E8M0xpLGVno+OaC1cyrKVUvlwq78as2zoiTGFHl2aoI5Mz9V4/KJOVxyByg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fmodule-ui-sdk/-/0.2.3/module-ui-sdk-0.2.3.tgz} + resolution: {integrity: sha512-zSvI3gfnqBiBt7h0Xbs6xmsLYP9E8M0xpLGVno+OaC1cyrKVUvlwq78as2zoiTGFHl2aoI5Mz9V4/KJOVxyByg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fmodule-ui-sdk/-/0.2.3/module-ui-sdk-0.2.3.tgz} '@ekklesia/typescript-config@0.2.3': - resolution: {integrity: sha512-dm8d8hJtdwwaMpsIwXcPXdCu616RQu3Y3/5OacPS1L2eb7Bhnxfl5SQJMuee8CtUSs45kBr0KXN8VWHl+AxSXg==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Ftypescript-config/-/0.2.3/typescript-config-0.2.3.tgz} + resolution: {integrity: sha512-dm8d8hJtdwwaMpsIwXcPXdCu616RQu3Y3/5OacPS1L2eb7Bhnxfl5SQJMuee8CtUSs45kBr0KXN8VWHl+AxSXg==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Ftypescript-config/-/0.2.3/typescript-config-0.2.3.tgz} '@ekklesia/ui@0.2.3': - resolution: {integrity: sha512-9xNOnp47VvARP91+XFG8mo/F5ln1Zi+w2RVY0mBeyf6qO5P39nyOAzwxunsD44Mb3E1o+fCTTgZoSJbLMaRtMA==, tarball: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/%40ekklesia%2Fui/-/0.2.3/ui-0.2.3.tgz} + resolution: {integrity: sha512-9xNOnp47VvARP91+XFG8mo/F5ln1Zi+w2RVY0mBeyf6qO5P39nyOAzwxunsD44Mb3E1o+fCTTgZoSJbLMaRtMA==, tarball: https://git.zayin.net/api/packages/Zayin/npm/%40ekklesia%2Fui/-/0.2.3/ui-0.2.3.tgz} peerDependencies: react: ^19.2.0 From 649114ba1de0f57d2afcb7787b6b03e882081743 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 22:02:49 -0500 Subject: [PATCH 06/11] ci: pin the platform harness that re-pairs the kiosk after revocation Co-Authored-By: Claude Opus 5.5 --- ci/platform.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/platform.env b/ci/platform.env index 19eb858..68477e8 100644 --- a/ci/platform.env +++ b/ci/platform.env @@ -2,4 +2,4 @@ # remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git EKKLESIA_PLATFORM_REF=refs/pull/92/head -EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a +EKKLESIA_PLATFORM_COMMIT=bae4ff3c6aa82e09723e2a87faa7ab9810764895 From f8b131f4ed6bfd84a4ec595cced7cb171901b443 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 22:23:05 -0500 Subject: [PATCH 07/11] docs(adr): point platform cross-references at the repository's current location Link targets only: the Ekklesia repository moved from git.riverofglorymn.org/River_of_Glory_Church to git.zayin.net/Zayin, and the old host no longer answers. No decision text changes, which is the cross-reference correction the ADR rules allow. Co-Authored-By: Claude Opus 5.5 --- .../0001-signage-owns-timing-providers-own-content.md | 4 ++-- .../0006-display-rendering-and-offline-continuation.md | 2 +- docs/architecture/decisions/0007-native-images.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md b/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md index 411160d..08c13ef 100644 --- a/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md +++ b/docs/architecture/decisions/0001-signage-owns-timing-providers-own-content.md @@ -2,8 +2,8 @@ - Status: Accepted - Date: 2026-08-27 -- Related: Ekklesia [ADR 0006](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) - (versioned capabilities), [ADR 0041](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) +- Related: Ekklesia [ADR 0006](https://git.zayin.net/Zayin/Ekklesia) + (versioned capabilities), [ADR 0041](https://git.zayin.net/Zayin/Ekklesia) (browser contributions) ## Context diff --git a/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md b/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md index f1168aa..6732f29 100644 --- a/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md +++ b/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md @@ -3,7 +3,7 @@ - Status: Accepted - Date: 2026-08-27 - Related: [0005](0005-authoritative-playback.md), Ekklesia - [ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code) + [ADR 0043](https://git.zayin.net/Zayin/Ekklesia) (display offline code) ## Context diff --git a/docs/architecture/decisions/0007-native-images.md b/docs/architecture/decisions/0007-native-images.md index 1d42355..b4f3499 100644 --- a/docs/architecture/decisions/0007-native-images.md +++ b/docs/architecture/decisions/0007-native-images.md @@ -3,8 +3,8 @@ - Status: Accepted - Date: 2026-08-27 - Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia - [ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage), - [ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies) + [ADR 0030](https://git.zayin.net/Zayin/Ekklesia) (host-provided storage), + [ADR 0042](https://git.zayin.net/Zayin/Ekklesia) (streaming bodies) ## Context From b479ae9296cee1f3142a3a859986fb0aab7fad2f Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 22:30:25 -0500 Subject: [PATCH 08/11] ci: follow the platform harness pin to Zayin/Ekklesia#92's current head Co-Authored-By: Claude Opus 5.5 --- ci/platform.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/platform.env b/ci/platform.env index 68477e8..3f3148f 100644 --- a/ci/platform.env +++ b/ci/platform.env @@ -2,4 +2,4 @@ # remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git EKKLESIA_PLATFORM_REF=refs/pull/92/head -EKKLESIA_PLATFORM_COMMIT=bae4ff3c6aa82e09723e2a87faa7ab9810764895 +EKKLESIA_PLATFORM_COMMIT=5e9433d96b413117374a6132d89400867414c2c8 From 5f523346646a5120f8c0f8a233c63b700f3fd364 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 13 Oct 2026 19:42:00 -0500 Subject: [PATCH 09/11] fix(display): keep cached image bytes when the browser has no Cache Storage A display opened over plain http:// on a LAN address is not a secure context, so the browser gives it no Cache Storage. The media cache then fell back to the in-memory test double, which remembered only each slide's signed delivery URL and still reported it as cached. Online that looked identical. Offline, the next slide's URL could not be loaded, the slide was marked unusable, and local continuation kept landing on the image already on screen. The wall froze until the network returned. Without Cache Storage, the browser media cache now fetches and holds the bytes in page memory as object URLs (one per key, revoked on prune, replace and stop), so a running display keeps cycling through what it already has. Nothing persists across a reload; on such an origin the platform cannot cache the display page either. Regression tests: the media cache on an origin without Cache Storage, and the whole controller on an ordered fake clock, cycling for six slide intervals with the network gone and then handing back to the server. Co-Authored-By: Claude Opus 5.5 --- src/browser/storage.ts | 66 +++++++- tests/browser/media-cache.test.ts | 87 ++++++++++ tests/browser/offline.test.ts | 259 +++++++++++++++++++++++++++++- 3 files changed, 408 insertions(+), 4 deletions(-) diff --git a/src/browser/storage.ts b/src/browser/storage.ts index 06c6867..c262db7 100644 --- a/src/browser/storage.ts +++ b/src/browser/storage.ts @@ -159,14 +159,74 @@ export function createBrowserStore(scope: typeof globalThis = globalThis): Signa }; } -/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure - * origin in development, a policy that refuses — a display works exactly as it did before: online. */ +/** + * Image bytes held in this page's memory, for a browser that offers no Cache Storage — a display + * opened over plain `http://` on a LAN address is not a secure context, and gets none. + * + * The bytes, not the delivery URL: a URL needs the network, so a cache that remembered only URLs + * looked identical online and could paint nothing new offline. Holding the bytes keeps a running + * display cycling through what it already has. Nothing survives a reload, and nothing needs to: on + * such an origin the platform cannot cache the display page itself either. + */ +function createPageMediaCache(fetchFn: typeof fetch): SignageMediaCache { + /** One object URL per key, revoked when the key is pruned or replaced, and all of them on stop. */ + const objectUrls = new Map(); + + function forget(key: string): void { + const url = objectUrls.get(key); + if (url !== undefined) { + objectUrls.delete(key); + URL.revokeObjectURL(url); + } + } + + return { + count: () => Promise.resolve(objectUrls.size), + find: (key: string) => Promise.resolve(objectUrls.get(key)), + prune(keep: readonly string[]): Promise { + const retained = new Set(keep); + for (const key of [...objectUrls.keys()]) { + if (!retained.has(key)) { + forget(key); + } + } + return Promise.resolve(); + }, + release(): void { + for (const key of [...objectUrls.keys()]) { + forget(key); + } + }, + async store(key: string, url: string): Promise { + try { + const response = await fetchFn(url); + // The same rule as Cache Storage: only a complete, successful response is kept. + if (!response.ok || response.status !== 200) { + return undefined; + } + const blob = await response.blob(); + forget(key); + const local = URL.createObjectURL(blob); + objectUrls.set(key, local); + return local; + } catch { + return undefined; + } + }, + }; +} + +/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage, the bytes are + * held in the page instead (`createPageMediaCache`), so a running display still survives an outage. */ export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache { const maybeCaches = (scope as { caches?: CacheStorage }).caches; const fetchFn = (scope as { fetch?: typeof fetch }).fetch; - if (!maybeCaches || !fetchFn) { + if (!fetchFn) { return createMemoryMediaCache(); } + if (!maybeCaches) { + return createPageMediaCache(fetchFn); + } const caches: CacheStorage = maybeCaches; async function open(): Promise { diff --git a/tests/browser/media-cache.test.ts b/tests/browser/media-cache.test.ts index 4618c55..606c7ec 100644 --- a/tests/browser/media-cache.test.ts +++ b/tests/browser/media-cache.test.ts @@ -117,3 +117,90 @@ describe("Signage's media cache and object URLs", () => { expect(live.size).toBe(0); }); }); + +/** + * A display opened over plain `http://` on a LAN address is not a secure context, and a browser + * gives such a page no Cache Storage. Stage 0's real kiosk ran exactly that way. + * + * The fallback used to remember only the signed delivery URL and report it as cached. Online that + * looked identical; offline, the next slide's URL could not be fetched, the slide was skipped as + * unusable, and the wall sat on one image until the network came back. The bytes are what a display + * needs offline, so the bytes are what this keeps — in the page's memory, for as long as it runs. + */ +describe("Signage's media cache on an origin without Cache Storage", () => { + const realCreate = URL.createObjectURL; + const realRevoke = URL.revokeObjectURL; + let live: Set; + let reachable: boolean; + let media: SignageMediaCache; + + beforeEach(() => { + live = new Set(); + reachable = true; + let next = 0; + URL.createObjectURL = () => { + next += 1; + const url = `blob:test/${next}`; + live.add(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + live.delete(url); + }; + media = createBrowserMediaCache({ + // No `caches`: what an insecure origin gets. + fetch: async (url: string) => { + if (!reachable) { + throw new TypeError("Failed to fetch"); + } + return url.includes("missing") + ? new Response("not found", { status: 404 }) + : new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }); + }, + location: { origin: "http://192.168.1.20:3000" }, + } as unknown as typeof globalThis); + }); + + afterEach(() => { + URL.createObjectURL = realCreate; + URL.revokeObjectURL = realRevoke; + }); + + it("keeps the bytes, so a slide it cached can still be painted with no network", async () => { + const stored = await media.store("slide/a", "http://files.example.test/a?sig=1"); + + // A local URL for bytes this page holds — never the delivery URL, which needs the network. + expect(stored).toMatch(/^blob:/u); + expect(stored).not.toContain("files.example.test"); + + reachable = false; + expect(await media.find("slide/a")).toBe(stored); + expect(await media.count()).toBe(1); + }); + + it("reports nothing as cached that it does not actually hold", async () => { + expect(await media.store("slide/a", "http://files.example.test/missing?sig=1")).toBeUndefined(); + reachable = false; + expect(await media.store("slide/b", "http://files.example.test/b?sig=1")).toBeUndefined(); + + expect(await media.find("slide/a")).toBeUndefined(); + expect(await media.find("slide/b")).toBeUndefined(); + expect(await media.count()).toBe(0); + }); + + it("gives back the URLs it replaces, prunes, and holds when the display stops", async () => { + for (const key of ["slide/a", "slide/b", "slide/c"]) { + await media.store(key, `http://files.example.test/${key}?sig=1`); + } + await media.store("slide/a", "http://files.example.test/slide/a?sig=2"); + expect(live.size).toBe(3); + + await media.prune(["slide/a", "slide/b"]); + expect(live.size).toBe(2); + expect(await media.find("slide/c")).toBeUndefined(); + expect(await media.count()).toBe(2); + + media.release(); + expect(live.size).toBe(0); + }); +}); diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index c24703e..42ded36 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type OfflineSnapshot, resolveOffline, @@ -8,6 +8,7 @@ import { } from "../../src/browser/offline.js"; import type { SceneSelection } from "../../src/browser/scene-request.js"; import { + createBrowserMediaCache, createMemoryMediaCache, createMemoryStore, signageMediaKey, @@ -574,3 +575,259 @@ describe("a display that loses its network", () => { await controller.stop(); }); }); + +/** + * Stage 0's real kiosk: two native images cycling, then the laptop's network switched off for a + * minute. The offline note appeared, but the wall stayed on one image until the network returned. + * + * The page was served over plain `http://` on a LAN address, which a browser does not treat as a + * secure context, so there was no Cache Storage. The media cache's fallback then remembered only each + * slide's signed delivery URL. Offline, the next slide's URL could not be fetched, the slide was + * marked unusable, and local continuation kept landing on the one image already on screen. + * + * This runs the whole controller on a clock that only moves when the test says so, across several + * slide intervals, with every timer it arms honoured in order. + */ +describe("a running display that loses its network for several slides", () => { + const SLOT_MS = 5000; + const realCreate = URL.createObjectURL; + const realRevoke = URL.revokeObjectURL; + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + let next = 0; + URL.createObjectURL = () => { + next += 1; + return `blob:display/${next}`; + }; + URL.revokeObjectURL = () => undefined; + }); + + afterEach(() => { + URL.createObjectURL = realCreate; + URL.revokeObjectURL = realRevoke; + }); + + async function settle(): Promise { + for (let index = 0; index < 12; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + /** A clock and timer queue the controller and its stage both run on. */ + function createClock(startMs: number) { + let nowMs = startMs; + let nextHandle = 0; + const timers = new Map void }>(); + return { + clearTimer: (handle: unknown) => { + timers.delete(handle as number); + }, + now: () => nowMs, + setTimer: (handler: () => void, delayMs: number) => { + nextHandle += 1; + timers.set(nextHandle, { atMs: nowMs + Math.max(0, delayMs), handler }); + return nextHandle; + }, + /** Moves time forward, running every timer that falls due on the way, in order. */ + async advance(ms: number): Promise { + const targetMs = nowMs + ms; + for (;;) { + let due: [number, { readonly atMs: number; readonly handler: () => void }] | undefined; + for (const entry of timers) { + if (entry[1].atMs <= targetMs && (!due || entry[1].atMs < due[1].atMs)) { + due = entry; + } + } + if (!due) { + break; + } + timers.delete(due[0]); + nowMs = due[1].atMs; + due[1].handler(); + await settle(); + } + nowMs = targetMs; + await settle(); + }, + }; + } + + /** Which of the two images a selection is. */ + function slideOf(selectionId: string | undefined): string | undefined { + return selectionId?.split("-")[1]; + } + + /** The server's answer at `atMs`: two images alternating, each play its own selection, and a + * freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */ + function serverState(atMs: number): DisplayChannelStateView { + const cycle = Math.floor(atMs / SLOT_MS); + const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); + const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2] + .filter((c) => c >= 0) + .map((c) => ({ + ...selection({ + id: `sel-${slide(c)}-${c}`, + itemId: `item-${slide(c)}`, + slotId: `slot-${slide(c)}`, + }), + media: { + display: { + expiresAtMs: atMs + 900_000, + height: 1080, + url: `http://files.example.test/${slide(c)}?sig=${atMs}`, + width: 1920, + }, + thumbnail: { + expiresAtMs: atMs + 900_000, + height: 270, + url: `http://files.example.test/${slide(c)}-t?sig=${atMs}`, + width: 480, + }, + }, + stale: false, + })); + const current = history[0] as DisplaySelection; + const startedAtMs = cycle * SLOT_MS; + return { + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + current, + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: current.id, + currentSlotId: current.slotId, + deadlineAtMs: startedAtMs + SLOT_MS, + pendingTimelineId: null, + remainingMs: startedAtMs + SLOT_MS - atMs, + revision: cycle, + selectionRevision: cycle, + startedAtMs, + transport: "playing", + }, + selections: history, + serverNowMs: atMs, + staticHold: null, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 1, + slots: [ + slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), + slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), + ], + }, + }; + } + + it("keeps advancing through its cached images on an origin with no Cache Storage", async () => { + const network = { reachable: true }; + // Mid-slide, so every sample below lands halfway through one. + const clock = createClock(12_500); + const unreachable = () => new TypeError("Failed to fetch"); + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => + jsonResponse({ + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", + viewerName: "Foyer", + } satisfies ViewerSessionResponse), + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + if (!network.reachable) { + throw unreachable(); + } + return jsonResponse(serverState(clock.now())); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => { + if (!network.reachable) { + throw unreachable(); + } + return jsonResponse({}); + }, + }, + ], + }); + // A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else. + const media = createBrowserMediaCache({ + fetch: async () => { + if (!network.reachable) { + throw unreachable(); + } + return new Response(new Uint8Array([1, 2, 3]), { + headers: { "content-type": "image/webp" }, + }); + }, + location: { origin: "http://192.168.1.20:3000" }, + } as unknown as typeof globalThis); + + const controller = createDisplayController({ + clearTimer: clock.clearTimer, + context: host.context, + // What a browser does: bytes this page holds always decode, and a remote URL needs a network. + decodeImage: (image) => + (image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable + ? Promise.resolve() + : Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")), + media, + newViewerKey: () => "viewer-fixed-key-0001", + now: clock.now, + root, + setTimer: clock.setTimer, + store: createMemoryStore(), + }); + + await controller.start(); + await settle(); + // Online for a full cycle, so both images have been shown and fetched. + const online: (string | undefined)[] = [slideOf(controller.diagnostics().renderedSelectionId)]; + for (let interval = 0; interval < 2; interval += 1) { + await clock.advance(SLOT_MS); + online.push(slideOf(controller.diagnostics().renderedSelectionId)); + } + expect(online).toEqual(["a", "b", "a"]); + expect(controller.diagnostics().offline).toBe(false); + + // The network goes away for six slides. + network.reachable = false; + const offline: (string | undefined)[] = []; + for (let interval = 0; interval < 6; interval += 1) { + await clock.advance(SLOT_MS); + offline.push(slideOf(controller.diagnostics().renderedSelectionId)); + expect(controller.diagnostics().offline).toBe(true); + } + + // Still cycling, on the server's own phase, from bytes the page already held. + expect(offline).toEqual(["b", "a", "b", "a", "b", "a"]); + expect(controller.diagnostics().unusableSelectionIds).toEqual([]); + expect(root.dataset.slate).toBe("false"); + + // The network returns. The server is authoritative again with no reload. + network.reachable = true; + await clock.advance(70_000); + const diagnostics = controller.diagnostics(); + expect(diagnostics.offline).toBe(false); + expect(diagnostics.renderedSelectionId).toMatch(/^sel-[ab]-\d+$/u); + expect(Number(diagnostics.renderedSelectionId?.split("-")[2])).toBeGreaterThan(10); + await controller.stop(); + }); +}); From ab5b02960ab05c86dfe09862d5562ba6680c9f57 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 13 Oct 2026 20:14:35 -0500 Subject: [PATCH 10/11] fix(display): continue a source slot's rotation while offline Offline continuation replayed each slot's newest cached answer. A provider-source slot is answered with the provider's next item on every play, so its newest answer is the image already on screen: on a timeline of one source slot, every offline boundary "advanced" to the same picture. Stage 0's real timeline is exactly that, which is why the wall froze whether the network or only the API went away, and resumed the moment the server answered. Each slot's cached answers are now its observed rotation (one per item, oldest-played first), and the k-th play of the slot since the anchor shows the k-th entry. The play count is arithmetic, skipping whole passes wherever the eligible set is stable, so a week offline is still one evaluation. Where a play starts is unchanged and still decided by advanceCursor alone; a pinned slot is a rotation of one and behaves as before. Nothing is invented: only answers the server already resolved are replayed, and an item whose newest answer is unusable is skipped. Regression tests: the resolver on a one-slot rotation of two and three images, across a week, and with an unusable image; and the controller on the real shape for six slide intervals with the network down and with only the API down. Co-Authored-By: Claude Opus 5.5 --- src/browser/offline.ts | 122 +++++++++++++++++++++++++-- tests/browser/offline.test.ts | 152 +++++++++++++++++++++++++++++----- 2 files changed, 248 insertions(+), 26 deletions(-) diff --git a/src/browser/offline.ts b/src/browser/offline.ts index 803eedc..7a0cbc7 100644 --- a/src/browser/offline.ts +++ b/src/browser/offline.ts @@ -1,6 +1,10 @@ import { advanceCursor, + eligibleSlots, + firstEligibilityBoundary, nextTimingBoundary, + TIMING_TRANSITION_LIMIT, + type TimingCursor, type TimingSlot, type TimingTimeline, } from "../shared/playback-timing.js"; @@ -111,17 +115,110 @@ function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet return selection.availableUntilMs === null || atMs < selection.availableUntilMs; } -/** The most recent cached answer for one slot. History is newest-first, so the first match is the - * one that most recently played there. */ -function cachedForSlot( +/** + * The answers one slot has already had, as the rotation they came in: one per item, oldest-played + * first, so the item on screen most recently is last. + * + * A pinned slot has one item and so a rotation of one. A provider-source slot is answered with the + * provider's *next* item on every play, so its history is the provider's own order — and replaying + * only the newest answer would replay the image already on screen at every boundary. Each item's + * newest answer is the one that counts: an item whose newest answer was withdrawn or failed here is + * left out, never replaced by an older answer for it. + */ +function rotationForSlot( snapshot: OfflineSnapshot, slotId: string, atMs: number, unusable: ReadonlySet, +): readonly SceneSelection[] { + const seen = new Set(); + const newestFirst: SceneSelection[] = []; + // History is newest-first, so the first answer seen for an item is its most recent one. + for (const selection of snapshot.selections) { + if (selection.slotId !== slotId) { + continue; + } + const item = `${selection.providerId}\u0000${selection.itemId}`; + if (seen.has(item)) { + continue; + } + seen.add(item); + if (isUsable(selection, atMs, unusable)) { + newestFirst.push(selection); + } + } + return newestFirst.reverse(); +} + +/** The cached answer for the `play`-th play of a slot since the anchor (1 is the next one). */ +function cachedForSlot( + snapshot: OfflineSnapshot, + slotId: string, + play: number, + atMs: number, + unusable: ReadonlySet, ): SceneSelection | undefined { - return snapshot.selections.find( - (selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable), - ); + const rotation = rotationForSlot(snapshot, slotId, atMs, unusable); + if (rotation.length === 0) { + return undefined; + } + return rotation[(Math.max(1, play) - 1) % rotation.length]; +} + +/** + * How many times `slotId` has started since the anchor, up to and including a play starting at + * `throughMs`. + * + * Arithmetic, like the cursor itself: wherever the eligible set cannot change, whole passes of the + * pattern are counted by division — every eligible slot plays exactly once per pass — so a week + * offline is one evaluation. It only chooses *which* cached answer a play shows; where a play + * starts is still decided by `advanceCursor` alone. + */ +function playsSinceAnchor( + timeline: TimingTimeline, + anchor: TimingCursor, + slotId: string, + throughMs: number, +): number { + let cursor = anchor; + let plays = 0; + for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) { + const fromMs = cursor.deadlineAtMs; + if (fromMs === null || fromMs > throughMs) { + break; + } + const stable = eligibleSlots(timeline, fromMs); + const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0); + const changeAt = firstEligibilityBoundary(timeline, fromMs, throughMs); + const stableThrough = changeAt === null ? throughMs : changeAt - 1; + if (passDuration > 0 && stable.some((slot) => slot.id === cursor.slotId)) { + const passes = Math.floor((stableThrough - fromMs) / passDuration); + if (passes > 0) { + if (stable.some((slot) => slot.id === slotId)) { + plays += passes; + } + cursor = { + deadlineAtMs: fromMs + passes * passDuration, + slotId: cursor.slotId, + startedAtMs: (cursor.startedAtMs ?? fromMs) + passes * passDuration, + }; + continue; + } + } + const next = advanceCursor(timeline, cursor, fromMs); + if (next.kind !== "advance") { + break; + } + if (next.slotId === slotId) { + plays += 1; + } + cursor = { + deadlineAtMs: next.deadlineAtMs, + slotId: next.slotId, + startedAtMs: next.startedAtMs, + }; + } + return plays; } export interface OfflineEvaluation { @@ -179,6 +276,11 @@ export function resolveOffline(input: { } const timing = timingTimelineOf(timeline); + const anchor: TimingCursor = { + deadlineAtMs: snapshot.playback.deadlineAtMs, + slotId: snapshot.playback.currentSlotId, + startedAtMs: snapshot.playback.startedAtMs, + }; let cursor = { deadlineAtMs: snapshot.playback.deadlineAtMs, slotId: snapshot.playback.currentSlotId, @@ -224,7 +326,13 @@ export function resolveOffline(input: { }; } - const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable); + const cached = cachedForSlot( + snapshot, + advance.slotId, + playsSinceAnchor(timing, anchor, advance.slotId, advance.startedAtMs), + atMs, + unusable, + ); if (cached) { const nextCursor = { deadlineAtMs: advance.deadlineAtMs, diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index 42ded36..2c7468f 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -295,6 +295,98 @@ describe("offline continuation", () => { expect(JSON.stringify(persisted)).not.toContain("sig=secret"); expect(JSON.stringify(persisted)).not.toContain("https://"); }); + + /** + * Stage 0's real timeline: **one** provider-source slot, which the server answers with the next + * image of the provider's rotation on every play. Offline, replaying only that slot's newest answer + * meant replaying the image already on screen — every boundary "advanced" to the same picture. + */ + function rotatingSnapshot(newestFirstItems: readonly string[]): OfflineSnapshot { + const selections = newestFirstItems.map((item, index) => + selection({ id: `sel-${index}`, itemId: `item-${item}`, slotId: "slot-source" }), + ); + return snapshot({ + playback: { + blackout: false, + currentSelectionId: "sel-0", + currentSlotId: "slot-source", + deadlineAtMs: 5000, + pendingTimelineId: null, + remainingMs: 5000, + revision: 3, + startedAtMs: 0, + transport: "playing", + }, + selections, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 2, + slots: [slot("slot-source", 0)], + }, + }); + } + + function itemAt(persisted: OfflineSnapshot, atMs: number): string | undefined { + const { resolution } = resolveOffline({ atMs, snapshot: persisted }); + return resolution.kind === "selection" ? resolution.selection.itemId : undefined; + } + + it("continues a source slot's rotation through the answers it already has", () => { + // On screen: B. Before it, A, then B, then A — the provider's two-image rotation. + const persisted = rotatingSnapshot(["b", "a", "b", "a"]); + + expect(itemAt(persisted, 2500)).toBe("item-b"); + expect([7500, 12_500, 17_500, 22_500, 27_500].map((at) => itemAt(persisted, at))).toEqual([ + "item-a", + "item-b", + "item-a", + "item-b", + "item-a", + ]); + }); + + it("keeps the provider's own order across a longer rotation", () => { + // Played C, A, B, C, A, B — B is on screen, so C comes next, then A, then B. + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + + expect([7500, 12_500, 17_500, 22_500].map((at) => itemAt(persisted, at))).toEqual([ + "item-c", + "item-a", + "item-b", + "item-c", + ]); + }); + + it("catches a rotation up across a week in one evaluation", () => { + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + // Mid-way through the slot's 120,961st play since the anchor. + const week = 7 * 24 * 60 * 60 * 1000 + 7500; + const started = Date.now(); + + // A three-image rotation that resumes at C shows C on every play numbered 1 mod 3. + expect(itemAt(persisted, week)).toBe("item-c"); + expect(itemAt(persisted, week + 5000)).toBe("item-a"); + expect(Date.now() - started).toBeLessThan(200); + }); + + it("skips an image it cannot render and keeps rotating through the rest", () => { + const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]); + const unusable = new Set(["sel-1"]); + + const items = [7500, 12_500, 17_500].map((atMs) => { + const { resolution } = resolveOffline({ + atMs, + snapshot: persisted, + unusableSelectionIds: unusable, + }); + return resolution.kind === "selection" ? resolution.selection.itemId : undefined; + }); + + // A's newest answer is unusable, so A is not shown — and no older answer of A is dug up instead. + expect(items).toEqual(["item-c", "item-b", "item-c"]); + }); }); describe("a display that loses its network", () => { @@ -660,18 +752,25 @@ describe("a running display that loses its network for several slides", () => { return selectionId?.split("-")[1]; } + /** The two timeline shapes a pair of alternating images can take. Two pinned slots, one image + * each; or Stage 0's real one — a single provider-source slot the server answers with the next + * image of the rotation on every play. */ + type TimelineShape = "two pinned slots" | "one provider-source slot"; + /** The server's answer at `atMs`: two images alternating, each play its own selection, and a * freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */ - function serverState(atMs: number): DisplayChannelStateView { + function serverState(atMs: number, shape: TimelineShape): DisplayChannelStateView { const cycle = Math.floor(atMs / SLOT_MS); const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); + const slotFor = (c: number) => + shape === "two pinned slots" ? `slot-${slide(c)}` : "slot-source"; const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2] .filter((c) => c >= 0) .map((c) => ({ ...selection({ id: `sel-${slide(c)}-${c}`, itemId: `item-${slide(c)}`, - slotId: `slot-${slide(c)}`, + slotId: slotFor(c), }), media: { display: { @@ -716,16 +815,29 @@ describe("a running display that loses its network for several slides", () => { eligibleUntilMs: null, id: "timeline-1", revision: 1, - slots: [ - slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), - slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), - ], + slots: + shape === "two pinned slots" + ? [ + slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), + slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), + ] + : [slot("slot-source", 0, { estimatedDurationMs: SLOT_MS })], }, }; } - it("keeps advancing through its cached images on an origin with no Cache Storage", async () => { - const network = { reachable: true }; + it.each([ + // Wi-Fi off on a plain-http LAN origin: no API, no storage, and no Cache Storage. + { outage: "network", shape: "two pinned slots" }, + { outage: "network", shape: "one provider-source slot" }, + // The API process stopped while the storage server stays up. + { outage: "api", shape: "one provider-source slot" }, + ] as const)("keeps cycling through its cached images with the $outage down: $shape", async ({ + outage, + shape, + }) => { + const api = { reachable: true }; + const storage = { reachable: true }; // Mid-slide, so every sample below lands halfway through one. const clock = createClock(12_500); const unreachable = () => new TypeError("Failed to fetch"); @@ -749,17 +861,17 @@ describe("a running display that loses its network for several slides", () => { method: "GET", path: /^viewers\/[^/]+\/display-state$/u, respond: () => { - if (!network.reachable) { + if (!api.reachable) { throw unreachable(); } - return jsonResponse(serverState(clock.now())); + return jsonResponse(serverState(clock.now(), shape)); }, }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => { - if (!network.reachable) { + if (!api.reachable) { throw unreachable(); } return jsonResponse({}); @@ -767,10 +879,10 @@ describe("a running display that loses its network for several slides", () => { }, ], }); - // A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else. + // A plain-http LAN origin: no `caches`, but `fetch` — which needs the storage server. const media = createBrowserMediaCache({ fetch: async () => { - if (!network.reachable) { + if (!storage.reachable) { throw unreachable(); } return new Response(new Uint8Array([1, 2, 3]), { @@ -783,9 +895,9 @@ describe("a running display that loses its network for several slides", () => { const controller = createDisplayController({ clearTimer: clock.clearTimer, context: host.context, - // What a browser does: bytes this page holds always decode, and a remote URL needs a network. + // What a browser does: bytes this page holds always decode, and a remote URL needs storage. decodeImage: (image) => - (image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable + (image.getAttribute("src") ?? "").startsWith("blob:") || storage.reachable ? Promise.resolve() : Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")), media, @@ -807,8 +919,9 @@ describe("a running display that loses its network for several slides", () => { expect(online).toEqual(["a", "b", "a"]); expect(controller.diagnostics().offline).toBe(false); - // The network goes away for six slides. - network.reachable = false; + // The outage lasts six slides. + api.reachable = false; + storage.reachable = outage !== "network"; const offline: (string | undefined)[] = []; for (let interval = 0; interval < 6; interval += 1) { await clock.advance(SLOT_MS); @@ -821,8 +934,9 @@ describe("a running display that loses its network for several slides", () => { expect(controller.diagnostics().unusableSelectionIds).toEqual([]); expect(root.dataset.slate).toBe("false"); - // The network returns. The server is authoritative again with no reload. - network.reachable = true; + // Everything returns. The server is authoritative again with no reload. + api.reachable = true; + storage.reachable = true; await clock.advance(70_000); const diagnostics = controller.diagnostics(); expect(diagnostics.offline).toBe(false); From 51d9085cdb3353b6b5301e4c3bfbc7ac5b3cec58 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 13 Oct 2026 20:38:42 -0500 Subject: [PATCH 11/11] fix(display): drop the previous assignment's frame when the new one has nothing Moved from a group that was playing to one with nothing to show, a display adopted the new assignment but kept the old group's last image on screen indefinitely. With nothing renderable, the controller keeps whatever frame is up, which is right for a gap in the same assignment and wrong for a frame from an assignment the display no longer has. The same early return also skipped persisting, so an outage or a restart resumed the old group's content from local state. The controller now records which assignment the frame on screen came from. When the server says the current assignment has nothing, a frame from another assignment is taken down (a new stage clear(), which also abandons any transition still preparing) and the slate shows; and the new assignment's state is persisted if the stored one belongs to a different assignment. A gap within the same assignment still keeps its last good frame, and every path with content is unchanged. Regression tests: populated-group to empty-group reassignment drops the old image and shows the slate; and a restart with no server after that move does not resume the old group's content. Co-Authored-By: Claude Opus 5.5 --- src/browser/scene.ts | 17 +++++ src/display/controller.ts | 24 ++++++- tests/browser/display.test.ts | 122 ++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/browser/scene.ts b/src/browser/scene.ts index 7be6fad..ca776d2 100644 --- a/src/browser/scene.ts +++ b/src/browser/scene.ts @@ -80,6 +80,9 @@ export interface SceneStage { setSlate(on: boolean): void; /** What is on screen now, or `undefined` if nothing ever was. */ currentKey(): string | undefined; + /** Takes whatever is on screen down, and abandons any transition still preparing. For content + * that must no longer be shown at all — never for an ordinary change of slide. */ + clear(): Promise; setCrossfade(milliseconds: number): void; destroy(): Promise; } @@ -262,6 +265,20 @@ export function createSceneStage(options: SceneStageOptions): SceneStage { } return { + async clear(): Promise { + if (destroyed) { + return; + } + // A transition that is still preparing belongs to the content being taken down. + generation += 1; + for (const layer of layers) { + layer.element.style.transitionDuration = "0ms"; + layer.element.style.opacity = "0"; + layer.element.dataset.visible = "false"; + } + await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]); + }, + currentKey: () => layers[currentIndex]?.key, async destroy(): Promise { diff --git a/src/display/controller.ts b/src/display/controller.ts index a091fb8..82b5654 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -118,6 +118,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp let stopped = false; let online = false; let renderedSelectionId: string | undefined; + /** The assignment the frame on screen came from. A frame is only ever kept for its own assignment. */ + let frameAssignment: string | undefined; let cachedAssetCount = 0; let lastError: { code: string; detail: string | null } | undefined; let appliedRevision = -1; @@ -169,6 +171,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp }; } + /** Identifies an assignment: the channel, within its group. */ + function assignmentOf(channel: { readonly id: string; readonly groupId: string }): string { + return `${channel.groupId}/${channel.id}`; + } + function recordError(code: string, detail: string | null = null): void { lastError = { code, detail }; report(); @@ -261,6 +268,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp return; } renderedSelectionId = selection.id; + frameAssignment = assignmentOf(state.channel); if (lastError !== undefined) { lastError = undefined; } @@ -287,11 +295,22 @@ export function createDisplayController(options: DisplayControllerOptions): Disp const target = state.staticHold ?? state.current; if (target === null) { - // Nothing renderable from the server. If something good is already on screen it stays; only a - // display with nothing at all shows the slate. + const assignment = assignmentOf(state.channel); + // Nothing renderable from the server. A good frame of *this* assignment stays through the gap; + // a frame from an assignment this display no longer has is stale content, and comes down. + if (frameAssignment !== undefined && frameAssignment !== assignment) { + stage.setSlate(true); + await stage.clear(); + renderedSelectionId = undefined; + frameAssignment = undefined; + } if (stage.currentKey() === undefined) { stage.setSlate(true); } + // What an outage or a restart resumes from must be this assignment too, never the old one. + if (snapshot !== undefined && assignmentOf(snapshot.channel) !== assignment) { + await persist(state); + } report(); return; } @@ -428,6 +447,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp } if (outcome.kind !== "superseded") { renderedSelectionId = selection.id; + frameAssignment = assignmentOf(snapshot.channel); } } diff --git a/tests/browser/display.test.ts b/tests/browser/display.test.ts index 2ec7560..99f5bec 100644 --- a/tests/browser/display.test.ts +++ b/tests/browser/display.test.ts @@ -546,6 +546,128 @@ describe("display controller", () => { await controller.stop(); }); + /** + * Stage 0: moved from a group that was playing to one with nothing to show, the display adopted + * the new assignment and kept the old group's last image on screen indefinitely. "Keep the last + * good frame" protects a frame of the *same* assignment from a gap in it; a frame from an + * assignment this display no longer has is not a good frame, it is stale content. + */ + function movedToEmptyGroup(store: ReturnType) { + const moved = { groupId: "group-2", id: "chan-2", key: "online" }; + let stateFetches = 0; + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + stateFetches += 1; + // Playing in group-1; moved to group-2, which has nothing, right after its first fetch. + return jsonResponse( + state( + stateFetches === 1 + ? { + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 1, + slots: [ + { + eligibleFromMs: null, + eligibleUntilMs: null, + enabled: true, + estimatedDurationMs: 5000, + id: "slot-1", + position: 0, + }, + ], + }, + } + : { + channel: moved, + current: null, + playback: { + activeTimelineId: null, + blackout: false, + currentSelectionId: null, + currentSlotId: null, + deadlineAtMs: null, + pendingTimelineId: null, + remainingMs: null, + revision: 1, + selectionRevision: null, + startedAtMs: null, + transport: "playing", + }, + selections: [], + }, + ), + ); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }), + }, + ], + }); + return { fetches: () => stateFetches, host, store }; + } + + it("stops showing the previous group's image when moved to a group with nothing to show", async () => { + const media = createMemoryMediaCache(); + const scenario = movedToEmptyGroup(createMemoryStore()); + const controller = build({ host: scenario.host, media, store: scenario.store }); + await controller.start(); + await settle(); + + expect(scenario.fetches()).toBe(2); + const diagnostics = controller.diagnostics(); + expect(diagnostics.channelId).toBe("chan-2"); + // The old group's image is gone — not merely covered — and the wall says so plainly. + expect(diagnostics.renderedSelectionId).toBeUndefined(); + expect(diagnostics.renderedKey).toBeUndefined(); + expect(diagnostics.slate).toBe(true); + expect(root.dataset.slate).toBe("true"); + expect(root.querySelector("img.signage-layer__foreground[src]")).toBeNull(); + await controller.stop(); + }); + + it("does not bring the previous group's content back from local state after that move", async () => { + const media = createMemoryMediaCache(); + const scenario = movedToEmptyGroup(createMemoryStore()); + const first = build({ host: scenario.host, media, store: scenario.store }); + await first.start(); + await settle(); + await first.stop(); + + // The kiosk restarts while the server is unreachable. It resumes from what it persisted — which + // must be its current assignment, not the group it was moved away from. + const unreachable = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => { + throw new Error("network is gone"); + }, + }, + ], + }); + const restarted = build({ host: unreachable, media, store: scenario.store }); + await restarted.start(); + await settle(); + + expect(restarted.diagnostics().renderedSelectionId).toBeUndefined(); + expect(root.dataset.slate).toBe("true"); + await restarted.stop(); + }); + it("registers again, exactly once, when it was removed from the roster", async () => { let sessions = 0; let subscriptions = 0;