Files
Signage/docs/architecture/decisions/0006-display-rendering-and-offline-continuation.md
2026-08-28 16:41:54 -05:00

112 lines
6.3 KiB
Markdown

# 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.