# PRD — "Living Deck" Gallery
### A portable, open-source-style spec for a gesture-, camera-, and voice-driven card gallery PWA

**Status:** Transfer spec, v1.0 (2026-07-21)
**Audience:** An agentic build system (e.g. Kiro) creating this greenfield on AWS.
**License intent:** Generic/open — contains no secrets, no proprietary content, no personal data. All card content is placeholder; bring your own catalog.

---

## 0. Scope & intent

This document specifies the **mechanism**, not the content: a single-page PWA that presents a deck of "cards" (any catalog — apps, artworks, products, essays) as a physical, living object the visitor browses with **touch, mouse, keyboard, camera hand-gestures, and optionally voice**. Every card can carry generated art, an ambient motion loop, and narration.

Out of scope: any specific catalog content, any fleet/back-office integration, any specific vendor account. Where the reference implementation used a specific provider, this spec names the *capability* and suggests AWS-native equivalents (§19).

**Experience pillars:**
1. **Physical** — the deck obeys one consistent physics model. Every input (finger, wheel, hand-in-air, voice) feeds the *same* spring; nothing ever teleports.
2. **Alive** — the focused card subtly moves (ambient video loop, Ken-Burns art, sheen); everything else is still. At most one thing animates at a time.
3. **Readable without labels** — state is communicated by motion and glow, not text badges.
4. **Progressive** — works fully with touch alone; camera and voice are additive layers that degrade gracefully (CDN hiccup, permission denial, reduced-motion, save-data all have defined fallbacks).
5. **Private by default** — no cookies, no third-party calls, no PII, anonymous count-only analytics.

---

## 1. Content model — `cards.json`

A generated, never-hand-edited JSON array. Each entry:

```jsonc
{
  "slug": "example-card",          // stable id, [a-z0-9-]
  "name": "Example Card",
  "tagline": "One-line description shown on the card",
  "url": "https://example.com",    // empty + locked:true = teaser card
  "cat": "platforms",              // category → accent color
  "superclass": "APPS",            // top-level filter: APPS | WRITING | LABS (rename freely)
  "focus": ["AGENTS"],             // secondary filter chips
  "stage": "beta",                 // idea | experiment | alpha | beta | production
  "stars": 4,                      // curator rating 0-5
  "thumb": "thumbs-art/example-card.jpg",
  "face_video": { "webm": "media/motion/example-card.webm", "mp4": "media/motion/example-card.mp4" },
  "narr": "Spoken narration text (optional; falls back to name + tagline)",
  "search_terms": ["concept", "terms"],
  "aliases": ["alt name"],
  "locked": false
}
```

Rules:
- **Generated artifact discipline:** one idempotent build script (`build-registry`) is the single source of truth. It emits `cards.json` (client), a server-side mirror for any Q&A endpoint, and regenerates dependent assets (QR chips, screensaver feed, search vocab) in lockstep. Byte-identical output for identical inputs. Header comment: "GENERATED — do not edit by hand."
- **Visibility is three-state:** `public` (full card) | `locked` (teaser: present but URL stripped — a locked outbound URL is a leak vector) | `hidden` (absent from the shipped JSON entirely).
- **Curation overlay:** durable overrides (stars, visibility, name/tagline/narration, stage) live in a `curation.json` merged at build time: `defaults < curation.json < pending-edits store`. New slugs default to `hidden` + `review: "pending"` until a curator flips them.
- **Assets attach by convention + hash:** art at `thumbs-art/<slug>.jpg`; motion at `media/motion/<slug>.{webm,mp4}` (auto-attached if present); voice at `voice/site-<slug>.mp3`. Regeneration is keyed by content hash so unchanged decks cost $0 to rebuild.

---

## 2. Visual design system (tokens)

Dark theme only.

```css
:root {
  --bg: #05060a;                 /* near-black */
  --ink: #eef2ff;                /* primary text */
  --muted: #9aa4bf;
  --line: rgba(255,255,255,.12);
  --accent: #7cc7ff;             /* cyan-blue */
  --accent2: #c79bff;            /* violet */
  --glass: rgba(16,20,34,.42);
  --font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Inter, system-ui, sans-serif;
}
```

