feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# 0001: Signage owns timing; a provider owns content
|
||||
|
||||
- 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)
|
||||
(browser contributions)
|
||||
|
||||
## Context
|
||||
|
||||
Church screens show announcements, scripture, trivia, service times, giving prompts, and photographs.
|
||||
The obvious design — one module that knows about all of them — is the design the legacy Announcements
|
||||
container has, and it is why adding trivia to that product means changing that product.
|
||||
|
||||
Signage exists to make the opposite true: adding trivia to the screens means installing a Trivia
|
||||
module, and Signage never changes.
|
||||
|
||||
That only works if the line between the two is drawn somewhere principled. Drawn in the wrong place,
|
||||
Signage ends up with an `announcementId` column, or a provider ends up owning a timer and two
|
||||
displays drift apart.
|
||||
|
||||
## Decision
|
||||
|
||||
**Signage owns everything about *when*. A provider owns everything about *what*.**
|
||||
|
||||
| Signage decides | A provider decides |
|
||||
| --- | --- |
|
||||
| Which slot is on screen, starting exactly when, until exactly when | Which of its items is next in a given selection scope |
|
||||
| That a slot's turn has come and a selection must be made | Whether that item may repeat, and when its pool is exhausted |
|
||||
| That every channel in a group advances together | What the item looks like, in its own React renderer |
|
||||
| How long a slot lasts when nothing overrides it | Its own default duration, and its own availability window |
|
||||
| That a resolved selection is persisted, revised, and replayed identically | What is inside the payload it hands back |
|
||||
|
||||
Concretely:
|
||||
|
||||
- Signage calls a provider; a provider never calls Signage's playback. A provider can *notify*
|
||||
Signage that something changed (invalidation) but cannot start, stop, skip, extend, or reorder
|
||||
anything.
|
||||
- Signage supplies a **stable opaque selection-scope id** and asks for the next item. The provider
|
||||
advances its own cursor within that scope, in its own tables. Signage stores no cursor of the
|
||||
provider's, and the provider stores no timing of Signage's.
|
||||
- Signage persists the *result* — item identity, revision, payload per audience, duration,
|
||||
availability — so that the same slot renders identically for a viewer that joined ten seconds later,
|
||||
survives a restart, and can be replayed by `previous` without asking the provider again.
|
||||
- Signage has no column, field, route, or type naming an announcement, a passage, a question, a
|
||||
campus, or a service. A payload is opaque JSON on the way through and opaque JSON at rest.
|
||||
|
||||
## The one exception, and why it is not one
|
||||
|
||||
Native images are Signage's own content: it stores them, processes them, and renders them. That looks
|
||||
like an exception to the rule until you see how it is built — as **Signage's own built-in content
|
||||
provider**, `signage:native-images`, implementing the same registration shape an external provider
|
||||
implements, resolving through the same code path, and persisting into the same resolved-selection
|
||||
table.
|
||||
|
||||
There is therefore exactly one selection pipeline, not two. A native image slot and a Trivia slot
|
||||
differ in which provider answers, and in nothing else. Had native images been special-cased, every
|
||||
later feature — history, offline continuation, audience variants, `previous` — would have had to be
|
||||
written twice and would have drifted.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A provider module can be written, installed, versioned, and removed without Signage knowing it
|
||||
exists. Signage never enumerates provider modules and declares no dependency on one.
|
||||
- A church that never installs Trivia has a Signage that is not missing anything; a timeline simply
|
||||
has no Trivia slot.
|
||||
- Provider failure cannot corrupt playback, because playback state was never the provider's to hold.
|
||||
What a failure costs is at most the freshness of one slot's content.
|
||||
- Signage cannot offer a feature that requires understanding content — "show announcements sorted by
|
||||
campus" is a Trivia-shaped request that belongs to whichever module owns campuses.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **One module that knows every content type.** Rejected: it is the legacy design, and every new kind
|
||||
of content is a change to the module every screen depends on.
|
||||
- **Providers push content into Signage's tables.** Rejected: two writers to one timeline, no way to
|
||||
attribute a bad row, and a provider outage that leaves stale rows nobody owns.
|
||||
- **Providers own their own timing and Signage only composes screens.** Rejected outright: two
|
||||
independent clocks cannot hold two televisions on the same slide, which is the single thing a
|
||||
synchronized channel group exists to guarantee.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 0002: The content-provider contract, and what it deliberately cannot express
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0001](0001-signage-owns-timing-providers-own-content.md),
|
||||
[0003](0003-timelines-slots-and-selection-scopes.md)
|
||||
|
||||
## Context
|
||||
|
||||
`signage.content-providers` is the seam every future content module reaches Signage through. It is
|
||||
the one contract in this repository that will have implementations Signage's authors never see, so
|
||||
its shape has to be right before there is a second implementation rather than after.
|
||||
|
||||
## Decision
|
||||
|
||||
A provider module resolves the capability during its own `start` and registers once. Registration
|
||||
returns a handle; the handle is released during `stop`. Signage keeps registrations **in memory
|
||||
only** — a registration is a running fact, not a stored one, so a provider that is uninstalled leaves
|
||||
no row behind claiming it exists.
|
||||
|
||||
### What a registration carries
|
||||
|
||||
- `providerId`, `providerVersion` — stable identity, chosen by the provider.
|
||||
- `contractVersion` — which version of *this* contract the provider was written against. Signage
|
||||
refuses a registration outside its supported range rather than guessing.
|
||||
- `renderer: { contributionId, contributionVersion }` — the browser renderer this provider's content
|
||||
is drawn by. Signage never imports it; it hands the identity to its own display, which asks the
|
||||
host to mount it (ADR 0041). A provider whose renderer the platform did not publish is registered
|
||||
and reported as unrenderable rather than refused, because publication is the platform's decision
|
||||
and can change without the provider changing.
|
||||
- `defaultDurationMs` — how long this provider's content is shown when no slot overrides it.
|
||||
- `listSources(query)` — the sources an operator may put in a timeline. Management discovery only.
|
||||
- `listItems(query)` — optional. Specific items, so an operator can pin one.
|
||||
- `resolveNext(request)` — **the only call that advances anything.**
|
||||
- `refresh(request)` — re-resolve one already-selected item without advancing.
|
||||
|
||||
### What Signage supplies, and nothing more
|
||||
|
||||
```text
|
||||
selectionScopeId stable, opaque, and the same string every time this source occurs in this timeline
|
||||
sourceId which of the provider's own sources is being asked
|
||||
timelineId which timeline is running
|
||||
channelGroupId which synchronized group is asking
|
||||
audienceKeys every audience channel in that group, in a stable order
|
||||
at the instant being resolved for, as an absolute ISO 8601 instant
|
||||
timezone the installation's IANA zone, for a provider whose availability is calendar-shaped
|
||||
```
|
||||
|
||||
Signage does not supply a principal, a session, a database, storage, a URL, or a permission. A
|
||||
provider is already running inside the same trusted process; what it needs from Signage is *when*,
|
||||
and that is the whole list.
|
||||
|
||||
### What a provider returns
|
||||
|
||||
One **logical** selection — never one per channel:
|
||||
|
||||
```text
|
||||
itemId the provider's own stable identity for the content
|
||||
itemRevision changes whenever the payload changes and the item stays the same thing
|
||||
durationMs? overrides the provider's default for this one item
|
||||
availableUntil? when this selection stops being valid, as an absolute instant
|
||||
withdrawn? this item is explicitly gone; remove it at the next safe boundary
|
||||
presentation? opaque presentation values the provider itself supplies to its own renderer
|
||||
variants one payload per audience key, plus one default
|
||||
```
|
||||
|
||||
The default variant is required. An audience-specific variant is optional, and a provider that has
|
||||
none simply returns its default — which is what makes "in-person" and "online" a provider feature
|
||||
rather than a Signage one.
|
||||
|
||||
### Three distinct failure answers, deliberately
|
||||
|
||||
| The provider says | Signage does |
|
||||
| --- | --- |
|
||||
| Returns `null` | Nothing is available for this source right now. **Skip the slot immediately.** No error, no slate, no gap. |
|
||||
| Returns `{ withdrawn: true }` | This item is explicitly gone. Drop it at the next safe reconciliation boundary. |
|
||||
| Throws, rejects, or exceeds its deadline | A temporary failure. **Keep the last known good selection, mark it stale**, and never reset the slot. |
|
||||
|
||||
Collapsing these into one would be the whole design's biggest mistake: an empty Trivia pool would
|
||||
show an error, and a provider restart would clear the screen.
|
||||
|
||||
### Bounded, and sanitized
|
||||
|
||||
Every provider call is wrapped: a deadline (`providerTimeoutMilliseconds`), a `try`/`catch`, and a
|
||||
result validated before it is believed. A malformed result is rejected with a stable
|
||||
`SIGNAGE_PROVIDER_*` code carrying no provider value, no payload, and no caught exception. A provider
|
||||
cannot crash the Signage runtime, corrupt playback, or put its own words on a screen or in a log.
|
||||
|
||||
### Invalidation is a notification, not a command
|
||||
|
||||
`handle.invalidate({ ... })` tells Signage that something it already resolved may have changed.
|
||||
Signage decides what to do: refresh the affected selections, bump their revision, and broadcast. The
|
||||
provider cannot say *when*, cannot skip a slot, and cannot extend a deadline. This is what lets a
|
||||
cancelled event update a mounted React renderer within a second while its slot's authoritative
|
||||
deadline stays exactly where it was.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A provider is a small object of pure functions over its own data. It needs no timer, no socket, no
|
||||
knowledge of channels, and no Signage table.
|
||||
- Signage's provider code is the same for every provider, including its own built-in image provider.
|
||||
- A provider written against contract v1 keeps working when v2 exists, because v2 will be a new
|
||||
version a provider opts into by declaring it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let providers write resolved selections directly.** Rejected: two writers, no attribution, and
|
||||
stale rows after an uninstall.
|
||||
- **One `resolve(at)` call with no scope.** Rejected: five Announcement slots in one pattern must
|
||||
share one cursor, and without a scope id a provider cannot tell "the next one" from "the first one".
|
||||
- **Per-channel resolution.** Rejected: it makes audience variants a race. One logical selection
|
||||
resolved once, with variants derived from it, is what keeps two channels on the same slide.
|
||||
- **An error means "skip".** Rejected: a provider that is briefly down would silently rewrite the
|
||||
running order, and nobody would ever find out.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 0003: Timelines are ordered patterns; a source instance is a selection scope
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0002](0002-content-provider-contract.md), [0004](0004-channel-groups-and-one-clock.md)
|
||||
|
||||
## Context
|
||||
|
||||
An operator building a Sunday-morning loop wants something like:
|
||||
|
||||
```text
|
||||
Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻
|
||||
```
|
||||
|
||||
Five Announcement slots. They must not all show the same announcement, and they must not each start a
|
||||
separate rotation that happens to begin at the same place. They are five *turns* of one rotation.
|
||||
|
||||
## Decision
|
||||
|
||||
Three concepts, and no more.
|
||||
|
||||
### A timeline is an ordered, repeating pattern
|
||||
|
||||
`signage_timelines` holds a name, an optional eligibility window, and a revision. It holds no cursor,
|
||||
no clock, and no channel: a timeline is a *program*, and running one is what a channel group does.
|
||||
The same timeline may be activated on two groups at once and they will not interfere.
|
||||
|
||||
### A slot is a position in that pattern
|
||||
|
||||
`signage_timeline_slots` rows are `(timeline_id, position)` with `position` **unique and contiguous**
|
||||
from zero. Contiguity is enforced inside the transaction that changes it, never by renumbering on
|
||||
read: a gap would make "the next slot" ambiguous exactly when playback needs it least.
|
||||
|
||||
A slot's identity is its `id`, which is stable across every reorder. Playback history, resolved
|
||||
selections, and a display's cursor all reference the id, so dragging a slot up the list never
|
||||
rewrites what already happened.
|
||||
|
||||
Each slot is one of two kinds:
|
||||
|
||||
- **`provider-source`** — "ask this source for the next item when this slot's turn comes".
|
||||
- **`pinned-item`** — "show exactly this item". A native Signage image is a pinned item of the
|
||||
built-in image provider, which is why the two need no separate code path (ADR 0001).
|
||||
|
||||
Every slot may carry an `enabled` flag, a duration override, its own eligibility window, and — for
|
||||
native images only — presentation overrides.
|
||||
|
||||
### A source instance is a selection scope
|
||||
|
||||
`signage_provider_sources` is the piece that makes the five-Announcement pattern work. One row is
|
||||
"the Announcements module's `active-slides` source, as used by this timeline", and it carries a stable
|
||||
opaque `selection_scope` string.
|
||||
|
||||
Every slot referencing that row passes the **same** scope id to `resolveNext`. The provider advances
|
||||
its own cursor within that scope, so the five occurrences walk through five different announcements
|
||||
and the sixth wraps according to *the provider's* repeat policy — which Signage neither implements
|
||||
nor knows about.
|
||||
|
||||
Two source rows for the same provider source are two independent rotations, which is exactly how an
|
||||
operator expresses "a fast loop here and a slow one there".
|
||||
|
||||
Signage generates the scope id and stores it. It is opaque to the provider in the sense that its
|
||||
shape means nothing; it is stable in the sense that it survives edits, restarts, and reorders. It is
|
||||
**not** derived from the timeline id or the slot id, because either would silently reset a
|
||||
provider's rotation when an operator renamed or reordered something.
|
||||
|
||||
### Eligibility is `[start, end)`, in absolute instants
|
||||
|
||||
Both a timeline and a slot may carry a window. It is half-open: active exactly at `start`, inactive
|
||||
exactly at `end`, which is the only convention under which two adjacent windows neither overlap nor
|
||||
leave a gap.
|
||||
|
||||
Every stored instant is absolute (`timestamptz`). The installation's IANA zone is an operator setting
|
||||
used to *write and read* those instants for a human, never to interpret an ambiguous local timestamp
|
||||
at run time. A daylight-saving transition therefore cannot move a slot.
|
||||
|
||||
Signage decides whether a source *participates*; the provider decides what is *available*. Leaving
|
||||
Trivia out of a service means using a timeline without a Trivia slot, or disabling that slot — and
|
||||
Signage still never learns what Trivia is.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reordering, renaming, duplicating, and disabling are cheap and safe: none of them touch a scope id
|
||||
or a selection's identity.
|
||||
- A timeline with every slot disabled, or every slot ineligible, is a legitimate state — it resolves
|
||||
to nothing, and the display shows its slate rather than an error.
|
||||
- An operator can express "three announcements, then trivia, then two more announcements" without any
|
||||
concept of grouping, nesting, or weighting. The pattern *is* the expression.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Slot position as identity.** Rejected: every reorder would rewrite history and break a display's
|
||||
cursor mid-service.
|
||||
- **Scope derived from `(timeline, provider, source)`.** Rejected: it silently merges two source rows
|
||||
an operator deliberately created as separate rotations, and changes meaning when a timeline is
|
||||
duplicated.
|
||||
- **A `repeat: n` count on one slot instead of repeated slots.** Rejected: the pattern is the thing an
|
||||
operator reads, and a count hides where in the loop the repeats actually fall.
|
||||
- **Local wall-clock schedules.** Rejected: one ambiguous hour every autumn, resolved silently, on the
|
||||
screens a congregation is looking at.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 0004: A channel group is one clock; an audience channel is one rendering of it
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0002](0002-content-provider-contract.md), [0005](0005-authoritative-playback.md)
|
||||
|
||||
## Context
|
||||
|
||||
A church wants the lobby screens, the sanctuary side screens, and the online stream overlay to be on
|
||||
the same slide at the same moment — but the online audience should see "watching from home" wording
|
||||
where the room sees "welcome in". That is one program with two renderings, not two programs.
|
||||
|
||||
It also wants the nursery hallway to run a completely different loop. That *is* two programs.
|
||||
|
||||
## Decision
|
||||
|
||||
**A channel group owns exactly one authoritative timeline clock.** It has one active timeline, one
|
||||
current slot, one start instant, one deadline, one pause state, one blackout state, and one pending
|
||||
activation. Everything a group's channels do, they do together.
|
||||
|
||||
**A channel is an audience within a group.** Each has a `key` (`default`, `in-person`, `online`, …),
|
||||
and exactly one channel per group is the default. Channels differ in exactly one way: which variant of
|
||||
a resolved selection they render.
|
||||
|
||||
- Signage resolves **one logical selection per slot occurrence**, for the whole group.
|
||||
- The provider is asked once, with every audience key the group has.
|
||||
- Each channel renders the variant matching its key, or the default variant if the provider offered
|
||||
none.
|
||||
|
||||
A truly independent clock requires another channel group. There is no per-channel pause, no
|
||||
per-channel skip, and no per-channel timeline — and no route that could express one.
|
||||
|
||||
**One default channel is created automatically**, and no other. `in-person` and `online` are examples
|
||||
in a document, not rows in a migration: a church that never needs a second audience never sees one.
|
||||
|
||||
**A viewer never chooses its channel.** A display registers with its persistent viewer identity and
|
||||
joins the default channel of the default group; an administrator moves it from the management
|
||||
dashboard. A public display that could pick its own channel would let anyone who reaches the page
|
||||
select what a screen shows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two viewers that connected minutes apart render the same slot with the same deadline, because both
|
||||
read the same persisted absolute instants rather than counting from when they arrived.
|
||||
- Adding an audience is additive: the provider starts returning a second variant, and the default
|
||||
channel is unaffected.
|
||||
- A provider that returns no variant for `online` is not a failure and produces no warning. Returning
|
||||
the default is the contract.
|
||||
- "Pause the lobby but not the sanctuary" is impossible by construction, and correctly so — those are
|
||||
two groups.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **One clock per channel, synchronized by convention.** Rejected: two clocks drift, and no amount of
|
||||
correction makes two televisions in one room agree.
|
||||
- **Per-channel timelines with a shared cursor.** Rejected: the cursor is only meaningful against one
|
||||
pattern; two patterns of different length make "the same slot" undefined.
|
||||
- **Let the display pick its audience from a query parameter.** Rejected: it is an unauthenticated
|
||||
content selector on a screen in a public hallway.
|
||||
- **Create `in-person` and `online` channels on install.** Rejected: it presumes a broadcast ministry
|
||||
every church does not have, and an unused channel is a thing an operator has to understand and
|
||||
ignore forever.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 0005: Playback is absolute instants reconciled forward, never a countdown
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Related: [0004](0004-channel-groups-and-one-clock.md),
|
||||
[0006](0006-display-rendering-and-offline-continuation.md)
|
||||
|
||||
## Context
|
||||
|
||||
The proven behaviour this preserves comes from the legacy Announcements `v2` playback engine, which
|
||||
ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won
|
||||
properties are worth restating, because they are the ones a naive implementation loses:
|
||||
|
||||
1. **Absolute instants, not remaining time.** A slide has a `startedAt` and a `deadlineAt`; a timer is
|
||||
only ever a hint about when to look again.
|
||||
2. **Reconcile forward from the previous authoritative deadline**, never from "now". Otherwise a
|
||||
delayed callback shortens nothing and lengthens everything, and drift accumulates all day.
|
||||
3. **Catch up arithmetically.** A screen that was off for a week must not replay a week of slides.
|
||||
|
||||
## Decision
|
||||
|
||||
### One serialized service per channel group
|
||||
|
||||
Each group has exactly one playback service owning its persisted state and its timers. Every mutation
|
||||
— a timer firing, an operator command, a provider invalidation — runs serialized against that group,
|
||||
inside one explicit transaction, and produces at most one revision increment. Two concurrent
|
||||
commands cannot interleave into a state neither asked for.
|
||||
|
||||
`revision` increases **only on semantic change**. An exact no-op — resuming something already
|
||||
running, activating the timeline already active — commits nothing and broadcasts nothing.
|
||||
|
||||
### Reconciliation, not simulation
|
||||
|
||||
`reconcile(group, at)` is the single entry point. It reads persisted state and walks it forward to
|
||||
`at`:
|
||||
|
||||
- If the current slot's deadline is still in the future, **nothing happens**. This is what makes
|
||||
restart recovery show the same item it was showing, rather than choosing a new one.
|
||||
- Otherwise the boundary is the *previous deadline*, not `at`. The next slot starts exactly when the
|
||||
last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide
|
||||
forever.
|
||||
- Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing
|
||||
slot can only be cut short by *its own* window, never by another slot's.
|
||||
|
||||
### Catch-up is bounded by boundaries, not by missed slides
|
||||
|
||||
When the gap is long, Signage advances the slot cursor **arithmetically**: it computes the effective
|
||||
duration of each enabled, eligible slot from what it already knows — the slot override, or the
|
||||
provider's registered default, or the native-image default — sums one pass of the pattern, and skips
|
||||
whole passes with a division. Only the slot it actually lands on is resolved.
|
||||
|
||||
That is the property that keeps a provider's rotation sane: catching up a week does **not** call
|
||||
`resolveNext` ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding
|
||||
stops at the first eligibility boundary in the interval, exactly as `v2` did, so a schedule change
|
||||
inside the outage is still honoured.
|
||||
|
||||
### Transport
|
||||
|
||||
| Control | Meaning |
|
||||
| --- | --- |
|
||||
| Pause | Freeze the current slot. Persist the remainder; clear start and deadline. |
|
||||
| Resume | `deadline = now + remainder`; `start = deadline − duration`, so the reconstructed timeline stays consistent. |
|
||||
| Static hold | Show one native image while **preserving the normal cursor and remainder** underneath. Releasing it resumes from that remainder. |
|
||||
| Next / Previous | Move over **actually resolved playback**: `previous` re-shows the previous resolved selection from history, not "the slot before this one". |
|
||||
| Blackout | Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving. |
|
||||
| Reload | Refresh provider availability and re-resolve upcoming selections **without disturbing the current frame or its deadline**. |
|
||||
| Activate now / at next boundary | Immediate, or queued as `pending` and applied at the next safe slot boundary. |
|
||||
|
||||
A provider's internal React behaviour may change anything it likes during a slot; it may not extend
|
||||
that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted.
|
||||
|
||||
### Realtime carries revisions, not content
|
||||
|
||||
Signage publishes compact authoritative state — group id, revision, slot identity, absolute
|
||||
start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are
|
||||
fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have
|
||||
refetches rather than waiting. That closes the GET-then-subscribe race without a second transport,
|
||||
and keeps every publication far inside the platform's payload ceiling.
|
||||
|
||||
Signage introduces no socket, no broker, and no second realtime transport. It publishes through the
|
||||
platform's module realtime contract and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's
|
||||
local cursor is discarded rather than reconciled.
|
||||
- Timers are never load-bearing. Losing one costs a late transition, not a wrong one.
|
||||
- Two displays that connected minutes apart agree, because they both read the same absolute instants.
|
||||
- A long outage costs one reconciliation, not one per missed slide.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A countdown per slide.** Rejected: it drifts, and it cannot be restored after a restart.
|
||||
- **Reconcile from `now`.** Rejected: every late callback permanently lengthens the loop.
|
||||
- **Replay every missed slide to catch up.** Rejected: it burns a provider's rotation and can take
|
||||
longer than the outage.
|
||||
- **Publish full state over realtime.** Rejected: a large timeline exceeds the payload ceiling, and a
|
||||
screen that missed one message would be wrong until the next one.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority
|
||||
|
||||
- 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)
|
||||
|
||||
## Context
|
||||
|
||||
The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
|
||||
transparent, which let the black page background show through the middle of every transition. `v2`
|
||||
fixed it with two permanent layers and never fading the outgoing one, and that fix is the single most
|
||||
visible quality difference on a real television.
|
||||
|
||||
Signage additionally has to transition between things that are not images at all: a provider's live
|
||||
React renderer, mounted through the platform's contribution ABI.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two permanent scene layers, and one direction of fade
|
||||
|
||||
The display owns exactly two full-screen layers, created once and never rebuilt. For every
|
||||
transition:
|
||||
|
||||
1. Resolve and mount the incoming content in the **hidden** layer — a native image, or a contributed
|
||||
renderer mounted through `context.contributions.mount(...)`.
|
||||
2. Wait for **actual readiness**: `img.decode()` for an image, and the renderer's own `mount`
|
||||
resolution for a contribution, which the contribution ABI defines as asynchronous precisely so a
|
||||
renderer can say "not yet".
|
||||
3. Discard completion belonging to a stale generation. A newer transition always wins.
|
||||
4. Keep the outgoing layer **fully opaque** the entire time.
|
||||
5. Raise the prepared incoming layer above it at opacity zero.
|
||||
6. Fade **only the incoming layer** in.
|
||||
7. Unmount and release the old layer after the fade completes.
|
||||
|
||||
Both layers are never faded at once, so the black root can never contribute to the composite.
|
||||
|
||||
### Failure keeps the last good frame
|
||||
|
||||
If incoming content cannot be decoded or mounted: keep the frame that is already on screen, record a
|
||||
bounded structured error, mark **that selection** locally unusable, and continue. A broken item is
|
||||
never retried in a tight loop, and one broken item never blanks a screen.
|
||||
|
||||
An individually empty provider slot is different, and is skipped immediately with no error at all —
|
||||
that is the provider saying "nothing right now", which is ordinary.
|
||||
|
||||
Only when there is neither renderable timeline content nor a cached good frame does the display show
|
||||
its built-in slate: restrained, non-flashing bars and the words *Content unavailable*, carrying no
|
||||
exception, URL, payload, credential, database detail, or identifier. Detail belongs in Signage's
|
||||
sanitized management telemetry, where an already-authorized operator can read it.
|
||||
|
||||
A deliberate blackout is not a failure and never reaches the slate: it is pure black.
|
||||
|
||||
### The management preview is the same code
|
||||
|
||||
The Dashboard's live preview mounts the identical renderer host, the identical native-image
|
||||
presentation resolver, and the identical layer transition. It differs in exactly two ways: it does not
|
||||
register a viewer, and it sends no heartbeat. A preview that reimplemented the transition would drift
|
||||
from what the wall actually shows, which makes it worse than no preview.
|
||||
|
||||
### Offline continuation, and its honest limit
|
||||
|
||||
Once mounted, a display survives losing the host. Signage persists its own domain state — viewer
|
||||
identity, the last authoritative snapshot, resolved selections and revisions, audience payloads, the
|
||||
timing anchor, transport state, renderer contribution identity, and the most recent safe error — in
|
||||
**IndexedDB**, and native image bytes in **Cache Storage**. Ordinary HTTP cache is not an asset
|
||||
manager and is not used as one.
|
||||
|
||||
While offline the display continues deterministically from the last authoritative anchor, applying
|
||||
cached scheduling and expiration, preserving pause, blackout, static hold, and remaining duration,
|
||||
skipping explicitly expired or withdrawn content, keeping the last good frame when expected content
|
||||
is unusable, and never inventing provider data. Catch-up is arithmetic, mirroring the server's own
|
||||
rule, so a long outage does not replay linearly.
|
||||
|
||||
**A local cursor is never reported back as authority.** On reconnect the server's state wins
|
||||
immediately; incoming artifacts and data are staged before the current frame is replaced, no missed
|
||||
slides are replayed, and superseded payloads and image bytes are pruned only after the new snapshot
|
||||
is usable.
|
||||
|
||||
### The limit this cannot fix, stated rather than papered over
|
||||
|
||||
**A cold browser start while Ekklesia is unreachable cannot mount Signage at all.** The platform's
|
||||
display service worker caches the shell and the declared code, deliberately not the display
|
||||
descriptor — a descriptor is authorization-bearing, and caching one would let a revoked screen keep
|
||||
showing content. Signage cannot and must not work around that: everything above applies to a display
|
||||
that is **already mounted** when its network fails, which is the overwhelmingly common case (a router
|
||||
reboot, an uplink drop, a switch power-cycle).
|
||||
|
||||
True offline cold start requires something only Core can provide: an authorized offline display
|
||||
descriptor or lease with its own expiry and revocation. That is tracked as an Ekklesia prerequisite,
|
||||
not as Signage work, and this repository does not claim the capability it does not have.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Transitions are visually identical for images and for live React renderers, because readiness is
|
||||
awaited rather than assumed for both.
|
||||
- A provider renderer that throws costs one slide, not the screen.
|
||||
- The preview and the wall cannot disagree.
|
||||
- A screen that reboots during an outage shows Ekklesia's own offline state and recovers by itself
|
||||
when connectivity returns, but shows no Signage content until it can resolve its descriptor.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Crossfade both layers.** Rejected: it is the `v1` bug, and it is visible on every transition.
|
||||
- **Treat `onload`, or a fixed delay, as readiness.** Rejected: a large image paints mid-fade, and a
|
||||
renderer that is still fetching a font shows a flash of the wrong thing.
|
||||
- **Cache the display descriptor so a cold start works offline.** Rejected: it is a stored permission
|
||||
decision, and a revoked screen would keep showing content. This is the platform's rule, and Signage
|
||||
respecting it is the point.
|
||||
- **Use the HTTP cache for image bytes.** Rejected: no eviction control, no way to prune superseded
|
||||
renditions deliberately, and no way to know what is actually available offline.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 0007: A native image is content-addressed, archived once, and never baked
|
||||
|
||||
- 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)
|
||||
|
||||
## Context
|
||||
|
||||
Native images are the only content Signage owns, and the one thing it must do well with no other
|
||||
module installed. A church's actual usage is an operator dragging twenty exported slides onto a page,
|
||||
some of them the same file they uploaded last week.
|
||||
|
||||
## Decision
|
||||
|
||||
### Identity is the content hash
|
||||
|
||||
An asset's identity is the **SHA-256 of the uploaded bytes**, and the database enforces it as unique.
|
||||
Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two
|
||||
operators uploading the same file — even simultaneously — produce one asset and one set of stored
|
||||
objects: the loser of that race finds the winner's asset and reports success, because the image the
|
||||
operator uploaded is stored.
|
||||
|
||||
Object keys are scoped to the asset (`assets/<asset-id>/…`), and until that asset's row commits they
|
||||
are **temporary**: nothing references them, nothing can select them, and a failed ingest deletes
|
||||
every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a
|
||||
successful upload cost exactly one write of each object instead of two — the commit is the database
|
||||
row, not a second copy of 25 MiB.
|
||||
|
||||
### One file per request, streamed, bounded
|
||||
|
||||
Uploads use `bodyMode: "streamed"` with a **25 MiB** Signage limit, inside the platform's 32 MiB
|
||||
absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty
|
||||
individually bounded requests, each of which can fail cheaply, rather than one request whose failure
|
||||
wastes everything already sent. Nothing in Signage can express an aggregate upload.
|
||||
|
||||
Because a `413` can arrive *after* the handler starts in streamed mode, **nothing is written anywhere
|
||||
until the whole request has been read and validated**. An upload that is aborted, oversized,
|
||||
malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes,
|
||||
the objects are written to the temporary asset-scoped keys above and the row commits last; any
|
||||
failure in between removes every object that attempt wrote. A partial write is never promoted to an
|
||||
asset.
|
||||
|
||||
### Accepted, and refused
|
||||
|
||||
Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video,
|
||||
multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds
|
||||
**40 million pixels** — the last because decoded pixels, not file size, are what a decompression bomb
|
||||
actually costs.
|
||||
|
||||
Format is decided by **decoding the bytes**, never by the filename or the declared content type.
|
||||
|
||||
### Three stored objects, and what each is for
|
||||
|
||||
| Object | Purpose |
|
||||
| --- | --- |
|
||||
| Original | Archived **byte-for-byte, unmodified**. It is the church's file; Signage is not entitled to rewrite it. |
|
||||
| Display WebP | Inside-fit within 1920×1080, quality 82. What a television actually renders. |
|
||||
| Thumbnail WebP | Inside-fit within 480×270, quality 72. What the library grid renders. |
|
||||
|
||||
Both renditions are auto-oriented, **never cropped**, **never upscaled**, stripped of metadata, and
|
||||
flattened against black. The original's real dimensions, orientation, and useful metadata stay in
|
||||
PostgreSQL, where they are queryable without touching storage.
|
||||
|
||||
### Presentation is resolved at render time, never baked
|
||||
|
||||
A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred
|
||||
duplicate, no pillarbox, no floating-card shadow. Anything else is shown **whole** with
|
||||
`object-fit: contain` over a blurred, solid, or black background, optionally darkened, with a
|
||||
foreground shadow.
|
||||
|
||||
None of that is written into a stored rendition. Changing a slot's background from blurred to black
|
||||
is a database update, not a re-encode, and the same rendition serves both. These controls apply to
|
||||
native images only — an external provider owns its rendering completely.
|
||||
|
||||
### Keys are persisted; URLs are not
|
||||
|
||||
Signage stores **logical object keys** and asks the host for a fresh read URL when something is about
|
||||
to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would
|
||||
be storing a credential that stops working. Signage never configures, names, or identifies a storage
|
||||
service.
|
||||
|
||||
### Deletion is reference-aware
|
||||
|
||||
Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused
|
||||
with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local
|
||||
age never invents one. Rendition identity is immutable — a rendition is replaced by uploading
|
||||
different bytes, which by definition produces a different asset.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays
|
||||
responsive because none of them blocks another.
|
||||
- Re-uploading last week's file costs one hash and one row read.
|
||||
- A partially failed ingest leaves a temporary object that is removed, never an asset in a state
|
||||
playback could select.
|
||||
- Changing presentation is instant and lossless, because nothing was baked.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A generated UUID as identity.** Rejected: it makes deduplication impossible and retries dangerous.
|
||||
- **Trust the declared content type.** Rejected: it is attacker-controlled and routinely wrong even
|
||||
when it is not.
|
||||
- **Bake the background and shadow into the rendition.** Rejected: every presentation change becomes a
|
||||
re-encode, and the archived original stops being the source of truth.
|
||||
- **One multi-file upload request.** Rejected: one bad byte wastes every byte already sent, and the
|
||||
platform's ceiling is per request for exactly that reason.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Signage architecture decision records
|
||||
|
||||
These record the durable choices behind Signage itself. Ekklesia's own ADRs remain authoritative for
|
||||
everything the platform owns — manifests, capabilities, permissions, storage, realtime, browser
|
||||
artifacts, contributions, and request bodies — and are never restated here.
|
||||
|
||||
- Filenames use a four-digit sequence followed by a short kebab-case title.
|
||||
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
|
||||
- Accepted ADRs are immutable except for status and cross-reference corrections.
|
||||
- A changed decision receives a new ADR that links to and supersedes the old one.
|
||||
|
||||
## Index
|
||||
|
||||
- [0001: Signage owns timing; a provider owns content](0001-signage-owns-timing-providers-own-content.md) — Accepted
|
||||
- [0002: The content-provider contract, and what it deliberately cannot express](0002-content-provider-contract.md) — Accepted
|
||||
- [0003: Timelines are ordered patterns; a source instance is a selection scope](0003-timelines-slots-and-selection-scopes.md) — Accepted
|
||||
- [0004: A channel group is one clock; an audience channel is one rendering of it](0004-channel-groups-and-one-clock.md) — Accepted
|
||||
- [0005: Playback is absolute instants reconciled forward, never a countdown](0005-authoritative-playback.md) — Accepted
|
||||
- [0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority](0006-display-rendering-and-offline-continuation.md) — Accepted
|
||||
- [0007: A native image is content-addressed, archived once, and never baked](0007-native-images.md) — Accepted
|
||||
@@ -0,0 +1,343 @@
|
||||
# Writing a Signage content provider
|
||||
|
||||
Signage decides **when** something is on screen. Your module decides **what**.
|
||||
|
||||
This document is the published contract for `signage.content-providers` version **1.0.0**, and for
|
||||
the renderer input Signage hands your browser contribution. It is written to be implementable from
|
||||
here alone — you will not import a line of Signage's code, because Ekklesia modules are independently
|
||||
installed peers and `pnpm boundaries` refuses a module that imports another module.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you declare
|
||||
|
||||
In your own `ekklesia.module.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"modules": { "required": { "signage": "^0.1.0" } },
|
||||
"capabilities": {
|
||||
"required": {
|
||||
"signage.content-providers": "^1.0.0",
|
||||
"signage.slide-renderer": "^1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"id": "announcements.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The module dependency is what makes the platform start Signage before you and stop you before
|
||||
Signage — so your registration always has something to register with, and your unregistration always
|
||||
happens first.
|
||||
|
||||
## 2. What you register
|
||||
|
||||
During your own `start` hook:
|
||||
|
||||
```ts
|
||||
const signage = context.capabilities.resolve("signage.content-providers");
|
||||
if (!signage) {
|
||||
return; // Signage is not installed. Nothing else about your module should care.
|
||||
}
|
||||
const registry = signage.implementation as SignageContentProviders;
|
||||
this.handle = registry.register({
|
||||
providerId: "announcements",
|
||||
providerVersion: "1.0.0",
|
||||
contractVersion: "1.0.0",
|
||||
renderer: { contributionId: "announcements.signage-slide", contributionVersion: "1.0.0" },
|
||||
defaultDurationMs: 10_000,
|
||||
listSources,
|
||||
listItems,
|
||||
resolveNext,
|
||||
refresh,
|
||||
});
|
||||
```
|
||||
|
||||
And release it in `stop`:
|
||||
|
||||
```ts
|
||||
this.handle?.unregister();
|
||||
```
|
||||
|
||||
`unregister` is idempotent, which matters: your `stop` hook runs **before** rollback if your own
|
||||
`start` throws partway, and again during reverse-order shutdown.
|
||||
|
||||
### The types, to copy into your own module
|
||||
|
||||
```ts
|
||||
type SignageJsonValue =
|
||||
| boolean | number | string | null
|
||||
| readonly SignageJsonValue[]
|
||||
| { readonly [key: string]: SignageJsonValue };
|
||||
|
||||
interface SignageContentProviders {
|
||||
readonly contractVersion: string;
|
||||
register(registration: SignageProviderRegistration): SignageProviderHandle;
|
||||
}
|
||||
|
||||
interface SignageProviderHandle {
|
||||
unregister(): void;
|
||||
invalidate(change?: { sourceId?: string; itemId?: string }): void;
|
||||
}
|
||||
|
||||
interface SignageProviderRegistration {
|
||||
providerId: string; // stable, yours, `[\w][\w.:-]*`, ≤ 200 chars
|
||||
providerVersion: string; // semantic version
|
||||
contractVersion: string; // "1.0.0"
|
||||
renderer: { contributionId: string; contributionVersion: string };
|
||||
defaultDurationMs: number; // 1000 … 3_600_000
|
||||
listSources(request: DiscoveryRequest): Awaitable<readonly SourceDescriptor[]>;
|
||||
listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable<readonly ItemDescriptor[]>;
|
||||
resolveNext(request: ResolveRequest): Awaitable<Resolution | null>;
|
||||
refresh(request: RefreshRequest): Awaitable<Resolution | null>;
|
||||
}
|
||||
|
||||
interface DiscoveryRequest { at: string; timezone: string }
|
||||
|
||||
interface SourceDescriptor { id: string; label: string; description?: string }
|
||||
interface ItemDescriptor { id: string; label: string; description?: string; availableUntil?: string | null }
|
||||
|
||||
interface ResolveRequest {
|
||||
selectionScopeId: string; // stable and opaque — see §3
|
||||
sourceId: string;
|
||||
timelineId: string;
|
||||
channelGroupId: string;
|
||||
audienceKeys: readonly string[];
|
||||
at: string; // absolute ISO 8601 instant
|
||||
timezone: string; // IANA zone
|
||||
}
|
||||
|
||||
interface RefreshRequest extends Omit<ResolveRequest, "sourceId" | "selectionScopeId"> {
|
||||
itemId: string;
|
||||
sourceId?: string;
|
||||
selectionScopeId?: string;
|
||||
}
|
||||
|
||||
interface Resolution {
|
||||
itemId: string;
|
||||
itemRevision: string; // changes when the payload changes, item unchanged
|
||||
durationMs?: number; // 1000 … 3_600_000
|
||||
availableUntil?: string | null; // absolute ISO 8601 instant
|
||||
withdrawn?: boolean;
|
||||
presentation?: SignageJsonValue; // yours, for your own renderer
|
||||
variants: readonly { audienceKey: string | null; payload: SignageJsonValue }[];
|
||||
}
|
||||
```
|
||||
|
||||
## 3. The selection scope is the whole idea
|
||||
|
||||
An operator's timeline might be:
|
||||
|
||||
```text
|
||||
Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻
|
||||
```
|
||||
|
||||
Those five Announcement slots are five **turns of one rotation**, not five rotations. Signage
|
||||
expresses that by giving every slot that references one configured source instance the same
|
||||
`selectionScopeId`, and asking `resolveNext` once per turn.
|
||||
|
||||
- The scope id is **stable**: it survives edits, reorders, renames, and restarts.
|
||||
- It is **opaque**: its shape means nothing, so never parse it.
|
||||
- It is **not** derived from the timeline or slot id, because either would silently reset your
|
||||
rotation when an operator dragged a row.
|
||||
|
||||
Keep your cursor, your "already shown in this pass" set, and anything else you need **in your own
|
||||
tables, keyed by scope id**. Signage stores none of it and will never tell you what it thinks your
|
||||
cursor is.
|
||||
|
||||
Two configured source instances of the same source are two independent rotations. That is an
|
||||
operator deliberately asking for one.
|
||||
|
||||
## 4. Repeat policy is yours
|
||||
|
||||
Signage implements none of it:
|
||||
|
||||
| Your module | Your policy |
|
||||
| --- | --- |
|
||||
| Announcements | Cycle the active set forever, wrapping where you choose |
|
||||
| Trivia | Never repeat until every currently available question has appeared |
|
||||
| Scripture | Never repeat until the reading pool is exhausted |
|
||||
|
||||
Signage asks "what is next in this scope?" and shows what you hand back.
|
||||
|
||||
## 5. Three answers, and why they stay three
|
||||
|
||||
| Return | Meaning | What Signage does |
|
||||
| --- | --- | --- |
|
||||
| `null` | Nothing available for this source right now | **Skips the slot immediately.** No error, no slate, no gap. |
|
||||
| `{ withdrawn: true, … }` | This item is explicitly gone | Removes it at the next safe reconciliation boundary, never mid-slide. |
|
||||
| throw / reject / exceed the deadline | A temporary failure | **Keeps the last known good selection and marks it stale.** The slot is not reset and playback does not stop. |
|
||||
|
||||
Collapsing these is the single most damaging mistake a provider can make. An empty question pool that
|
||||
throws becomes an error on a lobby wall; a genuine outage that returns `null` silently rewrites the
|
||||
running order and nobody finds out.
|
||||
|
||||
Every call is bounded by a deadline (an installation setting, 2 seconds by default). A call that
|
||||
never settles is a temporary failure — Signage does not wait, and does not cancel your work.
|
||||
|
||||
## 6. One selection, many audiences
|
||||
|
||||
A channel group has one clock and one or more audience channels (`default`, and optionally
|
||||
`in-person`, `online`, …). Signage asks you **once** per slot occurrence, with every audience key,
|
||||
and you return **one logical item** with a variant per audience:
|
||||
|
||||
```ts
|
||||
return {
|
||||
itemId: "advent-service",
|
||||
itemRevision: "3",
|
||||
variants: [
|
||||
{ audienceKey: null, payload: { headline: "Join us Sunday, 5pm" } },
|
||||
{ audienceKey: "online", payload: { headline: "Streaming Sunday, 5pm" } },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
- The **default variant is required** — `null` or the literal `"default"`.
|
||||
- An audience with no variant of its own renders the default. That fallback is the contract, not a
|
||||
convenience, and a provider with nothing audience-specific simply returns one variant.
|
||||
- Resolving once is what keeps every channel on the same slide with the same deadline. There is no
|
||||
per-channel resolution and no way to ask for one.
|
||||
|
||||
## 7. Invalidation updates a slide without restarting it
|
||||
|
||||
When your content changes — an event is cancelled, a countdown ticks past a threshold, a service time
|
||||
is corrected — tell Signage:
|
||||
|
||||
```ts
|
||||
this.handle.invalidate({ itemId: "advent-service" });
|
||||
```
|
||||
|
||||
Signage decides what to do: it re-resolves the affected selection through `refresh`, and if your
|
||||
`itemRevision` changed it stores the new payloads, bumps the selection's revision, and broadcasts.
|
||||
A mounted React renderer receives new input **in place**, with the slot's authoritative deadline
|
||||
unchanged.
|
||||
|
||||
This is a **notification, not a command**. You cannot skip a slot, extend a deadline, reorder a
|
||||
timeline, or make something appear. That is deliberate: one provider must not be able to take over a
|
||||
channel group.
|
||||
|
||||
## 8. What Signage validates, and rejects
|
||||
|
||||
Everything crossing the boundary, before it is believed:
|
||||
|
||||
- A registration whose id, version, renderer identity, duration, or functions are not the shape above
|
||||
is refused with `SIGNAGE_PROVIDER_REGISTRATION_INVALID` — and a `contractVersion` outside `1.x` with
|
||||
`SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED`, so an operator can see that your module needs an update
|
||||
rather than that it is broken.
|
||||
- A resolution is refused with `SIGNAGE_PROVIDER_RESULT_INVALID` when it has no default variant, a
|
||||
duplicate audience key, more than 16 variants, payloads totalling over 32 KiB, a duration outside
|
||||
1 s … 1 h, a non-absolute `availableUntil`, or a payload that is not genuinely finite JSON — a
|
||||
`Date`, a function, `NaN`, `undefined`, a class instance, or a cycle.
|
||||
- Every rejection carries a stable code and **nothing of your value**: not your payload, not your
|
||||
exception, not your message. Signage will not put your words on a screen or in a log.
|
||||
|
||||
None of this is a sandbox, and it is not described as one. Your module is trusted code in the same
|
||||
process. This is the difference between your bug staying your bug and a malformed value reaching a
|
||||
television.
|
||||
|
||||
## 9. Your renderer
|
||||
|
||||
Your browser contribution is the actual live renderer for your content. Signage mounts it through the
|
||||
platform's contribution ABI and treats it as completely opaque.
|
||||
|
||||
```tsx
|
||||
export default defineModuleContribution({
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
const input = context.input as SignageSlideRendererInput;
|
||||
root.render(<Slide input={input} />);
|
||||
return {
|
||||
update(next) { root.render(<Slide input={next as SignageSlideRendererInput} />); },
|
||||
unmount() { root.unmount(); },
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`mount` is asynchronous by contract. **Resolve it only when you are genuinely ready to be seen** — a
|
||||
font loaded, an image decoded, a layout measured. Signage waits for that promise before it fades your
|
||||
layer in, which is what makes a transition into a live renderer look like a transition into an image.
|
||||
|
||||
### The input, version 1.0.0
|
||||
|
||||
```ts
|
||||
interface SignageSlideRendererInput {
|
||||
contract: "signage.slide-renderer";
|
||||
version: string; // "1.0.0"
|
||||
providerId: string;
|
||||
itemId: string;
|
||||
itemRevision: string;
|
||||
audienceKey: string;
|
||||
payload: unknown; // yours, exactly as you returned it
|
||||
presentation: unknown; // yours, or null
|
||||
timing: {
|
||||
startedAtMs: number; // absolute, server clock
|
||||
deadlineAtMs: number;
|
||||
durationMs: number;
|
||||
paused: boolean;
|
||||
remainingMs: number | null; // when paused
|
||||
};
|
||||
clock: { serverNowMs: number; localNowMs: number };
|
||||
generation: string; // changes whenever anything above changed
|
||||
}
|
||||
```
|
||||
|
||||
**Use the clock anchor, not `Date.now()` alone.** A television's clock is routinely wrong by hours.
|
||||
The authoritative now is:
|
||||
|
||||
```ts
|
||||
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
|
||||
```
|
||||
|
||||
That stays correct however wrong the device's clock is, and it is what makes a countdown on two
|
||||
screens agree.
|
||||
|
||||
### What your renderer must and must not do
|
||||
|
||||
Must:
|
||||
|
||||
- Be ready asynchronously, and update in place through `update` without remounting.
|
||||
- Handle timing changes: paused, resumed, re-anchored after a reconnect.
|
||||
- Unmount deterministically, releasing every timer, listener, and framework root.
|
||||
- Survive repeated mount/unmount without leaking.
|
||||
- Render sensibly for an input shape older or newer than the one you developed against.
|
||||
|
||||
Must not:
|
||||
|
||||
- Reach the network, a database, storage, or a credential. **You are given none of them**, on
|
||||
purpose: mounting your renderer grants your module nothing, and everything you need arrives in
|
||||
`payload` because Signage is the party that was authorized to fetch it.
|
||||
- Assume your module's management UI is mounted, or that any other renderer exists.
|
||||
- Extend the slot. `deadlineAtMs` was decided before you mounted.
|
||||
- Depend on an external internet service. A renderer must work from its cached artifact and its
|
||||
cached payload, because a display's network is the first thing that fails.
|
||||
|
||||
A provider that genuinely needs cloud access needs a **later** contract version. Version 1 will not
|
||||
be weakened to allow it.
|
||||
|
||||
## 10. A checklist before you ship
|
||||
|
||||
- [ ] `providerId` is stable and will never change for this module.
|
||||
- [ ] Cursor and no-repeat state live in your own tables, keyed by `selectionScopeId`.
|
||||
- [ ] `resolveNext` returns `null` for "nothing right now" and throws only for a genuine failure.
|
||||
- [ ] `refresh` returns `{ withdrawn: true }` for content that is genuinely gone.
|
||||
- [ ] Every resolution has a default variant.
|
||||
- [ ] Payloads are finite JSON, well under 32 KiB for all variants together.
|
||||
- [ ] `itemRevision` changes exactly when the payload changes.
|
||||
- [ ] `invalidate` is called when your content changes, and never used to try to control playback.
|
||||
- [ ] Your renderer resolves `mount` only when it is ready, implements `update`, and cleans up.
|
||||
- [ ] Your renderer needs no network.
|
||||
- [ ] `unregister` is called from `stop`, and calling it twice is safe.
|
||||
Reference in New Issue
Block a user