H3.2 cross-device transport + H3.3 slideshow on the idle screen. Customer Display today only works on a second tab of the same iPad (BroadcastChannel = same-origin only). Slice G adds a server-state fallback so the customer can scan a QR code and watch the cart build on their own phone.
tsc --noEmit clean. Migration applied
to local Docker postgres + DAO upsert/get/delete + validateBranding accepts/rejects all probed
end-to-end. New cafe.display_states table, three slideshow columns on
cafe.tenant_config with a CHECK pinning speed ∈ {3,5,7}, one new shared modal
component, three POS toolbars rewired, and the qrcode npm dep added (~10KB).
Cashier still publishes to BroadcastChannel (instant, same-device) AND now
mirrors the same payload to POST /cafe/api/display/<sessionId>/state
(debounced 250ms; the paid event bypasses the debounce + uses
fetch keepalive so it survives a tab close). Display subscribes to BC AND polls
GET /cafe/api/display/<sessionId>/state?secret=… every 1500ms. Last-write-
wins reconciliation via updated_at. Same-device cashiers keep the snappy UX they
have today; QR/remote devices get ≤2s lag.
The route bypasses Cafe's middleware (alongside /display/ + /api/kitchen/);
both POST and GET gate solely on ?secret matching cafe.sessions.display_secret
— bad/missing → 404 (don't leak which session ids exist).
Replaced the immediate window.open(displayUrl) in three POS toolbars
(Pro lockable, Starter lockable, Starter in-app) with a shared
<DisplayModeDialog> with two CTAs:
window.open(url, "nix-customer-display").
Best for a second screen behind the counter.Three new columns on cafe.tenant_config:
display_slideshow_images (jsonb array of URLs, soft cap 10),
display_slideshow_enabled (bool, default false), and
display_slideshow_speed_seconds (int, default 5, CHECK constraint pinned to
{3,5,7}). The existing single promo_image_url stays — toggle picks which
renders on the display's idle screen. Cross-fade transition between images via CSS opacity
+ a paired second that pre-loads the next URL.
Settings form gets a Slideshow card with: enable toggle, speed pills (3s/5s/7s),
multi-upload (re-uses the existing UploadButton), a per-image list with remove,
and live preview that mirrors the toggle.
Validation refuses to enable the slideshow with zero images + rejects speed values outside
the enum + rejects URLs that aren't http(s):// + caps at 10 images. All probed
in the DAO test below.
The cafe.display_states table has ON DELETE CASCADE on the FK to
cafe.sessions, which catches hard-deletes, but sessions are only ever
soft-closed (state='closed'). To keep the table from accumulating stale
payloads, closeShiftAction now explicitly DELETEs the row.
Best-effort: a failure here doesn't surface as a close failure (logged, swallowed).
Schema probe: cafe.display_states (3 columns, FK confdeltype=c
for CASCADE, PK on session_id) + cafe.tenant_config 3 slideshow columns + the
cafe_tenant_config_slideshow_speed_check constraint all confirmed.
DAO write probe: upsert(idle) → get returns idle; upsert(cart) → get returns cart with the right line count + subtotal; delete → get returns null. Round-trip through the jsonb column preserves the nested shape.
validateBranding probe: accepts {2 https URLs, enabled, speed=3}; rejects
enable-with-no-images; rejects speed=4 (not in enum); rejects 11-image array; rejects
javascript: URL.
Raw artefacts: 01-schema.txt · 02-dao-probe.json · result.json
Polling load. Per open session = 1 read every 1.5s = 0.67 reads/s/session.
NIX scale (≤ a handful of open displays simultaneously) = trivial. Reads wrapped in
db.transaction() to bypass Hyperdrive on the hot poll path.
Stale display_states rows. closeShiftAction handles normal-close. Manual interrupts / aborted closes might leak rows. Acceptable until a future cron sweep.
QR rendering on Cloudflare Workers. The qrcode lib runs
client-side only (in the modal); should work fine on any modern browser. Local tsx
probe verifies the import resolves.
cafeDisplayStates table + slideshow cols/api/display/*DisplayModeDialog with both CTA testids + qrcode importdeleteDisplayState
1 migration:
nix-outdoor-sales-backend/migrations/20260515160000_cafe_display_states_and_slideshow.ts
nix-outdoor-sales-backend/migrate.js (registry entry)
nix-cafe — 3 new + 11 modified + 1 new dep:
New:
lib/db/display_states.ts ·
app/api/display/[sessionId]/state/route.ts ·
app/(authed)/pos/_components/display-mode-dialog.tsx
Modified:
lib/db/schema.ts · lib/db/display_branding.ts ·
lib/actions/display_branding.ts · lib/actions/register.ts ·
middleware.ts
app/(public-display)/display/[sessionId]/page.tsx ·
display-client.tsx
app/(authed)/settings/display/display-branding-client.tsx
app/(authed)/pos/_components/register-shell.tsx ·
starter-top-bar.tsx · starter-lockable-shell.tsx
app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx
Dep: qrcode + @types/qrcode