- **Category accents** (card ring, chips, glow): platforms `#7cc7ff` · showcase `#c79bff` · research `#6ee7c7` · personal `#ffce8a` · writing `#ff9bb0`. Stage chips: idea `#9aa7bd` · experiment `#c79bff` · alpha `#ffce8a` · beta `#7cc7ff` · production `#9be15d`.
- **Glow language:** colored `box-shadow`/`text-shadow` blooms built with `color-mix(in srgb, var(--cat) …)`; gradient category ring on the card frame (`::before`); `mix-blend-mode: screen` foil highlight on the focused card; radial scrims behind overlays.
- **Glassmorphism:** `backdrop-filter: blur(6–14px)` on frames and chips; card background translucency is a user-adjustable variable `--tra` (0..1) so a camera/photo background can read through: card bg alpha = `.20 + .74 * var(--tra)`.
- **Type scale:** card name `font-weight:800; font-size:clamp(26px, 7.6vw, 42px); letter-spacing:-0.5px`. Chips `700 10px` uppercase, letter-spacing ~1.4px. Monospace kickers 9–10px, letter-spacing .2em. Serif (Georgia italic) reserved for photo captions.

---

## 3. The deck — layout & rendering

**Architecture: a vertical magnetic-snap carousel over a circular ring.** Not a 3D coverflow — depth is faked with scale + opacity + z-index (cheaper, calmer). `perspective`/`rotateY` are reserved for the card-flip detail view and screensaver.

- Stage: `position:fixed; inset:0; overflow:hidden; touch-action:none`.
- Card: `position:absolute; left:50%; top:50%; width:min(92vw, 420px)`; base transform `translate3d(-50%,-50%,0)`; `will-change: transform, opacity; backface-visibility: hidden`.
- **Per-frame layout** for card *i* at spring position `pos`:
  - `d = wrapDelta(i - pos)` — shortest signed distance on the ring; `ad = |d|`
  - `y = d * gap` where `gap = min(innerHeight * 0.80, 640)` px
  - `scale = clamp(1 - ad*0.10, 0.82, 1)`
  - `opacity = ad > 1.7 ? 0 : clamp(1 - ad*0.5, 0, 1)`
  - `transform: translate3d(-50%, calc(-50% + {y}px), 0) scale({scale})`
  - `zIndex = 1000 - round(ad*10)`; `pointerEvents = ad > 1.2 ? "none" : "auto"`
- **Circular ring:** the spring position is an *unbounded virtual index*; `mod(n)` maps to a real card; `wrapDelta` returns the nearest signed distance in `(-n/2, n/2]`. Last and first cards are visually adjacent — no hard stop. On spring rest, re-base the virtual index into `[0, n)` so numbers never grow unbounded.
- **No virtualization needed** at deck sizes ≤ ~50: all cards stay in the DOM; distant ones are opacity-0 and pointer-inert. The hot transform is set imperatively per `requestAnimationFrame` — only the `left` property ever uses a CSS transition (`0.34s cubic-bezier(.2,.72,.2,1)`).

---

## 4. Motion physics

One critically-damped spring drives every deck movement; every input funnels into the same two entry points (`spring.x` for direct manipulation, `fling(vel)` for momentum). This unification is the core feel — do not fork it per input.

**Spring** (semi-implicit Euler, 2 substeps/frame, `dt` capped at 0.032s):

```
k = 210   c = 25 (snap-damping tunable: 32; critical ≈ 2√210 ≈ 29)   m = 1
F = -k(x - target) - c·v;   v += (F/m)·dt;   x += v·dt
rest when |x - target| < 0.0008 AND |v| < 0.0025  → snap, fire onrest
```

**Detents ("clicks"):** each substep, if the integer card boundary was crossed (`floor` changed), multiply `v *= (1 - bleed)` with `bleed = 0.15`. A thrown deck ratchets card-by-card, audibly/visibly clicking into detents, and always settles on an integer — never spins free.

**Fling** (shared by touch release, wheel, hand-flick, grab-throw, point-drive release), `vel` in cards/sec:

```
cv   = clamp(vel, -6, 6)
off  = clamp(cv * FLING_GAIN(0.55), -flingCap(2.5), +2.5)   // travel
target = round(x + off);  v = clamp(cv * FLING_VGAIN(1.0), -2.5, 2.5)
engage bleed(0.15); spring.set(target)
```

