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/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. 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..3f3148f 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=5e9433d96b413117374a6132d89400867414c2c8 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 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 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/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/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/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/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/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/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, }; 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",