Build the desktop management surface at / while leaving the verified
playback engine and display renderer untouched. Opening the dashboard goes
straight to the playlist that is actually active, with slide imagery
dominating rather than an admin table.
Playlist selection and activation are deliberately separate. Choosing a
playlist only changes what is being edited; an inactive playlist offers an
explicit Activate Playlist action. Activation remains the server's
decision: when playback queues the request to the current slide boundary
the header shows STARTING SOON and keeps the old playlist marked active
until authoritative state promotes it, so the UI never fakes activation
timing.
Entries render as large tiles in a responsive grid where position is the
sequence, so no ordinal numbers are stamped on slides. Names drop file
extensions and are uppercased in Roboto for presentation only; stored
display names keep their case. Status rings are green, gray, amber, or red
and are always accompanied by the same status as text, so nothing depends
on colour alone. Clicking a tile toggles that playlist entry only, never
the library asset or its use in other playlists. Tiles reorder by drag
through dnd-kit, persisting through the existing playlist API and snapping
back to the authoritative order if the server rejects the change. React
identity is always the stable PlaylistEntry UUID, never an index.
Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on
the page, run sequentially so appended order matches selection order, and
report per-file state without blocking the dashboard. Content duplicates
and assets already in the playlist are surfaced rather than duplicated.
The Library panel browses and reuses existing assets, and permanent assets
are never shown as stale.
The live preview reuses the display's own presentation resolver and scene
stylesheet, which are extracted to a shared module so the miniature and
the signage output cannot drift apart. It takes on none of the kiosk's
authority: no worker, no offline rotation, no heartbeat, no identity.
Management is desktop-only below 768px, a restriction /display never
inherits.
Compose now attaches only the web service to the external tabernet network
as announcements-web, keeping the API and object storage private.
Verified with 200/200 backend, 20 client unit, and 44 Playwright tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
Build the real /display Chromium signage client as a React route on the
existing single-page build. The server remains authoritative; the display
only consumes the verified Phase 3B snapshot and reports telemetry.
The renderer owns two permanent scene layers that are never recreated. A
transition prepares the hidden layer, decodes the incoming image on the
element that will actually be composited, then raises that layer above the
current one at opacity 0 and fades it in while the outgoing layer stays
fully opaque underneath. This removes the v1 defect where two simultaneous
fades let the black root through the composite. Every asynchronous load
and transition carries a generation token, so work finishing after
authoritative state moved on is discarded rather than shown.
A true 16:9 slide fills the frame with no backdrop or shadow; anything
else is contained, centred, and uncropped over a blurred, solid, or black
background with a subtle shadow. Blur uses the global setting and is
overscanned; boolean darkening applies to the background only. Static
targets use the same renderer with the snapshot's global presentation
defaults, and mode off is a topmost gate while the renderer keeps
tracking underneath.
Slide bytes live in an explicit Cache Storage cache with reference
counted object URLs and conservative pruning, never in ordinary HTTP
caching. A new manifest is staged before promotion, so one unfetchable
asset marks only itself unavailable instead of rejecting a playlist. A
small IndexedDB store with explicit version handling holds the persistent
client ID, last known good manifest, timing anchor, and last structured
error, and discards malformed records on read. A narrow service worker
caches only the /display app shell, network-first for navigations, and
explicitly bypasses media, API, and socket traffic.
The display sends a real display:heartbeat every 12 seconds with its
actual state, and reports structured errors as bounded JSON that the
server's existing string validation already accepts; /api/displays parses
them back into an object, so no migration was required.
Verified against an isolated stack through Nginx with 20 Playwright
Chromium tests plus 9 client unit tests, and 187/187 backend tests.
Co-Authored-By: Claude Opus 5 <[email protected]>