- **Drag:** direct — `spring.x += Δpx / unit` with `unit = min(innerHeight*0.72, 560)` px/card; integrator paused during drag; velocity tracked as `Δcards/Δt`.
- **Release guard:** carry momentum only if the gesture was held ≥120ms and `|v| > 0.3`; otherwise snap to nearest integer.
- **Wheel:** `spring.x += deltaY/unit * 0.5`; snap to `round` 120ms after the last wheel event.
- **Speed blur:** transient `blur(clamp(|v|*2.2, 0, 6)px)` for 220ms on hard flings, one-shot only.
- **Step (voice/keyboard):** `spring.set(round(target) ± 1)`.
- A soft "whoosh" cue may play on real landings; suppress if boundary crossings are <170ms apart (fast fling = one whoosh, not N).
- **All constants are runtime tunables** persisted to `localStorage` under one namespaced key, live-editable behind a `?tune=1` query flag. This is how the physics gets field-tuned on real devices — build it in from day one.

---

## 5. Card anatomy & states

**Layers (front face, bottom→top):**
1. `.frame` — radius 24px, translucent glass bg (see --tra), 1px hairline border, `box-shadow: 0 18px 50px rgba(0,0,0,.5)`, backdrop-blur 6px, gradient category ring on `::before`.
2. `.art` — hero band `height: clamp(220px, 38vh, 340px)`; fallback chain: category-gradient monogram → static illustration `<img>` (object-fit:cover, top-center) → optional ambient `<video>` → bottom scrim gradient to `rgba(5,6,12,.72)`. Failed images retry 3× cache-busted, then background-retry at 5s/12s/24s/45s.
3. `.sheen` — one-shot diagonal highlight sweep on focus (`0.72s` keyframe).
4. `.foil` — focus tint, `mix-blend-mode: screen`, opacity .6 when focused.
5. Corner chips: collect/save (top-left), "ⓘ Details" (top-right), stage chip, QR chip (bottom-right, 78×78 SVG with white quiet zone, `image-rendering: pixelated`), lock chip when teaser.
6. `.body` (overlaps art by −82px): category chip + curator stars → name → 2-line-clamped tagline → 3-up stat block (Class / Rank in deck / Status) → visitor 5-star rating row (interactive only on focused card) → domain link or "request access" for locked cards.

**Focus** = being the centered ring-distance-0 card. No separate zoom mode. On settle: sheen sweep, foil on, Ken-Burns starts on art, motion video plays, chips fade in, optional narration after ~320ms dwell.

**Launch** (double-tap focused card / gesture / voice): `0.5s cubic-bezier(.2,.8,.2,1)` → `scale(1.28), opacity 0, brightness(1.8)`; navigate at 430ms. Locked cards route to a request-access flow instead.

**X-ray / detail** (long-press 500ms, "ⓘ", V-sign gesture, or key `X`): 3D card flip — back face starts `perspective(1200px) rotateY(90deg)` → `rotateY(0)` in 0.34s; front fades out in 0.18s; frame grows to `min-height:min(88vh, 780px)` while edge-on (growth hidden by the flip). Back face is a text-first spec panel with accordion sections (default-open in landscape, collapsed in portrait).

---

## 6. Living motion — ambient video loops

The signature feature: each card's static illustration gets a **hand-fitted subtle motion loop** (the tree sways, the wheel turns, the aurora drifts) that plays only when the card is focused.

**Client playback:**
- `<video class="face-video" muted loop playsinline preload="none" poster={static art}>` — **no `src` at build time**.
- Gate: `FACE_VIDEO_OK = !prefers-reduced-motion && !navigator.connection.saveData`.
- Two-pass sync on every focus change:
  1. *Immediate cheap pass:* pause any non-focused playing video (invariant: ≤1 playing; a fast sweep through N cards triggers **zero** loads).
  2. *Debounced settle pass (180ms):* for cards at ring-distance ≤1, attach webm+mp4 `<source>`s and `load()` (once, flagged); focused card `play()`, neighbor stays loaded-paused.
- Crossfade: video `opacity: 0 → (.62 + .38*var(--tra))` over 0.5s on the `playing` event; simultaneously fade the static poster to 0 (single-surface — prevents double-exposure ghosting). On `error`/`pause`, fade back to the poster. Reduced-motion: `display:none`.

