feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js";
/**
* A **test-only** browser renderer, standing in for the contribution a real content module will
* publish.
*
* It is not an installed module and has no manifest: it lives under `tests/`, and the only way it
* reaches a screen is a test handing it to the stage the way the platform's contribution loader
* would. It is built with the platform's own `defineModuleContribution`, so what it proves is the
* actual contract rather than a convenient approximation.
*
* It deliberately does everything a demanding real renderer would:
*
* - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it;
* - presents **internal multiple stages** — a question, then its answer — inside one Signage slide,
* which Signage neither knows about nor times;
* - renders time-based text from the input's clock anchor rather than the device's clock;
* - renders differently per audience;
* - updates in place, without remounting;
* - unmounts deterministically, releasing its timer and its React root.
*
* It receives no API client, no realtime, no principal, no storage, and no credential — because the
* contract gives a renderer none of those. Everything it draws comes from the consumer's input.
*/
export interface TriviaPayload {
readonly question?: unknown;
readonly answer?: unknown;
readonly revealAfterMs?: unknown;
}
function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement {
const payload = input.payload as TriviaPayload;
const question = typeof payload.question === "string" ? payload.question : "…";
const answer = typeof payload.answer === "string" ? payload.answer : "";
const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000;
const [revealed, setRevealed] = useState(false);
// The authoritative now, computed from both clocks. A television's own clock is routinely wrong;
// this is what makes a countdown on two screens agree.
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000));
// `generation` is the dependency that matters and the linter cannot see why: it changes when the
// slide's content changes, which is exactly when this stage should restart — and it does *not*
// change when the clock is re-anchored on reconnect, which is exactly when it should not.
// biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point.
useEffect(() => {
if (input.timing.paused) {
return;
}
// Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides
// what happens *within* it. It cannot extend that deadline and does not try.
setRevealed(false);
const handle = setTimeout(() => setRevealed(true), revealAfterMs);
return () => clearTimeout(handle);
}, [input.generation, input.timing.paused, revealAfterMs]);
return (
<section className="fixture-trivia" data-testid="fixture-trivia">
<h1 data-testid="fixture-trivia-question">{question}</h1>
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
</section>
);
}
/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness"
* is a property that can be observed rather than assumed. */
export interface RendererReadiness {
readonly whenReady: Promise<void>;
ready(): void;
}
export function createReadiness(): RendererReadiness {
let settle: (() => void) | undefined;
const whenReady = new Promise<void>((resolve) => {
settle = resolve;
});
return {
ready(): void {
settle?.();
},
whenReady,
};
}
export interface FixtureRendererCounters {
mounts: number;
updates: number;
unmounts: number;
}
export function createFixtureRenderer(options: {
readonly readiness?: RendererReadiness;
readonly counters?: FixtureRendererCounters;
}) {
const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 };
const definition = defineModuleContribution({
id: "fixture.trivia.slide",
version: "1.0.0",
async mount(container, context) {
counters.mounts += 1;
const root: Root = createRoot(container as Element);
root.render(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
// Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for
// a font resolves only when it is honestly ready, and the stage's fade waits for it.
if (options.readiness) {
await options.readiness.whenReady;
}
return {
unmount() {
counters.unmounts += 1;
root.unmount();
},
update(input) {
counters.updates += 1;
root.render(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
},
};
},
});
return { counters, definition };
}