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) => (
- onChannelChange(event.target.value)}
- value={viewer.channelId ?? ""}
- >
- {groups.map((group) => (
-
- {group.channels.map((channel) => (
-
- {channel.name}
-
- ))}
-
- ))}
-
- )}
-
- ) : 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) => (
+ onChannelChange(event.target.value)}
+ value={viewer.channelId ?? ""}
+ >
+ {groups.map((group) => (
+
+ {group.channels.map((channel) => (
+
+ {channel.name}
+
+ ))}
+
+ ))}
+
+ )}
+
+ ) : 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
-
- {
- setCopyStatus("idle");
- void copyText(displaySetupUrl).then(
- () => setCopyStatus("copied"),
- () => setCopyStatus("failed"),
- );
- }}
- >
- Copy URL
-
-
- )
- }
- description="Connect a screen in three short steps."
- title="Pair a display"
- >
-
-
- 1
-
- Open display setup on the screen that will show signage.
-
-
-
- 2
-
- Enter its six-digit code under Display devices in the Dashboard
- sidebar.
-
-
-
- 3
- Name the display and choose its audience channel below.
-
-
- {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 (
+
+
+ {
+ setCopyStatus("idle");
+ void copyText(displaySetupUrl).then(
+ () => setCopyStatus("copied"),
+ () => setCopyStatus("failed"),
+ );
+ }}
+ variant="primary"
+ >
+ Copy setup address
+
+
+ Preview here
+
+
+ )
+ }
+ description="Connect a screen in three short steps."
+ title="Pair a display"
+ >
+
+
+ 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
+ Enter its six-digit code under Display devices in the Dashboard sidebar.
+
+
+ 3
+ Name the display and choose its group below.
+
+
+ {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}
- />
- )}
-
-
- edit.run(async () => {
- await api.send("POST", "channel-groups", {
- name: groupName.trim(),
- });
- setGroupName("");
- })
- }
- variant="primary"
- >
- Create group
-
-
- ) : 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) => (
-
-
- {confirmGroupId === group.id ? (
-
-
- Delete {group.name}? Its playback history will be
- removed. Displays assigned here will move to the
- default channel of another group.
-
-
setConfirmGroupId(undefined)}
- >
- Cancel
-
-
- edit.run(async () => {
- await api.send(
- "DELETE",
- `channel-groups/${group.id}`,
- );
- setConfirmGroupId(undefined);
- })
- }
- variant="danger"
- >
- Confirm delete
-
-
- ) : undefined}
+
+
+ {(control) => (
+ setGroupName(event.target.value)}
+ placeholder="Nursery hallway"
+ value={groupName}
+ />
+ )}
+
+
+ edit.run(async () => {
+ await api.send("POST", "channel-groups", {
+ name: groupName.trim(),
+ });
+ setGroupName("");
+ })
+ }
+ variant="primary"
+ >
+ Create group
+
+
+ ) : 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) => (
+
+
+ {confirmGroupId === group.id ? (
+
+
+ Delete {group.name}? Its playback history will be removed. Displays
+ assigned here will move to the default channel of another group.
+
+
setConfirmGroupId(undefined)}
+ >
+ Cancel
+
+
+ edit.run(async () => {
+ await api.send("DELETE", `channel-groups/${group.id}`);
+ setConfirmGroupId(undefined);
+ })
+ }
+ variant="danger"
+ >
+ Confirm delete
+
+
+ ) : 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 (
-
-
-
-
- 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 (
+
+
+
+
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
- : ""
- }
- />
- )}
-
-
- edit.run(async () => {
- await api.send(
- "POST",
- `channel-groups/${group.id}/channels`,
- {
- key: channelDraft.key.trim(),
- name: channelDraft.name.trim(),
- },
- );
- setChannelDraft({
- groupId: "",
- key: "",
- name: "",
- });
- })
- }
- variant="primary"
- >
- Add channel
-
-
- ) : 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 : ""}
+ />
+ )}
+
+
+
+ edit.run(async () => {
+ await api.send("POST", `channel-groups/${group.id}/channels`, {
+ key:
+ channelDraft.key.trim() ||
+ audienceKeyFor(
+ channelDraft.name,
+ group.channels.map((channel) => channel.key),
+ ),
+ name: channelDraft.name.trim(),
+ });
+ setChannelDraft({
+ groupId: "",
+ key: "",
+ name: "",
+ });
+ })
+ }
+ variant="primary"
+ >
+ Add audience
+
+
+ ) : 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
)}
- remove.run(() => api.send("DELETE", `images/${image.id}`))}
- variant="danger"
- >
- Delete
-
+ 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) => (
- onGroupChange(event.target.value)}
- value={groupId ?? ""}
- >
- {value.groups.map((group) => (
-
- {group.name}
-
- ))}
-
- )}
-
- ),
- { 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) => (
+ onGroupChange(event.target.value)}
+ value={groupId ?? ""}
+ >
+ {value.groups.map((group) => (
+
+ {group.name}
+
+ ))}
+
+ )}
+
+ ),
+ { 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 ? (
-
- run("previous")}
- >
- Previous
-
- {value.snapshot.transport === "paused" ? (
- run("resume")}
- variant="primary"
- >
- Resume
-
- ) : (
- run("pause")}
- >
- Pause
-
- )}
- run("next")}
- >
- Next
-
-
- run("blackout", { on: !value.snapshot.blackout })
- }
- variant={
- value.snapshot.blackout ? "primary" : "secondary"
- }
- >
- {value.snapshot.blackout ? "End blackout" : "Blackout"}
-
- {value.snapshot.staticAssetId === null ? undefined : (
- run("static", { assetId: null })}
- >
- Release hold
-
- )}
- run("reload")}
- >
- Reload content
-
-
- ) : undefined}
+ {canControl ? (
+
+ run("previous")}>
+ Previous
+
+ {value.snapshot.transport === "paused" ? (
+ run("resume")}
+ variant="primary"
+ >
+ Resume
+
+ ) : (
+ run("pause")}>
+ Pause
+
+ )}
+ run("next")}>
+ Next
+
+ run("blackout", { on: !value.snapshot.blackout })}
+ variant={value.snapshot.blackout ? "primary" : "secondary"}
+ >
+ {value.snapshot.blackout ? "End blackout" : "Blackout"}
+
+ {value.snapshot.staticAssetId === null ? undefined : (
+ run("static", { assetId: null })}
+ >
+ Release hold
+
+ )}
+ run("reload")}>
+ Reload content
+
+
+ ) : undefined}
- {canControl ? (
-
- {renderLoadable(
- timelines.state,
- (loaded) => (
- <>
-
- {(control) => (
-
- setActivateId(event.target.value)
- }
- value={activateId}
- >
- Choose a timeline…
- {loaded.timelines.map((timeline) => (
-
- {timeline.name}
-
- ))}
-
- )}
-
-
- run("activate", {
- timelineId: activateId,
- when: "now",
- })
- }
- variant="primary"
- >
- Now
-
-
- run("activate", {
- timelineId: activateId,
- when: "next-boundary",
- })
- }
- >
- At next slot
-
- >
- ),
- { label: "Loading timelines…" },
- )}
-
- ) : undefined}
+ {canControl ? (
+
+ {renderLoadable(
+ timelines.state,
+ (loaded) => (
+ <>
+
+ {(control) => (
+ setActivateId(event.target.value)}
+ value={activateId}
+ >
+ Choose a timeline…
+ {loaded.timelines.map((timeline) => (
+
+ {timeline.name}
+
+ ))}
+
+ )}
+
+
+ run("activate", {
+ timelineId: activateId,
+ when: "now",
+ })
+ }
+ variant="primary"
+ >
+ Now
+
+
+ run("activate", {
+ timelineId: activateId,
+ when: "next-boundary",
+ })
+ }
+ >
+ At next slot
+
+ >
+ ),
+ { 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}
- />
- )}
-
-
- create.run(() =>
- api.send("POST", "timelines", { name: newName.trim() }),
- )
- }
- variant="primary"
- >
- Create
-
-
- ) : 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) => (
-
-
-
- setSelectedId(timeline.id)}
- variant="quiet"
- >
- {timeline.name}
-
- {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 : (
-
- activation.run(() => onActivate(timeline.id))
- }
- variant="primary"
- >
- {timeline.id === activeTimelineId
- ? "Active"
- : "Activate"}
-
- )}
-
-
- ))}
-
-
- ),
- { label: "Loading timelines…", onRetry: timelines.reload },
- )}
-
-
-
+ return (
+
+
+
+ {(control) => (
+ setNewName(event.target.value)}
+ placeholder="Sunday morning"
+ value={newName}
+ />
+ )}
+
+
+ create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
+ }
+ variant="primary"
+ >
+ Create
+
+
+ ) : 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) => (
+
+
+
+ setSelectedId(timeline.id)}
+ variant="quiet"
+ >
+ {timeline.name}
+
+ {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 : (
+ activation.run(() => onActivate(timeline.id))}
+ variant="primary"
+ >
+ {timeline.id === activeTimelineId ? "Active" : "Activate"}
+
+ )}
+
+
+ ))}
+
+
+ ),
+ { 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) => (
- loadProvider(event.target.value)}
- value={providerId}
- >
- Choose a provider…
- {providers.map((provider) => (
-
- {provider.providerId}
-
- ))}
-
- )}
-
- {sources.map((source) => (
-
- edit.run(() =>
- api.send("POST", `timelines/${timelineId}/sources`, {
- label: source.label,
- providerId,
- sourceId: source.id,
- }),
- )
- }
- >
- {`Add source: ${source.label}`}
-
- ))}
- {items.slice(0, 20).map((item) => (
-
- edit.run(() =>
- api.send("POST", `timelines/${timelineId}/slots`, {
- itemId: item.id,
- providerId,
- }),
- )
- }
- >
- {`Pin item: ${item.label}`}
-
- ))}
-
- ) : undefined}
+ {canManage ? (
+
+
+ {(control) => (
+ loadProvider(event.target.value)}
+ value={providerId}
+ >
+ Choose a provider…
+ {providers.map((provider) => (
+
+ {provider.providerId}
+
+ ))}
+
+ )}
+
+ {sources.map((source) => (
+
+ edit.run(() =>
+ api.send("POST", `timelines/${timelineId}/sources`, {
+ label: source.label,
+ providerId,
+ sourceId: source.id,
+ }),
+ )
+ }
+ >
+ {`Add source: ${source.label}`}
+
+ ))}
+ {items.slice(0, 20).map((item) => (
+
+ edit.run(() =>
+ api.send("POST", `timelines/${timelineId}/slots`, {
+ itemId: item.id,
+ providerId,
+ }),
+ )
+ }
+ >
+ {`Pin item: ${item.label}`}
+
+ ))}
+
+ ) : undefined}
- {value.sources.length === 0 ? undefined : (
-
-
Configured sources
- {value.sources.map((source) => (
-
- {source.label}
- {source.providerId}
- {canManage ? (
- <>
-
- edit.run(() =>
- api.send(
- "POST",
- `timelines/${timelineId}/slots`,
- {
- sourceId: source.id,
- },
- ),
- )
- }
- >
- Add slot
-
-
- edit.run(() =>
- api.send(
- "DELETE",
- `timelines/${timelineId}/sources/${source.id}`,
- ),
- )
- }
- variant="danger"
- >
- Remove
-
- >
- ) : undefined}
-
- ))}
-
- )}
+ {value.sources.length === 0 ? undefined : (
+
+
Configured sources
+ {value.sources.map((source) => (
+
+ {source.label}
+ {source.providerId}
+ {canManage ? (
+ <>
+
+ edit.run(() =>
+ api.send("POST", `timelines/${timelineId}/slots`, {
+ sourceId: source.id,
+ }),
+ )
+ }
+ >
+ Add slot
+
+
+ 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 ? (
-
-
- edit.run(() =>
- api.send(
- "PUT",
- `timelines/${timelineId}/slot-order`,
- {
- slotIds: swap(
- value.slots.map(
- (candidate) => candidate.id,
- ),
- index,
- index - 1,
- ),
- },
- ),
- )
- }
- >
- Up
-
-
- edit.run(() =>
- api.send(
- "PUT",
- `timelines/${timelineId}/slot-order`,
- {
- slotIds: swap(
- value.slots.map(
- (candidate) => candidate.id,
- ),
- index,
- index + 1,
- ),
- },
- ),
- )
- }
- >
- Down
-
-
- edit.run(() =>
- api.send(
- "DELETE",
- `timelines/${timelineId}/slots/${slot.id}`,
- ),
- )
- }
- variant="danger"
- >
- Remove
-
-
- ) : 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("PUT", `timelines/${timelineId}/slot-order`, {
+ slotIds: swap(
+ value.slots.map((candidate) => candidate.id),
+ index,
+ index - 1,
+ ),
+ }),
+ )
+ }
+ >
+ Up
+
+
+ edit.run(() =>
+ api.send("PUT", `timelines/${timelineId}/slot-order`, {
+ slotIds: swap(
+ value.slots.map((candidate) => candidate.id),
+ index,
+ index + 1,
+ ),
+ }),
+ )
+ }
+ >
+ Down
+
+
+ edit.run(() =>
+ api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
+ )
+ }
+ />
+
+ ) : undefined}
+
+
+ );
+ })}
+
+
+ )}
- {canManage ? (
-
-
- edit.run(() =>
- api.send("POST", `timelines/${timelineId}/duplicate`, {
- name: `${value.timeline.name} (copy)`,
- }),
- )
- }
- >
- Duplicate
-
-
- edit.run(async () => {
- await api.send("DELETE", `timelines/${timelineId}`);
- onClose();
- })
- }
- variant="danger"
- >
- Delete timeline
-
-
- ) : undefined}
-
- ),
- { label: "Loading timeline…", onRetry: detail.reload },
- )}
-
-
- );
+ {canManage ? (
+
+
+ edit.run(() =>
+ api.send("POST", `timelines/${timelineId}/duplicate`, {
+ name: `${value.timeline.name} (copy)`,
+ }),
+ )
+ }
+ >
+ Duplicate
+
+
+ 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 (
+ setArmed(true)}
+ variant="danger"
+ >
+ {label}
+
+ );
+ }
+ return (
+ {
+ if (event.key === "Escape") {
+ setArmed(false);
+ }
+ }}
+ >
+ {
+ setArmed(false);
+ onConfirm();
+ }}
+ variant="danger"
+ >
+ {confirmLabel}
+
+ setArmed(false)}
+ variant="quiet"
+ >
+ Cancel
+
+
+ );
+}
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",