**Generation pipeline** (offline build step, any image-to-video model):
- Input: the card's art JPG. Output target: ~2–2.6s seamless loop, 540×720, 24fps, dual-encoded **WebM VP9** (`-crf 37`, ~200–400KB) + **MP4 H.264** (`-crf 26 -preset slow -movflags +faststart`), yuv420p, no audio.
- Per-card **curated motion prompt** describing the gentlest motion native to that specific illustration; global negative prompt bans new objects, text, morphing, zoom, pan, jitter. Camera locked. Uncurated cards fall back to a vision-model auto-prompt ("gentlest possible ambient motion, ≤60 words, preserve exact art style").
- Loop strategy per card: `palindrome` (forward+reversed concat) for symmetric motion, `crossfade` (0.4s xfade of tail over head) for directional motion.
- Post: crop the model's output to the art's content box, scale (lanczos), trim to the loop window.
- **Cache by hash:** state file keyed by `sha256(art bytes + PROMPT_VERSION)[:16]` — a card regenerates only when its art or the prompt version changes; an unchanged deck is a $0 no-op. Reference cost: ~$0.90/card at $0.15/s for 6s of 720p generation.
- QA artifacts per card: a 6-frame horizontal strip PNG + a seam pair (last|first frame) for human loop review. Per-card failures are non-fatal — the card keeps its static face; publish is never blocked.
- Protect list: any card whose art is a real person / typographic logo can be pinned STATIC_ONLY or given a curated video that generation never overwrites.

---

## 7. Card art pipeline

Offline build step, any image-generation model:
- **One style for the whole deck:** "bold minimal editorial illustration, deep near-black background, one strong central concept, {category-accent} accent glow, dramatic high-contrast lighting, flat vector poster aesthetic, generous negative space, centered composition" + hard NO-TEXT / no-logo negative clause.
- One curated concept line per card; accent color keyed to category.
- Output `thumbs-art/<slug>.jpg` ≤150KB: step down through `[(720w,q72),(720,q60),(640,q55),(560,q50),(480,q45)]` until under budget.
- Idempotent (skip existing unless `--force`); protected slugs never regenerate; fail-soft per card.

---

## 8. Camera hand-gesture system

**Stack:** MediaPipe Tasks Vision `HandLandmarker` (float16 model), `numHands: 1`, `runningMode: "VIDEO"`, driven by a `requestAnimationFrame` loop (~60fps) on the front camera (`getUserMedia({video:{facingMode:"user"}})`, browser-default resolution). Loaded **lazily** from CDN so a CDN hiccup never blanks the site; load failure shows a friendly error and disables the layer. Optional skeleton overlay: mirrored (x → 1−x), 21-point topology, 3px bones / 4px joints scaled by devicePixelRatio.

**Permission UX:** never prompt on page load. First-run "Let's go →" tap (the required user-activation) requests camera and auto-enables gestures. Returning visitors: silently resume only if `navigator.permissions.query({name:"camera"})` is already `granted` and the user hasn't explicitly turned gestures off (an explicit off persists and beats the default-on).

**Pose classifier** (pure function over the 21 landmarks; evaluated in priority order fist → pinch → palm → vee → point → none). A finger is "extended" iff `dist(wrist, tip) > dist(wrist, pip)` — orientation-independent, so point-down works. Scale-invariance via `handScale = dist(knuckle5, knuckle17)`.

| Pose/gesture | Detection | Action |
|---|---|---|
| **Point up/down (held)** | index extended, ≤1 stray other finger; up/down from tip-vs-knuckle y | continuous glide at `driveGain = 2.5` cards/s |
| **Point (release)** | held ≥120ms and \|v\| > 0.3 | feeds the fling; else snap to detent |
| **Vertical flick** | centroid-Y velocity over a 140ms window ≥ `0.7` heights/s, firing on the release edge (speed falls to 55% of peak); 300ms cooldown | fling `min(6, peak*1.5)` cards |
| **Horizontal hand-swipe** | centroid-X flick ≥ `0.45` widths/s, 350ms cooldown, only when horizontal peak beats vertical (axis arbitration ×1.0) | left/right panel navigation |
| **Fist + move (grab & throw)** | see disambiguation below | direct drag at `grabDragGain = 3.0`; release ≥0.6 cards/s = throw into the same fling path |
| **Double fist-pump** | two down-up pumps, displacement ≥ `0.11`, within 1800ms window; second pump may be 50% shallower (armed-amplitude factor); dropout grace 450/650ms | launch the focused card (1200ms global cooldown) |
| **Pinch** | thumbtip–indextip < 0.06 with ≥1 finger extended | snap-focus a card (480ms cooldown) |
| **Open palm (hold 500ms)** | ≥4 fingers extended | pause the stream |
| **V-sign (hold 600ms)** | index+middle extended, others curled, tip spread > 0.6×handScale; latched once per hold | toggle x-ray on focused card |
| **Shake (device motion)** | ≥3 direction reversals of >16 m/s² deltas within 600ms; 3s refractory; iOS permission piggybacked on first tap | jump to the "about the owner" card |
| **Three-finger tap (touch)** | ≥3 touches, <450ms, <24px movement, all lifted | fullscreen screensaver |

