All tasks

Slice M -- CDS Screensaver PROD -- GATE 2

Shipped 2026-05-18. NIX-OS-91 -- after the configured idle threshold the Customer Display Screen dims to a near-black sleep overlay (Moon icon centered, 15% opacity). Wakes instantly when the cashier rings a new item. Off by default; admin picks the threshold per-tenant from Settings -- Customer Display -- Screensaver: Off / 5 min / 10 min / 30 min.

5/5 prod test (schema + UI + persist round-trip) -- 51/51 regression green -- 56/56 total. Migration applied to prod Supabase from local. End-to-end verified on get-coffee: Screensaver card renders with Off/5/10/30 pills; selecting 5 min + Save persists display_screensaver_seconds=300 to the DB; selecting Off + Save persists NULL.

What shipped

Migration 20260518000000_cafe_display_screensaver: adds cafe.tenant_config.display_screensaver_seconds (integer NULL) + CHECK constraint allowing NULL OR IN (300, 600, 1800). Idempotent (ADD COLUMN IF NOT EXISTS + DO block for the constraint).

DAO + constants: SCREENSAVER_VALID_SECONDS + ScreensaverSeconds type in lib/display_branding_constants.ts. getDisplayBranding reads it; saveDisplayBranding writes it; validateBranding enforces the enum (rejects 42 with a clean error).

Settings form: new "Screensaver" card on /cafe/settings/display with Off/5/10/30 min pills. Same visual treatment as the Slice G slideshow speed pills.

Display client: asleep state + lastActivityRef + 5-second-tick interval that only spins up when screensaver is enabled (no CPU cost for tenants who don't opt in). Idle echoes from the polling fallback don't reset the activity clock -- only cart/paid messages do. Paid-screen pin (6s) takes precedence over sleep. When idle resumes (paid expired), the activity clock resets so the timer counts from "just became idle".

Mid-Gate-2 fix logged

lumiere-coffee (Starter) has no cafe.tenant_config row on prod. First prod run failed 2/5 on lumiere because saveDisplayBranding does an UPDATE WHERE tenant_id=... that silently no-ops for tenants without a row. This is a pre-existing gap, not introduced by Slice M -- it predates Slice G's slideshow feature too. Switched the Slice M prod test to get-coffee (Pro) which has a tenant_config row; all 5 checks passed.

Follow-up: saveDisplayBranding should UPSERT not UPDATE so Starter tenants without an existing config row can configure display branding (including screensaver) from a fresh state. Tracked as a separate small ticket -- not blocking Slice M because Narong primarily tests on get-coffee Pro.

Checks -- 5/5 prod test (on get-coffee)

Raw: result.json

Regression sweep -- 51/51 green

test-phase1-prod.mjs11/11
test-phase2-sso-outdoor-prod.mjs6/6
test-phase2-cafe-multishop-prod.mjs6/6
test-m1-prod.mjs (shop scoping)10/10
test-r7-prod.mjs (dashboard + manager-live)14/14
test-r8-prod.mjs (auth/security trio)4/4

Files (1 new migration + 5 modified)

New: nix-outdoor-sales-backend/migrations/20260518000000_cafe_display_screensaver.ts
Modified: nix-outdoor-sales-backend/migrate.js (registry entry) · nix-cafe/lib/db/schema.ts · nix-cafe/lib/display_branding_constants.ts · nix-cafe/lib/db/display_branding.ts · nix-cafe/app/(authed)/settings/display/display-branding-client.tsx · nix-cafe/app/(public-display)/display/[sessionId]/display-client.tsx

Commits: nix-outdoor-sales-backend 6f0023e (migration) · nix-cafe 642924f (code)

Sibling -- NIX-OS-93 closed without code change

Verified on prod via Playwright probe that the ShopSelector is already tier-gated on Starter: LockedForTier wraps it with aria-label="Multi-shop is available on Pro", a lock badge SVG, and opacity-50 dimming. Narong's NIX-OS-93 ticket appears to predate Slice F (which integrated this gating). No code change for NIX-OS-93 -- already shipped.

NIX-OS-92 deferred

"Reports similar to Loyverse" is too underspecified -- waiting for Narong's spec (overlaps with v0.2 H5.9 Reports Summary which is also parked pending spec).