**Grab-vs-pump disambiguation (the "motion signature" — critical):** a vertical fist move is ambiguous between drag-start and pump-start. Resolution:
- On fist, the grab detector goes **pending for 260ms** (deferral window; the deck does *not* move — 260ms is imperceptible for a drag and avoids snap-back).
- During deferral, track peak |Δy|. If displacement **reverses back >45% of its peak** (a pump's round-trip signature), mark the hold *pumplike* — grab never engages this hold.
- If 260ms elapse with ≥0.05 displacement and no reversal → **commit to grab**, re-anchoring origin at the current position (no jump). A committed grab stops feeding the pump detector; an engaged grab short-circuits before pump logic runs. Cross-signal: a detected pump forces pending→pumplike.
- Fist-loss grace 220ms; release velocity computed over the last 120ms.

**False-positive guards:** flings/swipes only fire from point/none poses (`shouldFling` returns null for palm/pinch/fist); fist frames clear flick sample buffers so pump recoil can't leak a fling; a hand reappearing elsewhere can't fake a swipe (samples cleared on dropout); per-gesture cooldowns throughout; V-sign latches one toggle per hold.

**Engineering pattern — shared detector module (non-negotiable):** all gesture logic lives in one framework-free ES module (pure functions + detector classes, no DOM, no camera) imported by BOTH the app and the Node test suite. Never fork the logic.

**Gesture replay test harness:**
- Fixture factories build synthetic 21-point frames per pose (`makeFist(y)`, `makePalm()`, `makePoint(y,dir,sloppy)`, …) hand-crafted so the classifier yields the intended pose — including "sloppy" variants reproducing real-device failures (loose pinky during point).
- A trajectory is a list of centroid values played at 33ms/frame (~30fps); a literal `null` frame injects landmark dropout (simulating motion-blur tracking loss).
- A DOM-free rig mirrors the app's exact detector routing order and records an event log (`grab-engage`, `pump`, `LAUNCH`, `hswipe`, `vfling`, …); tests assert on the event stream.
- **Control/regression pairs:** every field-tuned threshold ships with a fixture proving the natural motion passes on the new value AND fails on the old one — proving the retune. Fixtures are permanent regressions: run after ANY threshold change.

---

## 9. Touch, wheel, keyboard parity

Touch and camera **share the same action functions** (same fling, same panel-swipe handlers) — they cannot diverge.

- **Drag:** axis-locks after 10px (`x` if dx > dy×1.05); vertical drives the spring; horizontal ≥55px opens side panels. Exit-swipes from panels need |dx| ≥ 50 and |dx| > |dy| so buttons stay tappable.
- **Long-press 500ms** (≤10px slop) on focused card → x-ray; swallow the following synthetic click (450ms).
- **Two-finger touch** cancels drag/long-press (reserved).
- **Keyboard:** Space = mic toggle · Q/M = quiet-mode toggle (press again restores prior mode) · X = x-ray · S = screensaver (with real fullscreen, key counts as user gesture) · Escape = close overlays. All bindings input-focus-guarded, no auto-repeat. (Deliberately no arrow-key deck stepping in the reference; add if desired.)

**Onboarding:** compact first-run card with 3 animated glyphs (👆 point to browse · ✊ grab & throw · 📱 shake) + a "Let's go →" CTA that doubles as the permission user-gesture; a "?" button opens the full scrolling gesture legend. Seen-once flags persisted. Transient status flashes (900ms) and a pump-count dot indicator give per-gesture feedback.

---

## 10. Voice architecture (optional module, three layers)

> The deck is fully usable without this module. Each layer degrades independently.

**Layer A — pre-rendered narration clips.** One MP3 per card (`voice/site-<slug>.mp3`), played on focus dwell; falls back to browser `speechSynthesis` on the narration text if missing. Generation is an offline script with **four mandatory gates**: (1) pronunciation lexicon applied to TTS input only (captions keep written form); (2) canonical voice settings from one settings file; (3) **read-back QA** — transcribe the render with a local STT model and require normalized token overlap ≥0.62 vs the script, else keep the old clip and don't advance state; 3 consecutive fails on the same text-hash pauses that clip; (4) re-render only when the spoken text's SHA-256 changes. Spoken text = `narr` else `"{name}. {tagline}"` — the *same string* the client captions and echo-guards.

**Layer B — reply TTS with instant local fallback.** Replies race a server TTS fetch (GET, so CDN-cacheable by text-hash key) against a hard **1200ms budget without aborting**: a cold render misses → speak instantly via local `speechSynthesis`, while the fetch completes in background and warms (i) the CDN edge and (ii) an in-session LRU cache (40 entries). The same phrase upgrades to the server voice next time (<100ms cached). Circuit breaker: 3 consecutive server *failures* (timeouts don't count) → local-only for the session. Two-channel text: display keeps written form; spoken form goes through the pronunciation lexicon at egress.

**Layer C — realtime duplex conversation (WebRTC).** For a Gemini-Live-feel conversation with native barge-in:
- Server endpoint mints a **short-lived ephemeral client token** (never the real API key) and returns `{value, expiresAt, model, voice, sdpUrl, prices, sessionMaxMs}`.
- Client: `getUserMedia` with `echoCancellation/noiseSuppression/autoGainControl` → `RTCPeerConnection` + data channel → SDP offer to provider with the ephemeral token. Remote audio plays through one hidden autoplay `<audio>`.
- Server-side VAD (threshold 0.55, prefix 300ms, silence 700ms) with `interrupt_response` — talking over the guide cancels its speech at the model level. That is what makes interruption "just work."
- **Tool calls drive the UI (visual-only; the model speaks its own acknowledgment):** `filter_cards({class|focus|query})`, `open_card({slug})`, `show_details({slug})`, `next_card`, `prev_card`, `add_to_stack({slug})`. Slug enums come from the public catalog; unknown slug returns `{ok:false}`.
- Grounding: instructions contain ONLY a compact public catalog (name — tagline (class · stage)); short replies; never reveal internals.
- Any failure (mint 4xx/5xx, mic denial, SDP error) → seamless fallback to Layer B + browser speech recognition, labeled subtly.

**Audio egress chokepoint (non-negotiable if any voice ships):** exactly ONE function emits sound; the only two emitters (`speakLocal`, `playClip`) route through it. It stamps an **echo guard** — each spoken phrase is time-boxed for 4.5s (list capped at 8) and any incoming ASR transcript matching a recently spoken phrase is dropped as self-echo — stamped *before* async fetches (covers pre-playback gap) and re-stamped on clip end (covers late-finalized stragglers). A central `stopAllSpeech()` kill switch cancels synth, detaches audio handlers *before* clearing `src` (else the error handler re-speaks the killed reply), and bumps a turn-generation counter so stale in-flight TTS responses self-drop on arrival.

**Mute model (tri-state, one control):** `duplex` (mic+voice) → `speaker` (voice only) → `quiet` (chips-only captions, no audio). Tap cycles; **long-press jumps straight to duplex** ("I want to talk" = one action); instant shut-up (`stop`/barge) kills current+queued audio without changing mode; quiet persists across visits with legacy-key migration.

**Cost guards (public site = strictest posture, all layers):**
- Mint endpoint: strict origin allow-list (403 on missing/foreign), ~6 sessions/hr/IP, **daily budget via reserve-counting** (reserve a per-session estimate at mint, e.g. $0.25 against a $15/day cap → 503 "voice is resting" + fallback; actual spend logged separately — never mixed, or it double-counts), 5-minute hard session cap enforced both in session config and a client timer, with a warm spoken wind-down and a no-reopen latch.
- Client: session opens only after the free pre-rendered intro, only in duplex; auto-close on 18s no-speech and 45s conversation idle; live cost meter driven by the price table the server echoed (one source of truth).
- Reply TTS: ~40/hr/IP, 300-char cap, immutable CDN caching, per-call cost log. Q&A endpoint (if any): tiny token caps, ~10/hr/IP.

---

## 11. Screensaver

- **Arm:** 1s tick; enter when idle ≥ `idleSecs (15s)` — "idle" is held-off while speaking, listening, gesturing, or playing media. Desktop/tablet only (`innerWidth ≥ 768 || !pointer:coarse`); manual triggers (S key, three-finger tap) bypass the gate and request real fullscreen.
- **Content:** full-screen Ken-Burns slideshow; two layers crossfading (1.6s ease); Ken-Burns scale 1.0→1.095 + translate(−1.6%, −1.2%) over 9s ease-out; advance every 9s; portrait images letterbox `contain` without Ken-Burns; radial scrim + wordmark + optional place/date/caption meta.
- **Feed:** lazy-fetched on first idle (`requestIdleCallback`, never during boot) from a same-origin JSON array `{url, title?, date?, place?, caption?, w?, h?}`; missing/empty → built-in fallback image set; if the feed lands mid-session it hot-swaps in place. Next frame pre-decoded (`decoding=async`) to avoid pop-in. Shuffle per invocation.
- **Exit:** any input event dismisses immediately; native fullscreen-exit (Esc) also dismisses via `fullscreenchange`.

---

## 12. PWA & service worker

- Version string per release (`app-vNN`); caches named `shell-<V>` / `runtime-<V>`; bump on ANY shell change; `activate` deletes all caches not matching current version; `install` → `skipWaiting()`; `activate` → `clients.claim()`.
- Strategy: cross-origin (CDN/wasm) pass-through (never intercepted — and `getUserMedia` isn't a fetch, so camera/mic are untouched) · navigations + catalog JSON + API → **network-first** (a bad cache can never stick) · thumbs/icons/js/static → **cache-first** · `/media/*` video → **network-only** (let the browser do Range/206 natively). Non-GET untouched.
- Never ship a service worker on a domain casually: removing one later requires a deliberate self-destructing `/sw.js`.

---

## 13. Analytics (privacy-first, optional)

- First-party only. No cookies, no third parties, no fingerprinting, no PII, no IP or User-Agent stored (IP only lives in an in-memory rate bucket). Anonymous session id in `sessionStorage`. Public footer disclosure: "Anonymous visit counts only."
- Client: event queue batched via `sendBeacon` (flush at 1200ms or 20 events; click-throughs flush synchronously before navigation), flush on `visibilitychange`/`pagehide`, fail-silent, headless opt-out flag.
- **Event allow-list (server drops everything else):** `pageview {referrer origin+path, utm, device-class}`, `card_view {slug}` (1.5s dwell), `click_through {slug}`, `filter_use {chip}`, `rating_given {slug}`, `voice_query` (count only — never transcript), `voice_session {sec, usd, turns, end-reason}`.
- Server: origin-locked, ~240/hr/IP, batch cap 40, server-side sanitize + server timestamp, strict slug validation, numeric caps; persist JSONL batches to object storage partitioned by day; always respond 204. Aggregation only behind a gated admin endpoint rendering inline SVG charts.

---

## 14. Admin / curation (optional pattern)

A key-gated mobile cockpit: per-card stars (0–5), three-state visibility, superclass/focus/stage, name/tagline/narration overrides (narration edit queues a voice re-render), art/motion regen requests, and a pending-review queue for new cards. Writes land as per-slug patch files in object storage; the next registry build merges them into `curation.json`, regenerates artifacts, and deletes consumed patches — **edits apply next deploy**, and the cockpit shows "pending" until then. Visitor ratings: one-per-device localStorage guard + server rate limit; aggregates admin-only.

---

## 15. Quality gates

**Predeploy check (blocks deploy on exit ≠ 0):**
- Every catalog entry has a *decodable* art JPG (bytes > 0 AND an image tool reads pixel dims — not just file-exists).
- Admin key file and every curation/backup/snapshot file matches a deploy-ignore pattern (closes the "backup file shipped to prod" hole).
- Hidden slugs are ABSENT from the shipped JSON; locked slugs are present but URL-stripped.
- Grep shipped JSON for internal codenames (maintain a ban list) and secret-shaped strings (`sk-`, `api_key`, `bearer`, 24+ char random tokens).
- Motion pipeline internals (render state, QA strips, logs — they carry filesystem paths and prompts) excluded from deploy; the public loop videos included.

**Gesture regression suite:** the replay harness (§8) runs on every threshold change; control/regression pairs prove retunes.

**Perf budget:** 60fps deck sweep with zero video loads mid-sweep; ≤1 video playing ever; one-shot-only effects; `prefers-reduced-motion` and Save-Data honored everywhere (kill Ken-Burns, hide videos, opacity-only flips, near-zero animation durations).

**Responsive floor:** usable at 280px width (foldable outer screens): drop wordmark text, shrink/hide QR chip, tighten toolbars via `min-width:0`.

---

## 16. Acceptance criteria (condensed)

1. Deck at 60fps on a mid-range phone; fling ratchets through detents and always settles centered; ring wraps seamlessly.
2. Touch-only visitors get the complete experience; camera and voice are opt-in layers with graceful denial paths; no permission prompt ever fires on load.
3. All 12 gestures in §8 work per spec; grab-vs-pump fixtures pass; a sustained drag never launches; a fast double-pump never drags.
4. Focused card: sheen + foil + Ken-Burns + motion loop, with single-surface crossfade (no double exposure); non-focused cards fully still.
5. Voice (if enabled): interruption works mid-sentence (barge-in), no self-echo in any of the guarded regression scenarios, hard 5-min session cap, daily budget cap returns graceful fallback.
6. Predeploy gate blocks: missing art, leaked backup file, hidden-slug leak, locked-URL leak, codename/secret in shipped JSON.
7. Lighthouse-style checks: PWA installable, reduced-motion clean, no third-party requests, footer privacy disclosure present.

---

## 17. Build phases

1. **Deck core** — catalog loader, ring layout, spring/detent physics, touch/wheel/keyboard, card anatomy, focus states, x-ray flip, launch. (Everything else layers on this.)
2. **Design polish** — tokens, glass/glow/foil/sheen, translucency slider, responsive floor, reduced-motion.
3. **Art & motion pipelines** — art generation + optimizer; motion loop generation with hash cache; client lazy video system.
4. **Gestures** — shared detector module + replay harness FIRST, then camera wiring, onboarding, tunables panel.
5. **Screensaver + PWA** — idle system, SW with the exact caching strategy.
6. **Voice layers A→B→C** — narration clips, cached TTS fallback race, realtime WebRTC with tool calls; egress chokepoint before ANY layer ships.
7. **Analytics + admin + predeploy gates.**

---

## 18. AWS reference mapping (for the build agent)

| Capability | AWS approach |
|---|---|
| Static hosting + CDN | S3 + CloudFront (immutable cache headers per §12) |
| Serverless endpoints (tts, mint, hits, rate, answer) | Lambda + Function URLs or API Gateway (keep them tiny + stateless; in-memory rate buckets per warm instance are acceptable) |
| Object storage (analytics JSONL, curation patches, budget counters) | S3 (day-partitioned keys); budget counter = small JSON object, reserve-based |
| Realtime speech-to-speech w/ barge-in + tools | Amazon Nova Sonic (bidirectional streaming) — or any realtime speech API that offers ephemeral client credentials, server-side VAD, interrupt-on-speech, and tool calls; the *pattern* is what matters |
| Reply TTS | Amazon Polly generative voices; keep the 1200ms race + CDN cache + local fallback |
| STT for read-back QA | Amazon Transcribe (batch) or a local open-source STT model in the build pipeline |
| Card art generation | Amazon Nova Canvas or any image model — the *style contract* (§7) is what matters |
| Image-to-video motion loops | Amazon Nova Reel or any image-to-video model; keep palindrome/crossfade looping + hash cache |
| Hand tracking | MediaPipe Tasks Vision — client-side open source; host the wasm+model on your own CDN to remove the third-party CDN dependency |
| Q&A grounding (optional) | Bedrock fast/small model, hard token caps (~80 output tokens) |

Secrets posture: all provider keys live server-side only (Lambda env via Secrets Manager); clients only ever see ephemeral session tokens; every spend endpoint is origin-locked + rate-limited + budget-capped.

---

## Appendix A — Tunables (single localStorage-persisted object, editable via `?tune=1`)

```
flickVel 0.7      flickCd 300      pointCd 320      pinchCd 480
palmHold 500      pumpDisp 0.11    flingCap 2.5     detentBleed 0.15
snapDamping 32    gestureInvert f  swipeVelH 0.45   swipeArb 1.0
grabDragGain 3.0  grabCommitMs 260 armedWindow 1800 armedAmp 0.5
pumpGrace 450     armedGrace 650   idleSecs 15      driveGain 2.5
veeDwell 600      longPressMs 500  threeTapMs 450
spring k 210      spring c 25      substeps 2       dtCap 0.032
FLING_GAIN 0.55   FLING_VGAIN 1.0  gap min(0.80vh,640)  unit min(0.72vh,560)
scaleFalloff 0.10 scaleFloor 0.82  opacityCutoff 1.7    releaseHold 120ms
releaseVelMin 0.3 whooshSuppress 170ms  dwellNarrate 320ms  videoSettle 180ms
echoTTL 4500      ttsRace 1200     ttsCacheLRU 40   rtNoSpeech 18000
rtIdle 45000      rtHardCap 300000 ssAdvance 9000   ssKenBurns 9s→1.095
```

*End of PRD. Everything above is mechanism; no card content, keys, hostnames, or personal data are part of this spec.*
