@hadoku/watchparty-ui 0.45.17 → 0.45.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"room-chrome.js","sources":["../../../packages/shared/dist/chatImages.js","../../../packages/shared/dist/defaults.js","../../../packages/shared/dist/tierlist.js","../../../packages/shared/dist/protocol.js","../../../packages/shared/dist/characters.js","../../../packages/shared/dist/playback.js","../../../packages/shared/dist/fragmentDeliveryFlag.js","../../../packages/shared/dist/submissions.js","../../../packages/shared/dist/gameCatalog.js","../../../packages/shared/dist/mediaSearch.js","../src/lib/chatMedia/config.ts","../src/lib/chatMedia/upload.ts","../src/lib/chatMedia/previewPlacement.ts","../src/lib/chatMedia/react/MemePicker.tsx","../src/lib/config.ts","../src/lib/portalTarget.ts","../src/lib/storageKeys.ts","../src/lib/browserStorage.ts","../src/lib/debugGlobals.ts","../src/lib/debug.ts","../src/lib/auth.ts","../src/lib/characters.ts","../src/prefs/watchpartyPrefs.ts","../src/lib/preferences.ts","../src/roomChrome/chatLayout.ts","../src/lib/store.ts","../src/lib/mediaUrls.ts","../src/lib/tierlistApi.ts","../src/lib/spriteRetry.ts","../src/lib/avatar.ts","../src/lib/embedContext.ts","../src/lib/fragmentDelivery.ts","../src/lib/deviceId.ts","../src/lib/playlistVotes.ts","../src/lib/connectionLiveness.ts","../src/lib/socket.ts","../src/hooks/useAvatarChanges.ts","../src/lib/pictureColor.ts","../src/lib/avatarPictures.ts","../src/components/AvatarImage.tsx","../src/lib/chatMedia/imageClickIntent.ts","../src/components/ChatImageLightbox.tsx","../src/components/ChatImageAttachments.tsx","../src/lib/chatOwnership.ts","../src/components/ChatMessage.tsx","../src/components/ChatLog.tsx","../src/lib/slashCommands.ts","../src/components/SlashCommandPicker.tsx","../src/components/ChatAttachmentStrip.tsx","../src/hooks/useChatImageAttachments.ts","../src/components/ChatInput.tsx","../src/components/Roster.tsx","../src/components/RosterButton.tsx","../src/components/Toasts.tsx","../src/roomChrome/hostTheme.ts","../src/roomChrome/observeWidth.ts","../src/roomChrome/hostIdentity.ts","../src/components/controls/Icons.tsx","../src/roomChrome/RoomChrome.tsx"],"sourcesContent":["/**\n * Chat image attachments — the WIRE contract.\n *\n * Transport lives in `@wolffm/cdn-client` (upload, `urlFor`, catalogue search,\n * the accepted-type and size limits it parity-tests against the `cdn-api`\n * worker). What remains here is the part that package deliberately does not\n * own: **the shape watchparty puts on the wire and validates on both sides.**\n *\n * This module must stay free of `@wolffm/cdn-client`, and that is structural\n * rather than stylistic — `@watchparty/shared` is imported by the SERVER, and\n * the key predicate below is what `ChatSend`/`ChatBroadcast` validate with. A\n * browser-oriented client package has no business in the server's bundle.\n *\n * **A chat message persists the KEY, never an absolute URL.** `cdn.hadoku.me`\n * is same-site with the app, which was a deliberate trade-off, and the\n * isolation upgrade (a separate registrable domain) is still on the table.\n * Storing keys makes that move a config change; storing absolute URLs would\n * make it a migration over all chat history — the most expensive place in this\n * system to be wrong. Every app that hotlinked Tenor GIFs lost its entire chat\n * back-scroll on 2026-06-30 for exactly this reason.\n */\n/**\n * Public read base for CDN objects.\n *\n * A literal rather than an env lookup on purpose: this value is needed in the\n * BROWSER bundle, where `process.env` does not exist — the `_env` guard used\n * by `PRODUCTION_UI_URL` in `defaults.ts` would resolve to the placeholder in\n * exactly the context that has to work. It is also not a secret in any sense:\n * it appears in the `src` of every image the app renders, and `hadoku.me`'s\n * CSP names it explicitly in `img-src`. Overridable per-deployment via the\n * `cdnBase` mount prop / `VITE_CDN_BASE`, and handed to `createCdnClient`.\n */\nexport const DEFAULT_CDN_BASE = 'https://cdn.hadoku.me';\n/**\n * `<64 lowercase hex chars><ext>`. Anchored, no capture of user-controlled text.\n *\n * **Deliberately stricter than `@wolffm/cdn-client`'s `isKey`,** which accepts\n * any `[a-z0-9]{1,8}` extension. This is an ALLOWLIST, so it also refuses\n * `.svg` — a scriptable document, not an image, and one served same-site with\n * the app could set a `Domain=.hadoku.me` cookie. The CDN refuses SVG by\n * sniffing bytes; this refuses it on the wire as well, so a key that somehow\n * existed still could not be rendered.\n *\n * `jpe?g` because the worker maps `image/jpeg` to `.jpg` but the older `.jpeg`\n * spelling is accepted defensively.\n */\n/**\n * **The key shape IS the trust boundary**, and it is validated in THREE places\n * on purpose — a key arrives from another client, so it is data, not input:\n *\n * 1. `ChatSend`/`ChatBroadcast` Zod (`protocol.ts`) — the server refuses to\n * persist or broadcast a bad key.\n * 2. `normalizeChatMessage` (`ui/lib/socket.ts`) — inbound messages are\n * re-checked at the client boundary; bad entries are dropped, not rendered.\n * 3. `chatImageUrl()` returns null — last line: render nothing rather than a\n * guessed URL.\n *\n * `uploadChatImage` also validates the key the SERVICE returned, because a\n * malformed one would be persisted into chat history permanently.\n *\n * A key is `<sha256-hex><ext>`: it cannot contain a scheme, host, traversal or\n * quote, which is the only reason it is safe to interpolate into an `<img src>`.\n * The extension list is an ALLOWLIST and is deliberately stricter than\n * `cdn-client.isKey` (which accepts any `[a-z0-9]{1,8}`) so that it also refuses\n * `.svg` — a scriptable document served same-site with the app, which could set\n * a `Domain=.hadoku.me` cookie. Do NOT add a client-side SVG conversion;\n * hadoku_site has a test that fails on purpose if the accepted set widens.\n */\nexport const CHAT_IMAGE_KEY_RE = /^[0-9a-f]{64}\\.(?:png|jpe?g|gif|webp|avif)$/;\n/**\n * True when `value` is a well-formed CDN object key.\n *\n * Applied in three places, all deliberate — a key arrives over the wire from\n * another client, so it is data, not a value we produced:\n * - the `ChatSend`/`ChatBroadcast` Zod schemas, so the server refuses to\n * persist or broadcast a bad one;\n * - `normalizeChatMessage` at the client boundary, so a bad entry is dropped\n * rather than rendered;\n * - `resolveChatImageUrl`, which returns null rather than a guessed URL —\n * `cdn.urlFor` composes unconditionally and is not a validator.\n */\nexport function isChatImageKey(value) {\n return typeof value === 'string' && CHAT_IMAGE_KEY_RE.test(value);\n}\n","/**\n * Centralized default values shared across server, UI, E2E, and scripts.\n *\n * This is the single source of truth for all configuration defaults.\n * Consumers should import from here instead of hardcoding values.\n */\n// ─── Ports ──────────────────────────────────────────────────────────────\n/** Default server HTTP port (matches hadoku_site PM2 ecosystem — CF tunnel routes watchparty.hadoku.me → :8080). */\nexport const DEFAULT_HTTP_PORT = 8080;\n/** Default Vite dev server port */\nexport const DEFAULT_UI_PORT = 5173;\n/** E2E test UI port (avoids colliding with dev) */\nexport const DEFAULT_E2E_UI_PORT = 5175;\n/** E2E test server port (avoids colliding with prod/dev) */\nexport const DEFAULT_E2E_SERVER_PORT = 8082;\n/** Common dev proxy port (e.g. hadoku-site reverse proxy) */\nexport const DEFAULT_DEV_PROXY_PORT = 8080;\n// ─── Server defaults ────────────────────────────────────────────────────\nexport const DEFAULT_MEDIA_ROOT = './media';\nexport const DEFAULT_CATALOG_TTL = 30;\nexport const DEFAULT_MAX_WS_PAYLOAD_BYTES = 4096;\nexport const DEFAULT_WS_BACKPRESSURE_THRESHOLD_BYTES = 262_144;\nexport const DEFAULT_LOG_DIR = './logs';\nexport const DEFAULT_LOG_RETENTION_DAYS = 14;\n/// Local SQLite store for per-friend-key match stats + lifetime aggregates.\n/// `stats.sqlite` will sit inside this dir. Gitignored; created at startup.\nexport const DEFAULT_STATS_DB_DIR = './data';\n// Drift thresholds\nexport const DEFAULT_DRIFT_SOFT_MS = 80;\n// Presence timing\nexport const DEFAULT_PRESENCE_TTL_MS = 15_000;\n// Limits\nexport const DEFAULT_MAX_ROOMS = 50;\n/** Cap on live EPHEMERAL rooms (`<app>-*`, see apps/server/src/rooms/ephemeralRooms.ts).\n * Separate from MAX_ROOMS because those rooms are minted by joining, one per\n * draft or match, and must neither crowd out the curated rooms nor be crowded\n * out by them. Still bounded: a friend-tier key is the only gate, and an\n * unbounded namespace would be a room-per-request memory leak behind it.\n *\n * 800 is MEASURED, not guessed — `scripts/stress/ramp.mjs`, 2026-09-19, on the\n * box that runs production. The 200 it replaces was a guess, and it was low:\n * 1600 rooms / 6400 sockets ran at 10% of one core with zero dropped joins,\n * so the room axis never reached a knee at all. 800 is that verified-clean\n * figure halved.\n *\n * What DOES have a knee is message throughput, and it binds regardless of how\n * the rooms are arranged: healthy to 4800 chat/s (86% of one core, p99 10ms),\n * knee at 7200/s (p99 1.3s, event-loop lag p99 61ms), collapse at 9600/s.\n * At 800 rooms every room would have to sustain 6 messages/second to reach\n * that, so the cap sits ~6x under the real constraint.\n *\n * Memory per room is bounded by DEFAULT_CHAT_MAX_MESSAGES (200) rather than by\n * room lifetime, so 800 rooms is tens of MB — the cap is an abuse bound, not a\n * capacity one. Node's event loop is single-threaded, so the ceiling to watch\n * is 100% of ONE core, not of the box.\n *\n * SHARED across every namespace in EPHEMERAL_ROOM_NAMESPACES, so a second app\n * draws on the same 800 — which the measurement above says is fine, since the\n * room axis never reached a knee at twice this. If that stops being true the\n * fix is a per-namespace cap, not a bigger shared one. */\nexport const DEFAULT_MAX_EPHEMERAL_ROOMS = 800;\n/** Child apps admitted to the unprovisioned room namespace: a key spelled\n * `<namespace>-<sessionId>` is joinable without POST /api/rooms.\n *\n * A LIST rather than a shape on purpose — see the header of\n * apps/server/src/rooms/ephemeralRooms.ts for why an enumerable namespace is\n * what keeps a mistyped or deleted curated key failing loudly instead of\n * silently minting an empty room. Overridden wholesale by\n * EPHEMERAL_ROOM_NAMESPACES; adding an app is a line in hadoku_site's pm2\n * wrapper, not a watchparty deploy. */\nexport const DEFAULT_EPHEMERAL_ROOM_NAMESPACES = ['cardtable'];\n// HTTP rate limiting (token buckets; see apps/server/src/middleware/rateLimit.ts)\n//\n// Sized from measurement, not guesswork: a fragment is 2002 ms / ~1.9 MiB, so a\n// steady viewer makes 1.0 req/s; a join or a seek is a ~30-request burst; five\n// concurrent rooms is ~5 req/s steady, and five rooms joining at once is ~150\n// requests in a few seconds. The per-user ceiling is deliberately far above\n// that: five concurrent room streams from ONE user must never 429.\n/** Per-user bucket size: absorbs ~20 join/seek bursts back to back. */\nexport const DEFAULT_RATE_LIMIT_USER_CAPACITY = 600;\n/** Per-user refill, tokens per second (1800/min — 30x one viewer's steady rate). */\nexport const DEFAULT_RATE_LIMIT_USER_REFILL_PER_S = 30;\n/** Global bucket size. Pathology catch only: an order of magnitude over any real party. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_CAPACITY = 2000;\n/** Global refill, tokens per second. The largest realistic party is ~8 req/s. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_REFILL_PER_S = 200;\n/** Retry-After (seconds) floor on a global 503, so a stampede does not retry instantly. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_RETRY_AFTER_S = 2;\n/** Most in-flight (non-terminal) submissions one user may hold, regardless of bytes. */\nexport const DEFAULT_SUBMISSION_PENDING_COUNT_CAP = 25;\n// Media serving\nexport const DEFAULT_MAX_CONCURRENT_MEDIA = 12;\nexport const DEFAULT_FRAGMENT_CACHE_MB = 64;\nexport const DEFAULT_QUEUE_TIMEOUT_MS = 45_000;\n/** Cache-Control max-age (seconds) for media BYTES under /media.\n * Deliberately minutes, not hours: cross-viewer fragment reuse happens within\n * seconds, while a longer TTL would widen the window in which a `--force`\n * re-ingest leaves stale bytes cached against fresh byte offsets. Manifests\n * (*.json) are always `no-cache` regardless of this value. */\nexport const DEFAULT_MEDIA_CACHE_MAX_AGE_S = 300;\nexport const DEFAULT_MAX_CACHEABLE_BYTES = 2_097_152; // 2MB\n// ─── WebSocket ─────────────────────────────────────────────────────────\n/** Socket.io path used by both server and UI */\nexport const DEFAULT_WS_PATH = '/watchparty/ws';\n/** Socket.io ping timeout (ms) for client keepalive */\nexport const DEFAULT_SOCKET_PING_TIMEOUT_MS = 300_000;\n// ─── Rate limiting ─────────────────────────────────────────────────────\n/** Control events: capacity per window */\nexport const DEFAULT_CONTROL_RATE_CAPACITY = 10;\n/** Control events: window in seconds */\nexport const DEFAULT_CONTROL_RATE_WINDOW_S = 5;\n/** Seek events: capacity per window */\nexport const DEFAULT_SEEK_RATE_CAPACITY = 5;\n/** Seek events: window in seconds */\nexport const DEFAULT_SEEK_RATE_WINDOW_S = 1;\n/** Chat messages: capacity per window */\nexport const DEFAULT_CHAT_RATE_CAPACITY = 3;\n/** Chat messages: window in seconds */\nexport const DEFAULT_CHAT_RATE_WINDOW_S = 1;\n/** Identity updates: capacity per window. A single join legitimately costs up\n * to 2 (name + sprite), so 2 left zero headroom — a reconnect + one sprite\n * retry instantly tripped the limit (cedar-ridge \"Coz\" incident, 2026-06-04).\n * 5 gives a join + a few corrections room to breathe. */\nexport const DEFAULT_IDENTITY_RATE_CAPACITY = 5;\n/** Identity updates: refill interval in ms */\nexport const DEFAULT_IDENTITY_RATE_WINDOW_MS = 10_000;\n// ─── Chat ──────────────────────────────────────────────────────────────\n/** Max chat message length in characters */\nexport const DEFAULT_CHAT_MAX_LENGTH = 500;\n/** Local chat send cooldown in ms */\nexport const DEFAULT_CHAT_COOLDOWN_MS = 1_000;\n/** Max chat messages retained per room (server-side Redis cap) */\nexport const DEFAULT_CHAT_MAX_MESSAGES = 200;\n/** Max chat messages retained in the UI store for display */\nexport const DEFAULT_CHAT_UI_BUFFER = 500;\n/** Number of chat messages sent to a client on join */\nexport const DEFAULT_CHAT_HISTORY_JOIN_LIMIT = 100;\n/** Default page size for chat history pagination */\nexport const DEFAULT_CHAT_PAGE_LIMIT = 50;\n/** Maximum page size cap for chat history pagination */\nexport const DEFAULT_CHAT_PAGE_LIMIT_MAX = 100;\n/** Max image attachments on a single chat message. Small on purpose: the\n * attachments ride the websocket as keys, and the chat log sits beside a\n * synchronized video player where a tall message pushes the room out of view. */\nexport const DEFAULT_CHAT_MAX_IMAGES = 4;\n// ─── GC / background ──────────────────────────────────────────────────\n/** Room idle threshold before GC (10 minutes) */\nexport const DEFAULT_GC_ROOM_IDLE_MS = 10 * 60 * 1_000;\n/** Empty room grace period before GC destroy (2 minutes) */\nexport const DEFAULT_GC_ROOM_EMPTY_GRACE_MS = 2 * 60 * 1_000;\n/** GC sweep loop interval (10 seconds) */\nexport const DEFAULT_GC_INTERVAL_MS = 10_000;\n/** Rate limiter bucket prune age (1 hour) */\nexport const DEFAULT_RATE_LIMITER_PRUNE_AGE_MS = 3_600_000;\n/** Log cleanup loop interval (1 hour) */\nexport const DEFAULT_LOG_CLEANUP_INTERVAL_MS = 3_600_000;\n// ─── Health ────────────────────────────────────────────────────────────\n/** Interval between health snapshot broadcasts to the leader */\nexport const DEFAULT_HEALTH_BROADCAST_INTERVAL_MS = 3_000;\n// ─── Application heartbeat ─────────────────────────────────────────────\n/** App-level heartbeat: disconnect sockets that stop sending time_ping for this long */\nexport const DEFAULT_APP_HEARTBEAT_TIMEOUT_MS = 60_000;\n// ─── Media scheduler tuning ────────────────────────────────────────────\n/** Warn-and-act on a media Range stream that has written no bytes for this long.\n * Lower → noisier but catches stalled streams faster; raise on flaky upstreams. */\nexport const DEFAULT_STUCK_STREAM_THRESHOLD_MS = 15_000;\n/** Kill a media Range stream that has been stuck (no bytes written) this long.\n * Without this, a stuck stream holds its concurrency slot until the OS times\n * out — under sustained load the pool drains and every fresh fetch queues for\n * 45s → 503 cascade. Reproduced in iter3 (2026-05-25): audio_jpn_X.m4a hit\n * 40s+ TTFB at origin, all 4 viewers cascaded into HTTP 500. Threshold must\n * be > STUCK_STREAM_THRESHOLD_MS so warns precede the kill. */\nexport const DEFAULT_STUCK_STREAM_ABORT_MS = 30_000;\n/** A media client (by ip) is considered \"currently active\" if it has dispatched\n * a request within this window. Used by the priority-queue fairness logic. */\nexport const DEFAULT_MEDIA_CLIENT_ACTIVE_WINDOW_MS = 30_000;\n/** Rolling window for per-client throughput/queue stats summaries. */\nexport const DEFAULT_MEDIA_CLIENT_STATS_WINDOW_MS = 60_000;\n/** Buffer-ahead-ms below which a viewer's request is flagged as \"emergency\"\n * and dispatched into the reserve slot ahead of regular waiters. */\nexport const DEFAULT_EMERGENCY_BUFFER_MS = 3_000;\n/** How often the media-scheduler emits its periodic summary log when there is\n * active traffic. Skipped entirely when the system is idle. */\nexport const DEFAULT_MEDIA_SCHEDULER_SUMMARY_INTERVAL_MS = 30_000;\n/** Grace window after a socket disconnect before we broadcast a \"leave\" chat\n * system message. Lets transient reconnects (CF tunnel hiccups, refresh,\n * cellular↔wifi handoff on phones) avoid noisy join/leave churn. 60s\n * covers cellular↔wifi handoff stabilization — was 15s before Bug 2 in\n * the 2026-05-20 cedar-ridge retrospective showed phones with measured\n * reconnect gaps up to 60s producing spam. */\nexport const DEFAULT_CHAT_RECONNECT_GRACE_MS = 60_000;\n/** Periodic heartbeat byte interval (ms) for `streamE2eProcess` — keeps\n * Cloudflare-Free from idling out long-silent ingest/test streams. */\nexport const DEFAULT_STREAM_KEEPALIVE_HEARTBEAT_MS = 20_000;\n// ─── Client log endpoint ───────────────────────────────────────────────\n/** Max log entries per client-log POST request */\nexport const DEFAULT_CLIENT_LOG_MAX_ENTRIES = 50;\n// ─── Production URLs ───────────────────────────────────────────────────\n// Set via PRODUCTION_UI_URL / PRODUCTION_SERVER_URL env vars (e.g. in CI or .env).\n// Defaults to example.com so the public repo never leaks a real domain.\n// Guard: `process` is unavailable when this module is bundled for the browser.\nconst _env = typeof process !== 'undefined' && process.env ? process.env : {};\nexport const PRODUCTION_UI_URL = _env.PRODUCTION_UI_URL || 'https://example.com/watchparty/';\nexport const PRODUCTION_SERVER_URL = _env.PRODUCTION_SERVER_URL || 'https://watchparty.example.com';\n// ─── Stress test defaults ──────────────────────────────────────────────\nexport const DEFAULT_STRESS_VIEWERS = 10;\nexport const DEFAULT_STRESS_DURATION_SECS = 120;\nexport const DEFAULT_STRESS_MONITOR_INTERVAL_SECS = 10;\n// ─── Pino log levels ───────────────────────────────────────────────────\nexport const PINO_LEVEL_ERROR = 50;\nexport const PINO_LEVEL_WARN = 40;\nexport const PINO_LEVEL_INFO = 30;\nexport const PINO_LEVEL_DEBUG = 20;\n","/**\n * Tierlist scoring — turning N private S-to-F boards into one room board.\n *\n * This module is PURE and shared by client and server on purpose. The board\n * every viewer sees must be byte-identical to the one the leader sees and to\n * the one written into the stats DB; a hand-ported second copy of these rules\n * would drift silently and nobody would notice, because a plausible-but-wrong\n * tierlist looks exactly like a correct one.\n *\n * ── What is stored vs what is derived ──────────────────────────────────────\n * ONLY the raw letters are ever persisted (`Record<mediaId, TTier>` per voter).\n * Everything below runs at render time. That means the curve can be retuned\n * later without touching a single stored ballot, and the UI can always show\n * the raw picks next to the derived tier so the board stays auditable against\n * what people actually chose. Do NOT persist the output of `placeTierlist`\n * as if it were input.\n *\n * ── Why the raw average is not enough ──────────────────────────────────────\n * Averaging shrinks variance by ~sqrt(nVoters). Even when every individual\n * uses the full S-to-F range, the room means bunch toward the middle, and\n * rounding them lands most clips in B and C — a flat, unreadable board. That\n * compression is an ARTIFACT of averaging, not a finding about the clips, so\n * expanding it back is not fabricating signal.\n *\n * ── The rule: rescale the spread, never move the centre ────────────────────\n * A clip starts at the plain rounded average of its letters. When the board is\n * compressed (but not unanimous) each clip is nudged toward a more spread\n * board, and NEVER by more than `NUDGE_MAX_SHIFT` tiers.\n *\n * That clamp is load-bearing and structural, not a tuning knob. Because the\n * nudge is anchored on each clip's OWN average, an all-S board has every clip\n * anchored at S and the nudge can only ever step one tier — so a room that\n * genuinely loved everything stays top-heavy and CANNOT be turned into a bell\n * curve. Do not replace the clamp with a pure quantile allocation: quantile\n * moves the centre too, which is exactly the failure this design exists to\n * avoid.\n *\n * ── Why the gate has two sides (both verified, do not remove either) ───────\n * Nudging unconditionally was tried and is wrong at BOTH ends:\n * - Below `NUDGE_MIN_SD`: a unanimous board (everyone said B to everything,\n * sd = 0) got shifted a whole tier to A. Moving a perfect consensus is\n * indefensible.\n * - At or above `NUDGE_MAX_SD`: an already well-spread board\n * (S1 A2 B3 C3 D2 F2) was rearranged into a worse one (S2 A4 B1 C4 D1 F1).\n * There was nothing to fix.\n * So the nudge runs only in the band between them.\n *\n * ── Voter centring only bites on PARTIAL ballots ───────────────────────────\n * Subtracting each voter's own mean is provably a no-op when everyone rated\n * every clip — measured difference exactly 0.0000 — because it shifts every\n * clip by the same constant and cancels. It matters only when voters rated\n * DIFFERENT subsets: a late joiner, or someone who left before the end. That\n * is the common case in a watchparty, which is why it is here. The mean must\n * be taken over the clips THAT VOTER rated, never over all clips; taking it\n * over all clips silently reduces it to the no-op case.\n */\nimport { z } from 'zod';\nexport const Tier = z.enum(['S', 'A', 'B', 'C', 'D', 'F']);\n/** Display order, best first. Also the row order of the board. */\nexport const TIER_ORDER = ['S', 'A', 'B', 'C', 'D', 'F'];\n/** Numeric level per tier. Higher is better; F = 1 so that no level is 0\n * (a 0 would make \"unrated\" and \"rated F\" indistinguishable in a sum). */\nexport const TIER_LEVEL = { S: 6, A: 5, B: 4, C: 3, D: 2, F: 1 };\nconst MIN_LEVEL = 1;\nconst MAX_LEVEL = 6;\n/** Nudge gate, lower bound. Below this the room has effectively agreed and the\n * board is left exactly as the averages placed it. */\nexport const NUDGE_MIN_SD = 0.35;\n/** Nudge gate, upper bound. At or above this the board already reads well and\n * is left alone. */\nexport const NUDGE_MAX_SD = 1.0;\n/** Hard cap on how far the nudge may move a clip from its own average, in\n * tiers. This is what makes a genuine lean impossible to flatten. */\nexport const NUDGE_MAX_SHIFT = 1;\n/** One step of within-tier order, in score units.\n *\n * Deliberately two orders of magnitude below a tier (1.0): a clip's position\n * in its row refines the ORDER of that row and must never change which row it\n * is in. See `subRankOffset` for why this is applied AFTER placement rather\n * than folded into the score the tier is derived from. */\nexport const SUB_TIER_STEP = 0.01;\n/** Ceiling on the total sub-rank offset. Keeps the integer part of a displayed\n * score equal to its tier level even for an absurdly long row, which is the\n * property that makes the number readable at a glance (an S clip reads 6.0x,\n * never 7.0). */\nexport const SUB_TIER_MAX = 0.49;\n/** Target share per tier, S..F, used only to rank-order a compressed board.\n * Deliberately mild — a strong shape would fight the clamp and never win. */\nexport const TARGET_SHAPE = [0.08, 0.17, 0.25, 0.25, 0.17, 0.08];\n/** Flatten a ballot to `mediaId -> tier`, discarding order. */\nexport function ballotPicks(ballot) {\n const out = {};\n for (const tier of TIER_ORDER) {\n for (const mediaId of ballot.board[tier] ?? [])\n out[mediaId] = tier;\n }\n return out;\n}\n/** Clamp to the tier scale and convert a numeric level to a letter. */\nexport function levelToTier(level) {\n const rounded = Math.round(level);\n const clamped = Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, rounded));\n // TIER_ORDER is best-first (S=6 at index 0), so index = MAX - level.\n return TIER_ORDER[MAX_LEVEL - clamped];\n}\n/** Population standard deviation. Population, not sample: these are all the\n * clips in the playlist, not a draw from a larger pool. */\nfunction stdev(values) {\n if (values.length === 0)\n return 0;\n const mean = values.reduce((a, b) => a + b, 0) / values.length;\n const variance = values.reduce((a, v) => a + (v - mean) * (v - mean), 0) / values.length;\n return Math.sqrt(variance);\n}\n/**\n * Rank each clip onto a tier by `TARGET_SHAPE`.\n *\n * Clips with an identical score MUST come out on the same tier — otherwise the\n * arbitrary order of two equal clips decides which row they land in, and two\n * clients sorting equal scores differently would render different boards. Ties\n * are therefore repaired to the tier their group's MIDPOINT position falls in.\n */\nfunction rankTiers(scores) {\n const n = scores.size;\n const cuts = [];\n let run = 0;\n for (let i = 0; i < TARGET_SHAPE.length - 1; i++) {\n run += TARGET_SHAPE[i] * n;\n cuts.push(run);\n }\n const tierAt = (position) => {\n for (let j = 0; j < cuts.length; j++) {\n if (position < cuts[j])\n return MAX_LEVEL - j;\n }\n return MIN_LEVEL;\n };\n // Order by score desc, mediaId asc — the same total order used for display,\n // so ranking and rendering can never disagree.\n const order = [...scores.keys()].sort((a, b) => {\n const d = scores.get(b) - scores.get(a);\n return d !== 0 ? d : a.localeCompare(b);\n });\n const position = new Map();\n order.forEach((id, i) => position.set(id, i));\n const out = new Map();\n for (const id of order)\n out.set(id, tierAt(position.get(id)));\n // Tie repair: every clip sharing a score takes the group's midpoint tier.\n // That ties stay TOGETHER is the invariant (tested). Midpoint specifically —\n // rather than the group's best or worst position — is a judgment call: it\n // keeps a tied pair straddling a cut from being pulled wholly into the more\n // generous row. Change it if you prefer, but keep them on one row.\n const groups = new Map();\n for (const [id, score] of scores) {\n const key = Math.round(score * 1e6) / 1e6;\n const g = groups.get(key);\n if (g)\n g.push(id);\n else\n groups.set(key, [id]);\n }\n for (const group of groups.values()) {\n if (group.length < 2)\n continue;\n const mid = group.reduce((a, id) => a + position.get(id), 0) / group.length;\n const tier = tierAt(mid);\n for (const id of group)\n out.set(id, tier);\n }\n return out;\n}\n/**\n * Collapse a set of ballots into one board.\n *\n * `mediaIds` is the playlist's full clip list and fixes the universe — a clip\n * nobody rated comes back in `unrated` rather than being given an invented\n * score.\n */\nexport function placeTierlist(mediaIds, ballots) {\n // Flatten each ballot once: tier, plus the clip's rank from the BOTTOM of\n // its row and that row's size. Rank-from-bottom so the top of a row scores\n // highest, matching how a tierlist is read.\n const picksOf = new Map();\n const rankOf = new Map();\n for (const ballot of ballots) {\n picksOf.set(ballot.voterId, ballotPicks(ballot));\n const ranks = new Map();\n for (const tier of TIER_ORDER) {\n const row = ballot.board[tier] ?? [];\n row.forEach((mediaId, i) => ranks.set(mediaId, row.length - 1 - i));\n }\n rankOf.set(ballot.voterId, ranks);\n }\n // ── Per-voter mean, over the clips THAT VOTER rated ────────────────────\n const voterMean = new Map();\n let grandTotal = 0;\n let grandCount = 0;\n for (const ballot of ballots) {\n const picks = picksOf.get(ballot.voterId);\n const levels = mediaIds\n .filter(id => picks[id] !== undefined)\n .map(id => TIER_LEVEL[picks[id]]);\n if (levels.length === 0)\n continue;\n const sum = levels.reduce((a, b) => a + b, 0);\n voterMean.set(ballot.voterId, sum / levels.length);\n grandTotal += sum;\n grandCount += levels.length;\n }\n // Re-anchor the centred deviations on the grand mean so scores stay on the\n // 1..6 tier scale instead of hovering around zero.\n const grandMean = grandCount > 0 ? grandTotal / grandCount : 0;\n // ── Voter-centred score per clip ───────────────────────────────────────\n const scores = new Map();\n const votesByClip = new Map();\n /** Did every rater of this clip give it the same letter? */\n const isUnanimous = new Map();\n const unrated = [];\n const subRank = new Map();\n for (const mediaId of mediaIds) {\n const raw = {};\n let deviationSum = 0;\n let rankSum = 0;\n let raters = 0;\n for (const ballot of ballots) {\n const pick = picksOf.get(ballot.voterId)[mediaId];\n if (pick === undefined)\n continue;\n const mean = voterMean.get(ballot.voterId);\n if (mean === undefined)\n continue;\n raw[ballot.voterId] = pick;\n deviationSum += TIER_LEVEL[pick] - mean;\n rankSum += rankOf.get(ballot.voterId)?.get(mediaId) ?? 0;\n raters++;\n }\n votesByClip.set(mediaId, raw);\n if (raters === 0) {\n unrated.push(mediaId);\n continue;\n }\n scores.set(mediaId, deviationSum / raters + grandMean);\n isUnanimous.set(mediaId, new Set(Object.values(raw)).size === 1);\n // Mean rank-from-bottom across the voters who placed it, scaled into score\n // units and capped. With a single voter this is exactly the ladder the\n // feature was asked for: five clips in one row score X.04 down to X.00.\n subRank.set(mediaId, Math.min(SUB_TIER_MAX, SUB_TIER_STEP * (rankSum / raters)));\n }\n if (scores.size === 0) {\n return { placed: [], unrated, spread: 0, nudged: false };\n }\n // ── Absolute placement — the honest baseline ───────────────────────────\n const absolute = new Map();\n for (const [id, score] of scores) {\n absolute.set(id, Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, Math.round(score))));\n }\n // ── Gated, clamped nudge ───────────────────────────────────────────────\n const spread = stdev([...scores.values()]);\n const nudged = spread >= NUDGE_MIN_SD && spread < NUDGE_MAX_SD;\n const finalLevel = new Map(absolute);\n if (nudged) {\n const ranked = rankTiers(scores);\n for (const [id, abs] of absolute) {\n // NEVER overrule unanimity. The nudge exists to undo the variance that\n // AVERAGING removes; a clip its raters all put in one tier had nothing\n // averaged away, so there is nothing to restore and moving it just\n // contradicts them.\n //\n // This is not a corner case — it is every single-voter board, where each\n // clip is trivially unanimous. Without the guard a lone voter's board\n // came back shifted a whole tier DOWN: two shows placed in S were\n // revealed in A (observed on production, rainbow-roost). `absolute` was\n // right the whole time; the nudge was the only thing moving them.\n if (isUnanimous.get(id))\n continue;\n const want = ranked.get(id);\n finalLevel.set(id, Math.max(abs - NUDGE_MAX_SHIFT, Math.min(abs + NUDGE_MAX_SHIFT, want)));\n }\n }\n // ── Deterministic output order ─────────────────────────────────────────\n // Tier desc, then score desc, then mediaId asc. The mediaId tiebreak is what\n // guarantees every client renders the same board for equal scores.\n const placed = [...scores.keys()]\n .map(mediaId => {\n const tier = levelToTier(finalLevel.get(mediaId));\n const offset = subRank.get(mediaId) ?? 0;\n return {\n mediaId,\n tier,\n absoluteTier: levelToTier(absolute.get(mediaId)),\n // Tier level + position in the row. NOT the placement score — see the\n // field comment for why folding them together breaks placement.\n score: TIER_LEVEL[tier] + offset,\n placementScore: scores.get(mediaId),\n subRankOffset: offset,\n votes: votesByClip.get(mediaId) ?? {},\n };\n })\n .sort((a, b) => {\n const byTier = TIER_LEVEL[b.tier] - TIER_LEVEL[a.tier];\n if (byTier !== 0)\n return byTier;\n // Within a row: sub-rank first (that IS the row order people set), then\n // the consensus that put them in the row, then mediaId so two clients\n // sorting equal boards can never disagree.\n const byScore = b.score - a.score;\n if (byScore !== 0)\n return byScore;\n const byPlacement = b.placementScore - a.placementScore;\n if (byPlacement !== 0)\n return byPlacement;\n return a.mediaId.localeCompare(b.mediaId);\n });\n return { placed, unrated, spread, nudged };\n}\n/**\n * Map a legacy `VoteValue` onto a tier.\n *\n * The vote playlist's four values are a coarse tierlist already, so an existing\n * vote board can be read as one. `mid` deliberately maps to C rather than B:\n * the vote scale is not symmetric (one positive extreme, one step down, a\n * neutral, one negative) and C is the neutral row of a six-tier board.\n */\nexport const VOTE_TO_TIER = {\n super_upvote: 'S',\n upvote: 'A',\n mid: 'C',\n downvote: 'F',\n};\n","import { z } from 'zod';\nimport { CHAT_IMAGE_KEY_RE } from './chatImages.js';\nimport { DEFAULT_CHAT_MAX_LENGTH, DEFAULT_CHAT_MAX_IMAGES } from './defaults.js';\n/** Upper bound on a reported image dimension. Guards against a client sending\n * an absurd number that would drive a huge CSS aspect-ratio box. */\nconst MAX_IMAGE_DIMENSION = 20_000;\n/** Upper bound on an image's `alt` text. The meme library's longest title sits\n * around 110 chars; this leaves room without letting one attachment carry\n * more prose than the message itself. */\nconst MAX_IMAGE_TITLE_LENGTH = 300;\n// Identity-update Zod schema — used by the server's identity_update WS handler\n// to validate inbound payloads (presence.ts). Kept here next to the other\n// inbound schemas like JoinIn / ChatSend.\nexport const IdentityUpdateSchema = z.object({\n name: z.string().min(1).max(32).optional(),\n sprite: z.string().min(1).max(32).optional(),\n /** \"My avatar settings just changed — re-read my picture\" (the fleet\n * settings popout's `hadoku:avatar` event). Carries no picture: the server\n * resolves it from prefs-api by registry userId, never from the client. */\n avatar: z.literal(true).optional(),\n});\n// Wire event names — the single source of truth.\n// Use these instead of string literals when subscribing/emitting. New events MUST be added here\n// so server + client + tests can't drift. (Family-specific events live in PLAYLIST_EVENTS / READY_EVENTS below.)\nexport const CORE_EVENTS = {\n JOIN: 'join',\n SNAPSHOT: 'snapshot',\n CONTROL: 'control',\n CONTROL_ACK: 'control_ack',\n CONTROL_BROADCAST: 'control_broadcast',\n CHAT_SEND: 'chat_send',\n CHAT_BROADCAST: 'chat_broadcast',\n CHAT_HISTORY: 'chat_history',\n PRESENCE: 'presence',\n PRESENCE_PING: 'presence_ping',\n IDENTITY_UPDATE: 'identity_update',\n TIME_PING: 'time_ping',\n TIME_PONG: 'time_pong',\n HEALTH_REPORT: 'health_report',\n HEALTH_SNAPSHOT: 'health_snapshot',\n BUFFER_STATUS: 'buffer_status',\n CLIENT_READY: 'client_ready',\n // 'ready_state' here = playback-readiness gate (distinct from READY_EVENTS.STATE = \"ready check\" UX feature)\n READY_STATE: 'ready_state',\n ERROR: 'error',\n};\n// Control kinds per protocol.md\nexport const ControlKind = z.enum(['play', 'pause', 'seek', 'load', 'home']);\n// Optional identity fields (name + sprite) allow client roster population immediately after join.\n// These are advisory and not authenticated; server will simply echo through presence lists.\n// NOTE: room_key now determines identity (viewer vs leader) via server-side lookup.\n// leader_key and override fields removed - identity is embedded in the room_key itself.\n// How the client is framed. `client_url` is read from the app's own window and\n// is identical for a direct load and for the nested-iframe entry from the\n// hadoku.me home page, so nothing in the logs could distinguish the two entry\n// paths (2026-08-04). Optional + backward compatible: older clients omit it.\nexport const EmbedContext = z.object({\n framed: z.boolean(),\n depth: z.number().int().nonnegative().max(10)\n});\nexport const JoinIn = z.object({\n room_key: z.string(),\n name: z.string().min(1).max(32).optional(),\n sprite: z.string().min(1).max(32).optional(),\n embed: EmbedContext.optional(),\n /** Which media-delivery path this client resolved to ('range' | 'fragment').\n * Optional so older clients still validate. Reported so a party's logs can be split by\n * arm — without it an A/B rollout is unmeasurable, exactly as entry path was before\n * `embed` existed. */\n delivery_arm: z.enum(['range', 'fragment']).optional(),\n /** Browser-persisted device id (`hadoku_device_id`, owned by\n * @wolffm/prefs-client). Lets the server derive a `dev:`-tier\n * ParticipantId for a client with no friend key, so an anonymous viewer\n * keeps their identity across a reload instead of becoming a new person.\n *\n * Optional because it genuinely is: a first-ever visit, or a browser with\n * storage blocked, sends nothing and gets an `eph:` identity. Also keeps\n * older clients validating.\n *\n * CLIENT-ASSERTED — nothing proves it, so it must never gate a capability.\n * See `isAuthenticatedTier` in @wolffm/rooms-protocol. */\n device_id: z.string().min(1).max(128).optional(),\n /** Random per PAGE LOAD, carried on every rejoin from that page. A join whose\n * tab id matches a live connection of the same participant is that tab\n * reconnecting, so the server retires the old connection at once instead of\n * showing it as a second row until the heartbeat reaps it (mistral-bluff\n * 2026-10-20). Never broadcast: it is what lets a rejoin evict a connection.\n * CLIENT-ASSERTED, so it never gates a capability. */\n tab_id: z.string().min(8).max(64).optional(),\n /** Present on a rejoin only: why this page's previous connection ended, as the\n * BROWSER saw it, and for how long it was gone. The server cannot know either\n * — a connection cut upstream never reaches it as a close. */\n reconnect: z.object({\n count: z.number().int().nonnegative(),\n reason: z.string().max(64),\n offline_ms: z.number().int().nonnegative(),\n }).optional(),\n});\nexport const Snapshot = z.object({\n // The room's id — the same for every participant, unlike the key each one\n // joined with (a leader key is a credential). What a game bundle is told\n // its room is (GameBundleSlot).\n room_id: z.string(),\n play_state: z.enum(['idle', 'playing', 'paused', 'ended']),\n playhead_ms: z.number().int().nonnegative(),\n leader_id: z.string().nullable(),\n media_id: z.string().nullable(),\n server_seq: z.number().int().nonnegative(),\n server_ts: z.number().int().nonnegative()\n});\nexport const ControlIn = z.object({\n client_seq: z.number().int().positive(),\n kind: ControlKind,\n to_ms: z.number().int().nonnegative().optional(),\n media_id: z.string().optional()\n // leader_id removed - not needed since set_leader control was removed\n});\n/**\n * A CDN image attachment on a chat message.\n *\n * `key` is validated against the content-addressed shape rather than accepted\n * as free text: it ends up in an `<img src>` on every client in the room, so\n * the schema is the trust boundary. `w`/`h` are the intrinsic pixel dimensions,\n * measured client-side before upload (the CDN deliberately does not return\n * them) so the chat log can reserve layout space and not reflow when the image\n * loads — which matters more here than in most chat apps, because the log sits\n * beside a synchronized video player where reflow is very visible.\n */\nexport const ChatImage = z.object({\n key: z.string().regex(CHAT_IMAGE_KEY_RE, 'not a CDN image key'),\n /**\n * Intrinsic pixels, or **0 meaning \"unknown\"**.\n *\n * `nonnegative`, not `positive`: `measureImage()` falls back to 0x0 when a\n * file cannot be decoded (a corrupt paste, or a format `createImageBitmap`\n * and the `<img>` fallback both refuse), and the renderer has always handled\n * that — `hasDims = w > 0 && h > 0` drops it to natural flow instead of\n * reserving a box. The schema disagreed, so such a message failed validation\n * and was DROPPED by the server: the paste vanished and the user's typed\n * text went with it, with nothing said. Widening is backward compatible —\n * every message already in history carries positive dimensions.\n */\n w: z.number().int().nonnegative().max(MAX_IMAGE_DIMENSION),\n h: z.number().int().nonnegative().max(MAX_IMAGE_DIMENSION),\n /**\n * Human-readable description, rendered as the `alt` text.\n *\n * Present for a meme picked from the shared library (where it is frequently\n * the ONLY text describing the image, so a meme-heavy log is unusable with a\n * screen reader without it) and absent for a pasted screenshot, which has no\n * title to carry. Optional so that older clients — and every message already\n * in chat history — still validate.\n *\n * It is untrusted text from another client and is rendered as an attribute\n * value only, never as markup. Bounded so one message cannot carry a\n * paragraph; the library's own titles run well under this.\n */\n title: z.string().max(MAX_IMAGE_TITLE_LENGTH).optional(),\n});\n/**\n * Inbound chat. `text` may be empty ONLY when the message carries at least one\n * image — the refine keeps \"no empty messages\" true while allowing an\n * image-only paste, which is the common case.\n */\nexport const ChatSend = z.object({\n text: z.string().max(DEFAULT_CHAT_MAX_LENGTH),\n style: z.string().max(64).optional(),\n images: z.array(ChatImage).max(DEFAULT_CHAT_MAX_IMAGES).optional(),\n}).refine((d) => d.text.length > 0 || (d.images?.length ?? 0) > 0, { message: 'message must carry text or at least one image' });\n// System message types for chat\nexport const SystemMessageType = z.enum(['join', 'leave', 'media_change', 'identity_change', 'recap']);\n// Chat broadcast payload (outbound from server)\nexport const ChatBroadcast = z.object({\n id: z.string(),\n client_id: z.string(),\n // The sender's participant handle — stable across their reconnects, unlike\n // `client_id`. Optional: system messages and pre-existing history have none.\n author: z.string().optional(),\n name: z.string(),\n sprite: z.string().nullable(),\n text: z.string(),\n ts: z.number(),\n // Optional so a client on an older bundle still validates a message that\n // has none, and so system messages (which never carry images) stay unchanged.\n images: z.array(ChatImage).max(DEFAULT_CHAT_MAX_IMAGES).optional(),\n isSystem: z.boolean().optional(),\n systemType: SystemMessageType.optional()\n});\n// Health reporting (client → server)\nexport const HealthReport = z.object({\n avg_download_rate_bps: z.number().nonnegative(),\n buffered_ahead_ms: z.number().nonnegative(),\n rtt_ms: z.number().nonnegative(),\n playhead_ms: z.number().nonnegative(),\n is_buffering: z.boolean(),\n fragments_loaded: z.number().int().nonnegative(),\n fragments_pending: z.number().int().nonnegative(),\n fragments_error: z.number().int().nonnegative(),\n video_ready_state: z.number().int().optional(),\n /** Buffered-ahead of the WORST AUDIO track, ms. `buffered_ahead_ms` above is\n * already the min across video and audio; this is reported separately so the\n * health card (and any later analysis) can say WHICH track is starving.\n * -1 means \"no audio FragmentManager\" (media still loading, or audio not yet\n * attached) and must never be treated as a zero-length buffer. Defaults to\n * -1 so older clients, which never send it, are read as \"unknown\". */\n audio_buffered_ahead_ms: z.number().default(-1),\n // Nominal media duration (ms) = last fragment's end. Lets the leader's health\n // card distinguish \"near end-of-stream, little buffer ahead is expected\" from\n // a real low-buffer stall. Defaults to 0 so older clients still validate.\n duration_ms: z.number().nonnegative().default(0),\n /** Media delivery path this client is actually using. Reported HERE and not only at\n * join because the join-time value is provisional — /api/flags lands after the socket\n * connects, so the arm is not final until FragmentManager first reads it. Optional so\n * older clients still validate. */\n delivery_arm: z.enum(['range', 'fragment']).optional(),\n /** Page state, so a stall can be read against what the viewer could see.\n * Without it \"he says he saw nothing\" and \"his player froze for 55s\" could\n * not be reconciled (mistral-bluff 2026-10-20). All optional: older\n * clients omit them, and `net_*` exist only where the browser exposes\n * navigator.connection (Chromium). */\n page_visible: z.boolean().optional(),\n page_focused: z.boolean().optional(),\n muted: z.boolean().optional(),\n online: z.boolean().optional(),\n net_type: z.string().max(16).optional(),\n net_rtt_ms: z.number().nonnegative().optional(),\n net_downlink_mbps: z.number().nonnegative().optional(),\n /** ms since this connection last received a time_pong — a half-open\n * socket shows here as a growing number while everything else looks fine. */\n pong_age_ms: z.number().int().nonnegative().optional(),\n reported_at: z.number().int().nonnegative()\n});\n// Health snapshot (server → leader): per-client health with identity\nexport const ClientHealth = HealthReport.extend({\n client_id: z.string(),\n name: z.string(),\n sprite_id: z.string().nullable(),\n /** Set when this connected client's last report is older than the freshness\n * window: how old it is, ms. The card then shows the client as silent\n * rather than dropping it — a viewer whose reports cannot get through is the\n * one the leader most needs to see. */\n stale_ms: z.number().int().nonnegative().optional(),\n});\nexport const HealthSnapshot = z.object({\n clients: z.array(ClientHealth),\n server_ts: z.number().int().nonnegative()\n});\n// Buffer status (server → all clients in room): lightweight per-client buffer data\nexport const BufferClient = z.object({\n client_id: z.string(),\n sprite_id: z.string().nullable(),\n name: z.string(),\n buffered_ahead_ms: z.number().nonnegative(),\n // playhead + duration let the viewer buffer ring apply the same near-end\n // exemption the leader's health card uses: near end-of-stream there is\n // legitimately little buffer ahead, so a low buffered_ahead_ms there must NOT\n // render \"critical\" red. Default 0 so older clients still validate.\n playhead_ms: z.number().nonnegative().default(0),\n duration_ms: z.number().nonnegative().default(0),\n /** The video element's readyState, forwarded so the viewer buffer ring can\n * apply the same \"plenty buffered but the element cannot advance\" rule the\n * leader's card uses (see components/bufferHealth.ts). -1 = unknown/older\n * client, which must never trip the rule. */\n video_ready_state: z.number().int().default(-1),\n});\nexport const BufferStatus = z.object({\n clients: z.array(BufferClient),\n server_ts: z.number().int().nonnegative(),\n});\n// Client readiness signal (client → server)\nexport const ClientReady = z.object({\n media_id: z.string(),\n});\n// ---------------------------------------------------------------------------\n// Playlist protocol\n// ---------------------------------------------------------------------------\nimport { Tier } from './tierlist.js';\nexport const VoteValue = z.enum(['super_upvote', 'upvote', 'mid', 'downvote']);\nexport const PlaylistVideo = z.object({\n mediaId: z.string(),\n displayName: z.string(),\n // Enrichment fields — present for flat-playlist ingest output, absent for\n // subfolder-layout playlist clips. All optional so old playlist.json stays valid.\n description: z.string().optional(),\n thumbnail: z.string().optional(), // media-relative path, e.g. output/fmp4/.../poster.jpg\n durationMs: z.number().optional(),\n uploadDate: z.string().optional(), // YouTube upload date 'YYYY-MM-DD'; drives playlist order\n});\n// Playlist type — `vote` is the rate-and-reveal playlist (per-video votes,\n// reveal popup); `basic` is a plain sequential watch-through with no voting.\n// Defaults to `vote` so a type-less playlist.json keeps its original behavior.\n//\n// There is deliberately no `tierlist` type: the tierlist is a GLOBAL board, not\n// a playback mode, and coupling it to playlist loading was a mistake that made\n// it vanish on every media change.\nexport const PlaylistType = z.enum(['basic', 'vote']);\nexport const PlaylistConfig = z.object({\n id: z.string(),\n name: z.string(),\n type: PlaylistType.default('vote'),\n videos: z.array(PlaylistVideo),\n cover: z.string().optional(), // media-relative path to the playlist cover image\n});\n// Client -> Server\nexport const PlaylistLoadIn = z.object({ playlistId: z.string() });\nexport const PlaylistSelectIn = z.object({ index: z.number().int().min(0) });\nexport const PlaylistVoteUpdateIn = z.object({\n playlistId: z.string(),\n mediaId: z.string(),\n vote: VoteValue,\n});\n/** Leader removes one voter from the revealed results. `voterKey` is a key of\n * `PlaylistResultsOut.votes` — the reveal-time id, which survives the voter\n * leaving (removal is mostly used on people who already left). */\nexport const PlaylistVoteRemoveIn = z.object({ voterKey: z.string().min(1).max(128) });\n/** Server -> a removed voter: drop your stored ballot. Carries the media ids\n * because on REJOIN it arrives before playlist_state (deliberately — that\n * state is what triggers the client's re-send), so the client cannot look the\n * playlist up yet. */\nexport const PlaylistVotesClearedOut = z.object({ playlistId: z.string(), mediaIds: z.array(z.string()) });\n// Server -> Client\nexport const PlaylistStateOut = z.object({\n playlist: PlaylistConfig.nullable(),\n activeIndex: z.number().int().nullable(),\n myVotes: z.record(z.string(), VoteValue), // mediaId -> VoteValue\n});\nexport const PlaylistVoteProgressItem = z.object({\n clientId: z.string(),\n name: z.string(),\n votedCount: z.number().int().nonnegative(),\n totalVideos: z.number().int().nonnegative(),\n});\nexport const PlaylistVoteProgressOut = z.object({\n progress: z.array(PlaylistVoteProgressItem),\n});\nexport const PlaylistResultsOut = z.object({\n playlist: PlaylistConfig,\n votes: z.record(z.string(), z.record(z.string(), VoteValue)), // votes[clientId][mediaId]\n names: z.record(z.string(), z.string()), // clientId -> display name\n /** clientId -> sprite id, frozen at reveal, so a voter who has since LEFT\n * (and so is gone from the live roster) still renders. Optional: older\n * servers omit it and clients fall back to the roster. */\n sprites: z.record(z.string(), z.string()).optional(),\n /** clientId -> avatar picture URL, frozen at reveal like `sprites`. Only\n * people who have a picture; everyone else shows their character. */\n pictures: z.record(z.string(), z.string()).optional(),\n /** True when this re-sends results after a voter was removed. A client whose\n * reveal is closed ignores it, rather than having the popup reappear. */\n update: z.boolean().optional(),\n});\nexport const PLAYLIST_EVENTS = {\n LOAD: 'playlist_load',\n SELECT: 'playlist_select',\n VOTE_UPDATE: 'playlist_vote_update',\n REVEAL_REQUEST: 'playlist_reveal_request',\n STATE: 'playlist_state',\n VOTE_PROGRESS: 'playlist_vote_progress',\n RESULTS: 'playlist_results',\n /** Leader -> server: drop a voter from the revealed results. */\n VOTE_REMOVE: 'playlist_vote_remove',\n /** Server -> the removed voter: forget the ballot you hold in localStorage. */\n VOTES_CLEARED: 'playlist_votes_cleared',\n};\n// ---------------------------------------------------------------------------\n// Tierlist protocol — ONE GLOBAL BOARD\n// ---------------------------------------------------------------------------\n//\n// Not room state, and not a playlist. The tierlist is one board the whole site\n// shares: openable from anywhere, persisted in SQLite, and outliving every\n// room. An earlier version hung it off a loaded playlist, which meant it\n// vanished when the room changed media and two rooms could not see the same\n// votes.\n//\n// `voterId` on this wire is an OPAQUE token, never the friend key — that IS the\n// X-User-Key credential, and a board every viewer receives cannot carry it.\n/** One rankable entry. `id` doubles as the media path, so card art resolves by\n * the usual `<id>/poster.jpg` convention. */\nexport const TierlistItem = z.object({\n id: z.string(),\n displayName: z.string(),\n thumbnail: z.string().optional(),\n});\n/** `config/tierlist.json` — which entries are eligible this season. */\nexport const TierlistConfig = z.object({\n items: z.array(TierlistItem),\n});\n/** A board: the ordered item ids in each tier. Order within a row is part of\n * the ballot (it feeds the sub-rank), not presentation. */\nexport const TierlistBoard = z.partialRecord(Tier, z.array(z.string()));\n/** Client -> Server. Saving REPLACES the caller's whole board. */\nexport const TierlistSaveIn = z.object({ board: TierlistBoard });\n/** One entry on the shared board. Mirrors `PlacedClip` in tierlist.ts. */\nexport const TierlistPlacedOut = z.object({\n mediaId: z.string(),\n tier: Tier,\n absoluteTier: Tier,\n /** Tier level + within-row position. Integer part is the tier. */\n score: z.number(),\n /** The consensus mean that decided the tier. Never the displayed number. */\n placementScore: z.number(),\n subRankOffset: z.number(),\n votes: z.record(z.string(), Tier), // opaque voterId -> the letter they gave\n});\n/** The whole board as one client should see it: the shared result, plus that\n * caller's own arrangement. */\nexport const TierlistSnapshotOut = z.object({\n items: z.array(TierlistItem),\n placed: z.array(TierlistPlacedOut),\n unrated: z.array(z.string()),\n spread: z.number(),\n nudged: z.boolean(),\n names: z.record(z.string(), z.string()),\n sprites: z.record(z.string(), z.string()),\n /** Voter id -> avatar picture URL, for voters who have one. Optional so a\n * browser on a newer bundle still reads an older server's board. */\n pictures: z.record(z.string(), z.string()).optional(),\n voterCount: z.number().int().nonnegative(),\n myBoard: TierlistBoard,\n});\nexport const TIERLIST_EVENTS = {\n /** Broadcast to EVERY connected socket when anyone saves. Carries no board —\n * it is a \"re-fetch\" ping, because the shared result is identical for all\n * viewers but `myBoard` is not, and one payload cannot serve both. */\n UPDATED: 'tierlist_updated',\n};\n// Ready check — leader-triggered \"are you all ready to keep going?\" prompt.\n// Server tracks per-client ready state while a check is active. Cleared\n// automatically when the leader unpauses (control/play). No payload on\n// start/stop — sender role authorizes the action.\nexport const ReadyCheckStartIn = z.object({});\nexport const ReadyToggleIn = z.object({ ready: z.boolean() });\nexport const ReadyStateOut = z.object({\n active: z.boolean(),\n ready: z.array(z.string()), // clientIds who have flagged ready\n});\nexport const READY_EVENTS = {\n START: 'ready_check_start',\n TOGGLE: 'ready_check_toggle',\n STATE: 'ready_check_state', // distinct from buffer-readiness 'ready_state'\n};\n// ---------------------------------------------------------------------------\n// The room's game: a SELECTION. Every game is a bundle with its own server\n// (GAMES `module` entries), so the room only records which one is open.\n// ---------------------------------------------------------------------------\nexport const GAME_EVENTS = {\n SELECT: 'game_select', // leader -> server (open a game for the room; null closes it)\n ACTIVE: 'game_active', // server -> room (the open game id; null = none)\n SYNC: 'game_sync', // client -> server (which game is open? on mount/reconnect)\n};\n// `gameId: null` turns the active game off for the whole room.\nexport const GameSelectIn = z.object({\n gameId: z.string().min(1).max(32).nullable(),\n});\n// Broadcast when the open game changes, or sent to one socket on game_sync.\nexport const GameActiveOut = z.object({\n gameId: z.string().nullable(),\n});\n","// The playable characters: one table, read by the client, the server and —\n// through a contract test — the Bomberman game's Rust (`game/src/assets.rs`).\n//\n// Grouped by the show they come from. Each set is one page of the character\n// picker, so a set holds at most CHARACTER_PAGE_SIZE characters. `color` is the\n// character's chat/name colour, taken from their hair. Display names are NOT\n// here: they live in the deployment's git-ignored media/sprites/names.json.\n//\n// `rev` is the portrait's revision: bump it when the art in media/sprites/ is\n// redrawn in place. Sprite PNGs are served immutable for a year, so without a\n// new URL a browser that has seen the old image keeps showing it.\nexport const CHARACTER_SETS = [\n {\n id: 'fantasy',\n characters: [\n { id: 'sage', color: '#7f7c84' },\n { id: 'herald', color: '#bddaf9' },\n { id: 'cleric', color: '#78855e' },\n { id: 'knight', color: '#cfccc0' },\n { id: 'mage', color: '#794983' },\n { id: 'brawler', color: '#af4a33' },\n { id: 'wanderer', color: '#936f42' },\n { id: 'rogue', color: '#667240' },\n ],\n },\n {\n id: 'cyber',\n characters: [\n { id: 'gunner', color: '#b6a472' },\n { id: 'chief', color: '#a47e4d' },\n { id: 'walker', color: '#e0443a', rev: 2 },\n { id: 'operative', color: '#347cae' },\n ],\n },\n {\n id: 'stream',\n characters: [\n { id: 'idol', color: '#eb82bf' },\n { id: 'recluse', color: '#6d6880' },\n { id: 'phantom', color: '#a4a3ae' },\n { id: 'harlequin', color: '#e5d5c5' },\n { id: 'doll', color: '#a02633', rev: 2 },\n { id: 'imp', color: '#b398cb' },\n ],\n },\n {\n id: 'arcane',\n characters: [\n { id: 'apprentice', color: '#c6c15b' },\n { id: 'prodigy', color: '#5b4a6a' },\n { id: 'dreamer', color: '#97d0e4' },\n { id: 'sunny', color: '#ee989b' },\n { id: 'mentor', color: '#e1e0dc' },\n ],\n },\n];\nexport const CHARACTER_PAGE_SIZE = 8;\nconst ENTRIES = CHARACTER_SETS.flatMap((s) => s.characters);\n// Canonical character (sprite) order — every set, in set order. Used everywhere\n// a UI roster or server sprite-pool needs a deterministic iteration order, so\n// auto-assignment hands out the first set before any later one.\nexport const CHARACTER_ORDER = ENTRIES.map((c) => c.id);\nexport const CHARACTER_COLORS = Object.fromEntries(ENTRIES.map((c) => [c.id, c.color]));\n/** Portrait revision per character; 1 for art that has never been redrawn. */\nexport const CHARACTER_ART_REV = Object.fromEntries(ENTRIES.map((c) => [c.id, 'rev' in c ? c.rev : 1]));\n/**\n * A character's portrait, as a path under the watchparty server's root. A\n * redrawn portrait carries `?v=<rev>` so the year-long immutable cache cannot\n * keep serving the old art; untouched ones keep their bare URL and stay cached.\n * The UI prefixes its server origin; GET /api/characters hands it to everyone\n * else, so the rule is spelled once.\n */\nexport function portraitPath(id) {\n const rev = CHARACTER_ART_REV[id];\n return `/media/sprites/square_${id}.png${rev > 1 ? `?v=${rev}` : ''}`;\n}\n/** Narrow an arbitrary sprite string (it arrives off the wire) to a CharacterId. */\nexport function isCharacterId(sprite) {\n return !!sprite && CHARACTER_ORDER.includes(sprite);\n}\n","/**\n * Playback tuning constants shared across client, server, simulation, and tests.\n *\n * All buffer/fetch behavior should reference these values so there is exactly\n * one place to tweak when optimising for different network conditions.\n */\n// ─── Client fetch behavior ──────────────────────────────────────────────────\n/** Max parallel fragment fetches per viewer */\nexport const MAX_CONCURRENT_JOBS = 4;\n/** How far ahead of playhead (ms) to prefetch fragments */\nexport const BUFFER_AHEAD_MS = 30_000;\n/** Buffer required (ms) before playback starts or resumes after a stall */\nexport const INITIAL_BUFFER_MS = 5_000;\n// ─── Health status thresholds ────────────────────────────────────────────────\n/** Buffer below this (ms) → \"Critical\" status */\nexport const CRITICAL_BUFFER_MS = 2_000;\n/** Buffer below this (ms) → \"Low Buffer\" / warning status */\nexport const LOW_BUFFER_MS = 5_000;\n// ─── Stall + sync thresholds ────────────────────────────────────────────────\n/** Leader stall longer than this (ms) → leader auto-emits a pause control so viewers\n * don't drift out ahead of the still-buffering leader. See useStallRecovery. */\nexport const LEADER_STALL_PAUSE_THRESHOLD_MS = 2_000;\n/** Same gap-pattern must persist this long (ms) before logging an unfillable-gap warn,\n * to filter out transient append-queue churn. See fragmentManager._maybeWarnStableGaps. */\nexport const STABLE_GAP_DWELL_MS = 5_000;\n// ─── Prefetch + buffer-gate tuning ──────────────────────────────────────────\n/** Background prefetch tick interval (ms) for usePrefetch + audio mirror. */\nexport const DEFAULT_PREFETCH_INTERVAL_MS = 2_000;\n/** Maximum wall-clock time (ms) waiting for INITIAL_BUFFER_MS of buffer before\n * releasing initializePlayback. After this, playback starts with whatever's buffered. */\nexport const DEFAULT_BUFFER_GATE_TIMEOUT_MS = 10_000;\n/** Number of front-of-stream fragments included in the INIT-DIGEST telemetry event. */\nexport const INIT_DIGEST_TRACK_COUNT = 10;\n// ─── Fragment fetch retry/error behavior ────────────────────────────────────\n/** When fetchInit() fails, retry after this delay (ms). */\nexport const INIT_RETRY_DELAY_MS = 2_000;\n/** Consecutive fetchInit() failures before giving up on the current MediaSource\n * and signalling a fast recovery (reject append/buffer waiters → MediaPlayer\n * recreates the MediaSource) instead of looping forever every\n * INIT_RETRY_DELAY_MS. Without this cap a persistent init failure froze\n * playback for ~65s: the retry loop logged silently while initializePlayback\n * blocked on two 30s waitForAppend timeouts before recreating (cedar-ridge\n * 2026-06-25, DemonLord s01e04). At 3 × 2s this recovers in ~6s. */\nexport const MAX_INIT_FETCH_RETRIES = 3;\n/** After endOfStream() is called but a SourceBuffer is still updating, retry on\n * this interval (ms). See FragmentManager.tryEndOfStream. */\nexport const END_OF_STREAM_RETRY_INTERVAL_MS = 50;\n/** A fragment that has exhausted MAX_FETCH_ERROR_CYCLES rounds of retries is\n * declared permanently unservable. Reset attempts beyond this are skipped. */\nexport const MAX_FETCH_ERROR_CYCLES = 3;\n/** Fragment error states older than this (ms) are reset to pending so retries\n * can resume (provided cycle budget remains). See prefetchAround. */\nexport const ERROR_RESET_AGE_MS = 30_000;\n/** How long processAppendQueue may hold the SAME queue head behind a\n * lower-indexed pending fetch before declaring that fetch dead and breaking\n * the hold (ms).\n *\n * The sequential-append guard has no timeout of its own: it holds the whole\n * append queue while ANY pending fetch has a lower index than the head. That\n * is correct for a fetch that will land, and a permanent wedge for one that\n * will not. A fragment whose append is reported discarded goes back to\n * 'pending' and is re-fetched, re-taking the lower index — so a fragment that\n * keeps getting discarded holds every later append forever.\n *\n * That is the audio-side freeze from cedar-ridge 2026-08-25: audio appends\n * #556–562 were each reported discarded, the audio queue backed up to 15\n * items, and `waitForAppend` timed out on #569 after 30s while the playhead\n * sat pinned at the audio buffer's leading edge.\n *\n * 20s is deliberately longer than a slow fragment fetch under load (the\n * adaptive fetch timeout runs 30–120s, but a fetch that is actually\n * progressing re-enters the queue well inside this window) and well short of\n * the 30s `waitForAppend` timeout it exists to pre-empt. */\nexport const APPEND_HOLD_MAX_MS = 20_000;\n/** Buffer-ahead-ms header value sent on background pre-buffer fetches so the\n * server's priority queue treats them as lowest-priority and yields to live viewers. */\nexport const BACKGROUND_FETCH_PRIORITY_MS = 99_999;\n// ─── Time sync ──────────────────────────────────────────────────────────────\n/** Interval (ms) the client emits `time_ping` to derive its drift offset. */\nexport const DEFAULT_CLIENT_TIME_PING_INTERVAL_MS = 5_000;\n// ─── Output filenames ──────────────────────────────────────────────────────\n/** Filename used by ingest for the fragmented MP4 muxed output. Consumed by the\n * client to convert a media URL into the corresponding audio-base URL. */\nexport const FRAGMENT_MP4_FILENAME = 'output_frag.mp4';\n/** Subdirectory of the media output where per-language audio fragments live. */\nexport const AUDIO_DIR = 'audio';\n","/**\n * Fragment-delivery rollout flag — shared by server and client so both agree on what a given\n * setting means.\n *\n * Three modes:\n * off — everyone uses the Range path (the shipping default)\n * on — everyone uses the edge-cacheable fragment route, falling back to Range on failure\n * ab — a deterministic `percent` slice uses the fragment route; the rest stay on Range\n *\n * `ab` exists so the two delivery paths can be compared inside ONE party, on the same media, at\n * the same time. That matters because the numbers this feature is meant to move (TTFB, cache\n * hit rate, stalls) swing wildly with time of day, which media is loaded, and who is watching —\n * comparing two separate runs would mostly measure those.\n *\n * Assignment is a pure hash of the client id, so a viewer keeps its arm across reconnects\n * within a session and cannot flap between paths mid-episode.\n */\nexport const DEFAULT_FRAGMENT_DELIVERY_FLAG = { mode: 'off', percent: 0 };\nexport const FRAGMENT_DELIVERY_MODES = ['off', 'on', 'ab'];\nexport function isFragmentDeliveryMode(v) {\n return typeof v === 'string' && FRAGMENT_DELIVERY_MODES.includes(v);\n}\n/** Clamp/normalise anything (env string, JSON body, stored value) into a valid flag. */\nexport function normalizeFragmentDeliveryFlag(raw) {\n const o = (raw && typeof raw === 'object' ? raw : {});\n const mode = isFragmentDeliveryMode(o.mode) ? o.mode : DEFAULT_FRAGMENT_DELIVERY_FLAG.mode;\n const n = Number(o.percent);\n const percent = Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : 0;\n return { mode, percent };\n}\n/**\n * FNV-1a over the client id. Small, dependency-free, and — the actual requirement — identical\n * on both sides, so the arm the server reports in telemetry is the arm the client used.\n */\nfunction hashClientId(clientId) {\n let h = 0x811c9dc5;\n for (let i = 0; i < clientId.length; i++) {\n h ^= clientId.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h >>> 0;\n}\n/**\n * Which delivery path should this client use?\n *\n * `override` is the local escape hatch (`?fragmentDelivery=0|1`) and wins over the server flag\n * in BOTH directions — so a viewer hitting a bad fragment-route session can force themselves\n * back onto Range without waiting for an operator, and an operator can test the route on a\n * single client while the room-wide flag is off.\n */\nexport function resolveDeliveryArm(flag, clientId, override) {\n if (override === true)\n return 'fragment';\n if (override === false)\n return 'range';\n const f = normalizeFragmentDeliveryFlag(flag);\n if (f.mode === 'on')\n return 'fragment';\n if (f.mode === 'off')\n return 'range';\n // 'ab'. percent 0 => nobody, percent 100 => everybody; both must hold exactly, so compare\n // against `percent` with a strict `<` on a 0-99 bucket.\n if (f.percent <= 0)\n return 'range';\n if (f.percent >= 100)\n return 'fragment';\n if (!clientId)\n return 'range'; // no stable identity => never in the experiment arm\n return hashClientId(clientId) % 100 < f.percent ? 'fragment' : 'range';\n}\n","/**\n * User submissions — \"a user asked for this media\".\n *\n * The wire contract and the state machine, shared by client and server so the\n * two cannot hold different ideas about which moves are legal. Nothing here\n * fetches, uploads or ingests: wave 1 stores a request and its adjudication,\n * and the later waves attach the machinery that walks it forward.\n *\n * ── `ownerUserId` is a userId, NEVER a credential ──────────────────────────\n * Every row keys on the stable userId whoami returns, not on the raw\n * `X-User-Key`. Issue #144 is exactly the mistake of keying five stats tables\n * on the credential: a key rotation then orphans rows that still hold a\n * secret, and two aliases of one rotated user never collapse. Hashing the\n * credential does not fix it either — a key-derived id is still key-derived.\n * This store is being written after that lesson; do not reproduce it.\n *\n * ── The transition table is DATA, on purpose ───────────────────────────────\n * `SUBMISSION_TRANSITIONS` is the single rule every later wave consults. A\n * fetcher, an uploader, a moderation UI and an operator script will all move\n * submissions between states; if each one hand-writes \"is this move allowed\"\n * they will disagree, and the disagreement shows up as a submission wedged in\n * a state nothing advances. `canTransition` is the only predicate.\n */\nimport { z } from 'zod';\n/** How the bytes are expected to arrive. */\nexport const SubmissionKind = z.enum(['url', 'magnet', 'upload']);\n/**\n * Lifecycle. The happy path is\n * `pending → approved → fetching → ingesting → ready`; `rejected` and\n * `failed` are the two ways out of it.\n *\n * Moderation is approve-first (operator, 2026-09-24): nothing reaches the\n * catalog without passing through `approved`, so `pending` is not a formality\n * that a later wave may skip.\n */\nexport const SubmissionState = z.enum([\n 'pending', 'approved', 'fetching', 'ingesting', 'ready', 'rejected', 'failed',\n]);\n/**\n * Every legal move, keyed by the state you are leaving.\n *\n * Deliberate calls, so a later wave does not read an omission as an oversight:\n *\n * - **`ready` and `rejected` are terminal.** A request that has landed in the\n * catalog, or that an operator has refused, is finished. Wanting it again is\n * a new submission — which keeps the audit trail honest about who asked for\n * what, and when.\n * - **Nothing returns to `pending`.** `pending` means \"never adjudicated\". A\n * submission cannot become un-adjudicated, so a retry re-enters at\n * `approved` (the decision still stands), never at the top.\n * - **`fetching` and `ingesting` cannot be rejected.** Killing work in flight\n * needs cancellation semantics — a way to stop the yt-dlp child and clean up\n * a part-written file — and none exists yet. Adding `fetching → rejected`\n * without that leaves an orphaned process writing into a directory nobody\n * owns. Add the edge together with the killer that makes it true.\n * - **`failed` is recoverable, once approved.** A fetch that died on a network\n * blip should not cost the user their place in the queue.\n */\nexport const SUBMISSION_TRANSITIONS = {\n pending: ['approved', 'rejected'],\n approved: ['fetching', 'rejected', 'failed'],\n fetching: ['ingesting', 'failed'],\n ingesting: ['ready', 'failed'],\n ready: [],\n rejected: [],\n failed: ['approved'],\n};\n/** States nothing can leave. Derived, so it cannot drift from the table. */\nexport const SUBMISSION_TERMINAL_STATES = Object.keys(SUBMISSION_TRANSITIONS)\n .filter(s => SUBMISSION_TRANSITIONS[s].length === 0);\n/**\n * The one predicate. Total over `unknown` rather than over the enum, because\n * both ends read states back out of SQLite and off the wire, where a value\n * that is not in the enum at all is a reachable outcome — and must be `false`\n * rather than a throw or a crash inside a transaction.\n *\n * A self-transition is NOT legal. `from === to` is a write that changes\n * nothing; treating it as a move would let a caller \"advance\" a submission\n * and log a state change that never happened.\n */\nexport function canTransition(from, to) {\n const legal = SUBMISSION_TRANSITIONS[from];\n if (!legal)\n return false;\n return legal.includes(to);\n}\n/** One submission, as it is stored and as it goes on the wire. */\nexport const Submission = z.object({\n id: z.string().min(1),\n /** Stable userId from whoami. Never the raw `X-User-Key`. */\n ownerUserId: z.string().min(1),\n kind: SubmissionKind,\n /** The magnet URI, the https URL, or the filetransfer binding id. */\n source: z.string().min(1),\n title: z.string().optional(),\n /**\n * The anime series folder (PascalCase, e.g. `SparksOfTomorrow`) a `magnet`\n * submission belongs to — wave 4 (2026-10-01: \"magnets are in scope, and\n * user content is NOT restricted to the playlist tree\"). `undefined` for\n * `url`/`upload`, which always land in the playlist tree regardless of\n * title. The scraper derives its own destination from this same string, so\n * it is stored verbatim rather than normalized — a normalization that\n * disagreed with the scraper's own folder-name assumption would silently\n * misroute the copy.\n */\n series: z.string().optional(),\n state: SubmissionState,\n createdAt: z.number().int().nonnegative(),\n updatedAt: z.number().int().nonnegative(),\n /** Why it failed, or why an operator rejected it. */\n error: z.string().optional(),\n /** Size once known. 0 means \"known to be empty\"; absent means \"not yet known\" —\n * the same distinction `chatImages` draws for image dimensions. */\n bytes: z.number().int().nonnegative().optional(),\n});\n/** Longest `source` accepted at the boundary. A magnet with a long tracker\n * list is legitimately over 1KB; nothing real approaches this. */\nexport const SUBMISSION_SOURCE_MAX_CHARS = 4096;\n/** Longest caller-supplied title. */\nexport const SUBMISSION_TITLE_MAX_CHARS = 300;\n/** Longest caller-supplied series name. Comfortably over the longest real\n * series folder name in the library; bounded for the same reason title is —\n * it ends up as a filesystem path segment. */\nexport const SUBMISSION_SERIES_MAX_CHARS = 100;\n/** `POST /api/submissions` request body. */\nexport const SubmissionCreateIn = z.object({\n kind: SubmissionKind,\n source: z.string().min(1).max(SUBMISSION_SOURCE_MAX_CHARS),\n title: z.string().min(1).max(SUBMISSION_TITLE_MAX_CHARS).optional(),\n /** Required for `kind: 'magnet'` by `checkSource`; ignored for `url`/`upload`. */\n series: z.string().min(1).max(SUBMISSION_SERIES_MAX_CHARS).optional(),\n});\n/** `POST /api/submissions/:id/reject` request body — the reason is optional,\n * and lands in the submission's `error` so the requester can be told why. */\nexport const SubmissionRejectIn = z.object({\n reason: z.string().min(1).max(SUBMISSION_TITLE_MAX_CHARS).optional(),\n});\n/** List response shape, for `GET /api/submissions` and `.../mine`. */\nexport const SubmissionListOut = z.object({\n submissions: z.array(Submission),\n});\n// ── Caps (operator, 2026-09-24) ────────────────────────────────────────────\n// They live in shared rather than in the server's config so the submit UI can\n// show the same number the server enforces, instead of a second copy that\n// drifts.\n//\n// `DEFAULT_SUBMISSION_PENDING_BYTES_CAP` IS enforced — at submit, by\n// `POST /api/submissions`, which answers 429 with {@link SubmissionQuotaOut}.\n// The other two are still defaults only: the upload cap lands with the upload\n// path that can weigh a file, and retention with the prune that reads it.\n/** Total bytes one user may have submitted-but-not-yet-ready. 20 GB. */\nexport const DEFAULT_SUBMISSION_PENDING_BYTES_CAP = 20 * 1024 * 1024 * 1024;\n/** Largest single upload. 4 GB. */\nexport const DEFAULT_SUBMISSION_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;\n/** Days a fetched source is kept after a successful ingest, unless pinned. */\nexport const DEFAULT_SUBMISSION_SOURCE_RETENTION_DAYS = 14;\n/**\n * `429` body when a submit would exceed the per-user pending cap.\n *\n * **The numbers are in the body on purpose.** A bare 429 tells a friend \"no\"\n * and nothing else, so the only recovery is to guess which of their own\n * requests to abandon. With both figures the UI can say \"you are holding\n * 21.4 GB of a 20 GB allowance\" against the same values the server enforced,\n * rather than a second copy that drifts.\n *\n * ── What an unknown size contributes: nothing ──────────────────────────────\n * A `url` or `magnet` row has `bytes` absent until its fetch lands — the size\n * is genuinely unknown, and the schema says so (absent ≠ 0). The cap therefore\n * binds on MEASURED bytes. The alternative is to charge an unknown row some\n * assumed size, which is a number nobody measured: too small and the cap does\n * not bind, too large and a friend's second request is refused on the strength\n * of a guess about their first. The honest consequence is that many\n * not-yet-fetched requests pass the check and the cap starts biting as soon as\n * real sizes land — a *count* cap is the tool for the other half, and it\n * belongs with the fetch path that can measure.\n */\nexport const SubmissionQuotaOut = z.object({\n error: z.literal('submissions/quota_exceeded'),\n /** The caller's measured bytes in non-terminal submissions, right now. */\n pendingBytes: z.number().int().nonnegative(),\n /** The cap that was applied, from the server's config. */\n capBytes: z.number().int().nonnegative(),\n});\n","import { z } from 'zod';\n/**\n * The room's game list, as DATA.\n *\n * The leader's Play tab used to be a hardcoded array in `GamePicker.tsx`, so\n * every new game was a watchparty code change. Now the server reads the list\n * from the `GAMES` env var (JSON), serves it at `GET /api/games`, and the UI\n * renders and mounts whatever it says. The shape is `GameEntry` from\n * `@wolffm/rooms-protocol` — this file is only its runtime validation, and\n * `apps/ui/src/lib/gameCatalog.ts` checks the two agree at compile time.\n *\n * Validated at BOTH ends: the server so a bad env value is reported where the\n * operator set it, the browser because a module URL is about to be imported.\n */\n/** Same charset as a room-key segment; `game_select` carries it on the wire. */\nconst GAME_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;\n/** Any origin works: the check is that resolving against it cannot leave it. */\nconst PROBE_ORIGIN = 'https://game-catalog.invalid';\n/**\n * A root-relative path on the page's own origin, and nothing else.\n *\n * Absolute URLs are refused outright, not just cross-origin ones: the catalog\n * does not know the page's origin, so `https://hadoku.me/x` cannot be told\n * apart from `https://evil.example/x` here. `//host/x` (protocol-relative) and\n * `/\\host/x` (browsers read the backslash as a slash) both leave the origin\n * despite the leading `/`, which is why this resolves the path rather than\n * just checking its first character.\n */\nexport function isSameOriginPath(value) {\n if (!value.startsWith('/') || value.startsWith('//'))\n return false;\n if (/[\\\\\\s]/.test(value) || [...value].some((c) => c.charCodeAt(0) < 0x20 || c.charCodeAt(0) === 0x7f))\n return false;\n try {\n return new URL(value, PROBE_ORIGIN).origin === PROBE_ORIGIN;\n }\n catch {\n return false;\n }\n}\nconst SameOriginPath = z.string().min(2).max(512).refine(isSameOriginPath, {\n message: 'must be a root-relative path on the page origin (e.g. /cardtable/embed/x.js)',\n});\nconst GameEntryBase = {\n id: z.string().regex(GAME_ID_RE),\n name: z.string().min(1).max(64),\n cover: SameOriginPath.optional(),\n};\nexport const GameEntrySchema = z.object({ ...GameEntryBase, kind: z.literal('module'), url: SameOriginPath }).strict();\nexport const GameCatalogSchema = z.array(GameEntrySchema).max(32).refine((games) => new Set(games.map((g) => g.id)).size === games.length, { message: 'game ids must be unique' });\n/** What a deployment gets without `GAMES`: Bomberman, served by hadoku-bomberman\n * under its own edge mount. */\nexport const DEFAULT_GAME_CATALOG = [\n { id: 'bomberman', name: 'Bomberman', kind: 'module', url: '/bomberman/embed/bomberman-embed.js' },\n];\n/**\n * Parse the `GAMES` env value. Unset keeps the default list; set REPLACES it,\n * and `[]` turns games off. A malformed ENTRY is dropped, not coerced, and a\n * duplicate id keeps its first entry — the same fail-closed rule as\n * `EPHEMERAL_ROOM_NAMESPACES`. A value that is not a JSON array at all falls\n * back to the default rather than taking every game away over a typo.\n */\nexport function parseGameCatalog(raw) {\n if (raw === undefined)\n return { games: [...DEFAULT_GAME_CATALOG], problems: [] };\n let value;\n try {\n value = JSON.parse(raw);\n }\n catch {\n return { games: [...DEFAULT_GAME_CATALOG], problems: ['GAMES is not valid JSON; using the default list'] };\n }\n if (!Array.isArray(value)) {\n return { games: [...DEFAULT_GAME_CATALOG], problems: ['GAMES is not a JSON array; using the default list'] };\n }\n const games = [];\n const problems = [];\n for (const [i, item] of value.entries()) {\n const parsed = GameEntrySchema.safeParse(item);\n if (!parsed.success) {\n problems.push(`GAMES[${i}] dropped: ${parsed.error.issues.map((x) => `${x.path.join('.') || '(entry)'} ${x.message}`).join('; ')}`);\n continue;\n }\n if (games.some((g) => g.id === parsed.data.id)) {\n problems.push(`GAMES[${i}] dropped: duplicate id ${parsed.data.id}`);\n continue;\n }\n if (games.length >= 32) {\n problems.push(`GAMES[${i}] dropped: more than 32 games`);\n continue;\n }\n games.push(parsed.data);\n }\n return { games, problems };\n}\n","/**\n * Search in the Add media drawer — shows (AniList, TMDB) and YouTube videos.\n *\n * The wire contract only. The server owns every rule that decides what a\n * result MEANS — which names a release might carry, which folder a show lands\n * in, whether it is already on the scraper's watch list — so the client\n * renders what it is handed and sends back an id, never a name or a pattern.\n */\nimport { z } from 'zod';\nexport const ShowSource = z.enum(['anilist', 'tmdb']);\nexport const ShowResult = z.object({\n source: ShowSource,\n /** The provider's own id, as a string (AniList and TMDB both use integers). */\n id: z.string(),\n title: z.string(),\n year: z.number().int().nullable(),\n /** AniList `format` (TV, ONA, MOVIE…) or `TV` for TMDB. */\n format: z.string().nullable(),\n episodes: z.number().int().nullable(),\n /** Same-origin proxied poster (`/api/search/thumb?u=…`), or null. */\n poster: z.string().nullable(),\n /** The folder this show would land in — `SousouNoFrieren`. */\n series: z.string(),\n /** On the scraper's watch list already (by series). */\n onWatchlist: z.boolean(),\n /**\n * Not addable: a movie, or a title with no usable Latin-script name. The\n * scraper matches releases by `SxxExx`, so a film can never arrive through it.\n */\n addable: z.boolean(),\n});\nexport const ShowSearchOut = z.object({\n results: z.array(ShowResult),\n /** Providers that answered. TMDB is absent when no token is configured. */\n sources: z.array(ShowSource),\n});\nexport const VideoResult = z.object({\n id: z.string(),\n url: z.string(),\n title: z.string(),\n channel: z.string().nullable(),\n durationS: z.number().nullable(),\n thumb: z.string().nullable(),\n});\nexport const VideoSearchOut = z.object({ results: z.array(VideoResult) });\nexport const WatchlistAddIn = z.object({\n source: ShowSource,\n id: z.string().regex(/^\\d{1,12}$/),\n});\nexport const WatchlistAddOut = z.object({ series: z.string(), title: z.string() });\n/** Shortest query worth sending to a provider. */\nexport const SEARCH_MIN_QUERY = 2;\nexport const SEARCH_MAX_QUERY = 100;\n","/**\n * The app's handle on `@wolffm/cdn-client`.\n *\n * Transport — upload, URL composition, catalogue search — now lives in that\n * shared package (`hadoku_site/packages/cdn-client`), which every ecosystem app\n * integrates with. What stays here is the watchparty-specific SHIM: the\n * composer's attachment model, the wire schema, and the picker's markup. That\n * split is deliberate — `/tube/` comments want the same transport and nothing\n * like the same layout.\n *\n * The client is memoised per `cdnBase`, and `setRuntimeConfig` can legitimately\n * change `cdnBase` at mount time — so a once-only singleton would pin the wrong\n * origin, and a fresh object per call would throw away a shared instance that\n * non-React callers use too.\n *\n * It is NOT memoised to protect `useCatalogueSearch` any more. It used to be:\n * that hook keyed its catalogue on the client's IDENTITY, so a fresh object per\n * render re-ran the effect, which set state, which re-rendered — a permanent\n * loop, not the \"re-fetch per keystroke\" this comment used to claim. Measured\n * upstream at ~2 catalogue requests a second for as long as the picker stayed\n * mounted, with the component otherwise idle.\n *\n * cdn-client 1.0.3 fixes it at the source — the hooks key on the client's\n * CONFIGURATION and read it through a ref — so an unstable client is now merely\n * wasteful allocation rather than a request storm. The `^1.0.3` floor below is\n * what makes that a guarantee instead of a coincidence.\n */\nimport { createCdnClient, type CdnClient } from '@wolffm/cdn-client';\nimport { DEFAULT_CDN_BASE, isChatImageKey } from '@watchparty/shared';\n\nlet cached: { base: string; client: CdnClient } | null = null;\nlet cdnBase: string = DEFAULT_CDN_BASE;\n\nexport interface ChatMediaConfig {\n /** Public read base for CDN objects, e.g. `https://cdn.hadoku.me`. */\n cdnBase: string;\n}\n\n/** Point chat media at a CDN. Called from `setRuntimeConfig` at app boot. */\nexport function setChatMediaConfig(config: Partial<ChatMediaConfig>): void {\n if (config.cdnBase) cdnBase = config.cdnBase;\n}\n\n/** The configured base, trailing slash stripped. */\nexport function getChatMediaCdnBase(): string {\n return cdnBase.replace(/\\/+$/, '');\n}\n\n/** The shared client, stable for as long as `cdnBase` is. */\nexport function getCdnClient(): CdnClient {\n const base = getChatMediaCdnBase();\n if (!cached || cached.base !== base) {\n cached = { base, client: createCdnClient({ cdnBase: base }) };\n }\n return cached.client;\n}\n\n/**\n * Compose the render URL for a stored key, or `null` if the key is not one.\n *\n * **The null case is load-bearing and is NOT what `client.urlFor` does.**\n * `urlFor` concatenates unconditionally — it is a convenience that makes\n * storing keys the easy path, not a validator. A key reaches this app over the\n * wire from another client, so it is data: interpolating an unchecked one\n * would put attacker-controlled text into an `<img src>`.\n *\n * The check uses watchparty's `isChatImageKey`, not `client.isKey`, and the\n * difference matters. The package accepts any `[a-z0-9]{1,8}` extension;\n * `CHAT_IMAGE_KEY_RE` is an ALLOWLIST (png/jpg/jpeg/gif/webp/avif), so it also\n * refuses `.svg` — a scriptable document served same-site with the app. It is\n * the same predicate the Zod wire schema enforces, so render and protocol\n * cannot disagree.\n */\nexport function resolveChatImageUrl(key: string): string | null {\n if (!isChatImageKey(key)) return null;\n const base = getChatMediaCdnBase();\n if (!base) return null;\n return getCdnClient().urlFor(key);\n}\n","/**\n * The composer's attachment model, on top of `@wolffm/cdn-client`.\n *\n * The package owns TRANSPORT: `upload()`, `measureImage()`,\n * `filesFromClipboard()`/`filesFromDrop()`, and the size/type constants\n * mirrored from the worker. What stays here is what is watchparty's:\n *\n * - the pending-attachment model, which is a UNION because a meme pick is\n * already on the CDN and a pasted file is not;\n * - the user-facing wording for each failure;\n * - the guard on the key the CDN hands back.\n *\n * Everything is a pure function or one `await`, deliberately: the apps/ui\n * vitest project runs in the `node` environment and collects only `.ts`, so a\n * rule that lives in a component cannot be tested at all.\n */\nimport {\n CdnError,\n MAX_BYTES,\n filesFromClipboard,\n filesFromDrop,\n kindOfType,\n measureImage as measureImageBytes,\n type CdnErrorReason,\n} from '@wolffm/cdn-client';\nimport { isChatImageKey } from '@watchparty/shared';\nimport { getCdnClient } from './config';\n\n/**\n * An attachment sitting on the composer, not yet sent.\n *\n * Two kinds, and the distinction is load-bearing on three separate paths:\n *\n * - `file` — pasted or dropped. Needs uploading on submit, and `previewUrl`\n * is an object URL that MUST be revoked when it goes away.\n * - `remote` — picked from the meme library. Already content-addressed on the\n * CDN, so submitting it costs no network at all, and `previewUrl` is an\n * ordinary CDN URL that must NOT be revoked (harmless, but it would mean\n * the revoke path had stopped tracking what it owns).\n *\n * A discriminated union rather than optional fields, so neither path can\n * silently read the other's shape — `file` being `undefined` at the one point\n * that uploads is exactly the bug this prevents.\n */\ninterface PendingBase {\n /** Stable id for React keys and for removing one specific attachment. */\n id: string;\n /** What the composer renders right now — object URL or CDN URL by kind. */\n previewUrl: string;\n /** Intrinsic pixels, or 0 when unknown (renderers fall back to natural flow). */\n w: number;\n h: number;\n}\n\nexport interface PendingFileImage extends PendingBase {\n kind: 'file';\n file: File;\n}\n\nexport interface PendingRemoteImage extends PendingBase {\n kind: 'remote';\n /** Already on the CDN — this goes straight onto the message. */\n key: string;\n /** Library title, carried through to the rendered `alt`. */\n title?: string;\n}\n\nexport type PendingImage = PendingFileImage | PendingRemoteImage;\n\n/** True when this attachment owns an object URL that has to be revoked. */\nexport function ownsObjectUrl(p: PendingImage): boolean {\n return p.kind === 'file';\n}\n\n/**\n * A finished attachment, ready to go on the message.\n *\n * Structurally the wire `ChatImage`, kept as its own type because the package\n * emits keys and dimensions, not watchparty's protocol.\n */\nexport interface ChatMediaAttachment {\n key: string;\n w: number;\n h: number;\n title?: string;\n}\n\n/** Raised when the CDN accepted an upload but returned something unusable. */\nexport class ChatImageUploadError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChatImageUploadError';\n }\n}\n\n/**\n * User-facing wording for a `CdnError`.\n *\n * Branching on `reason` rather than a status is what the package's error type\n * is for. Two of these must stay distinct: `unauthenticated` is fixable by the\n * user and `forbidden` is not, so collapsing them shows a sign-in prompt that\n * changes nothing.\n */\nexport function uploadErrorMessage(reason: CdnErrorReason): string {\n switch (reason) {\n case 'unauthenticated': return 'Sign in to paste images';\n case 'forbidden': return \"Your account can't upload images\";\n case 'too-large': return 'Image too large (max 10 MB)';\n case 'unsupported-type': return \"That image type isn't supported\";\n case 'invalid': return \"That image couldn't be read\";\n case 'network': return 'Check your connection and try again';\n case 'unavailable': return 'Image upload is having trouble — try again';\n default: return 'Image upload failed';\n }\n}\n\n/** Turn any thrown value into something worth showing a user. */\nexport function messageForUploadFailure(err: unknown): string {\n if (err instanceof CdnError) return uploadErrorMessage(err.reason);\n if (err instanceof ChatImageUploadError) return err.message;\n return 'Image upload failed';\n}\n\n/**\n * Reject a file at PASTE time, before it ever reaches the composer.\n *\n * `client.upload()` pre-flights the same limits, but it does so at SEND — and\n * telling someone their 12 MB screenshot is too large only after they have\n * typed a message and hit Enter is the experience this avoids. The constants\n * come from the package, which parity-tests them against the worker's own\n * values, so this cannot drift into promising an upload the server refuses.\n *\n * A file with NO type passes: some clipboard and drag sources supply nothing,\n * and refusing those would drop valid images the server would have accepted.\n * The server sniffs the bytes and is the actual gate either way.\n *\n * IMAGE-only, deliberately: cdn-client 2.x also accepts audio, but chat renders\n * every upload in an <img>. 2.0 removed ACCEPTED_TYPES/MAX_UPLOAD_BYTES rather\n * than widen them for exactly this reason; `kindOfType` also resolves the\n * browser's alternate spellings of a type.\n */\nexport function preflightMessage(file: { size: number; type: string; name?: string }): string | null {\n const type = file.type.toLowerCase().split(';')[0].trim();\n if (type && kindOfType(type) !== 'image') {\n return uploadErrorMessage('unsupported-type');\n }\n if (file.size > MAX_BYTES.image) return uploadErrorMessage('too-large');\n if (file.size === 0) return uploadErrorMessage('invalid');\n return null;\n}\n\n/**\n * Pull image files out of a paste or a drop.\n *\n * Both delegate to the package, which reads `clipboardData.files` first and\n * falls back to walking `items` — not equivalent, since some browsers populate\n * only `items` for an image copied out of another page.\n */\nexport function imageFilesFromClipboard(dt: DataTransfer | null | undefined): File[] {\n return filesFromClipboard(dt);\n}\n\nexport function imageFilesFromDrop(dt: DataTransfer | null | undefined): File[] {\n return filesFromDrop(dt);\n}\n\n/**\n * Measure a file's intrinsic dimensions.\n *\n * The package returns `null` when the environment has no decoder; the wire\n * schema spells \"unknown\" as **0**, and the renderer falls back to natural\n * flow on it. Translating here keeps that single spelling everywhere.\n */\nexport async function measureImage(file: Blob): Promise<{ w: number; h: number }> {\n const dims = await measureImageBytes(file);\n return { w: dims?.width ?? 0, h: dims?.height ?? 0 };\n}\n\n/**\n * Upload one file and return the key to put on the message.\n *\n * The key is re-validated even though the CDN produced it. A malformed one\n * would be persisted into chat history permanently, and `urlFor` will happily\n * compose a URL for anything — so this is the last point at which a bad key\n * can be refused rather than stored.\n */\nexport async function uploadChatImage(\n file: File,\n opts: { signal?: AbortSignal } = {},\n): Promise<{ key: string; deduplicated: boolean }> {\n const result = await getCdnClient().upload(file, { signal: opts.signal });\n if (!isChatImageKey(result.key)) {\n throw new ChatImageUploadError('Image upload returned an unusable key');\n }\n return { key: result.key, deduplicated: result.deduplicated === true };\n}\n\n/**\n * Turn every pending attachment into a wire attachment, preserving order.\n * Throws on the first failure.\n *\n * A `remote` attachment (a meme pick) is passed straight through: its bytes\n * are already on the CDN under that key, so re-uploading them would spend\n * bandwidth to arrive at the identical content-addressed key. That is what\n * makes picking a meme feel instant while pasting a screenshot does not.\n */\nexport async function uploadAll(\n pending: readonly PendingImage[],\n opts: { signal?: AbortSignal } = {},\n): Promise<ChatMediaAttachment[]> {\n const out: ChatMediaAttachment[] = [];\n for (const p of pending) {\n if (p.kind === 'remote') {\n out.push({ key: p.key, w: p.w, h: p.h, ...(p.title ? { title: p.title } : {}) });\n continue;\n }\n const { key } = await uploadChatImage(p.file, opts);\n out.push({ key, w: p.w, h: p.h });\n }\n return out;\n}\n\n/** Log helper so callers keep one shape for upload failures. */\nexport function uploadErrorFields(err: unknown): Record<string, unknown> {\n return {\n error: err instanceof Error ? err.message : String(err),\n name: err instanceof Error ? err.name : typeof err,\n reason: err instanceof CdnError ? err.reason : undefined,\n status: err instanceof CdnError ? err.status : undefined,\n };\n}\n","/**\n * Where the meme picker's hover preview goes.\n *\n * Ported from dataplatform's `/tube/` comment picker\n * (`src/components/MediaPicker.tsx: placePreview`), which solved this first.\n * Keeping the geometry identical is deliberate — the two pickers read the same\n * catalogue and should not feel like different products — so if you change the\n * rules here, change them there too.\n *\n * The preview sits BESIDE the panel rather than over it, so the grid stays\n * readable and you can keep scanning while one item is expanded. Left is\n * preferred because the panel is right-anchored above the composer, so that is\n * where the room usually is; it flips right when it will not fit, and is\n * clamped into the viewport either way so a tile near an edge still gets a\n * whole preview rather than a sliced one.\n */\n\n/** Side of the enlarged preview box, in px. */\nexport const PREVIEW_PX = 240;\n\n/** Breathing room from the panel and the viewport edges. */\nexport const PREVIEW_GAP = 8;\n\n/**\n * Vertical slack for the caption row beneath the image.\n *\n * The box HUGS its image (`height: auto`), so its real height varies with the\n * meme's aspect. Placement clamps against the TALLEST box it could be, which\n * means a shorter one is simply clamped more conservatively — never pushed\n * off-screen. Getting this backwards (clamping against the actual height)\n * would let a tall image overflow the bottom edge.\n */\nexport const PREVIEW_CHROME_PX = 28;\n\nexport interface Rect {\n left: number;\n top: number;\n right: number;\n width: number;\n height: number;\n}\n\nexport interface Viewport {\n width: number;\n height: number;\n}\n\n/** Max height the image itself may paint at (mirrors the CSS `max-height`). */\nexport const PREVIEW_MAX_IMG_PX = 240;\n\nexport function placePreview(\n tile: Rect,\n panel: Rect,\n viewport: Viewport\n): { left: number; top: number } {\n let left = panel.left - PREVIEW_PX - PREVIEW_GAP;\n // No room on the left — put it on the panel's other side.\n if (left < PREVIEW_GAP) left = panel.right + PREVIEW_GAP;\n // Clamp AFTER the flip: on a narrow viewport neither side fits cleanly, and\n // a preview half off-screen is worse than one overlapping the panel.\n left = Math.min(left, viewport.width - PREVIEW_PX - PREVIEW_GAP);\n left = Math.max(PREVIEW_GAP, left);\n\n // Vertically centred on the tile, so the eye does not have to travel.\n const boxHeight = PREVIEW_MAX_IMG_PX + PREVIEW_CHROME_PX;\n let top = tile.top + tile.height / 2 - boxHeight / 2;\n top = Math.min(top, viewport.height - boxHeight - PREVIEW_GAP);\n top = Math.max(PREVIEW_GAP, top);\n\n return { left, top };\n}\n","import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useCatalogueSearch } from '@wolffm/cdn-client/react';\nimport type { CatalogueItem } from '@wolffm/cdn-client';\nimport { getCdnClient, resolveChatImageUrl } from '../config';\nimport {\n PREVIEW_PX,\n PREVIEW_MAX_IMG_PX,\n placePreview,\n} from '../previewPlacement';\n\n/**\n * The shared meme library, as a picker.\n *\n * This is the half that deliberately did NOT move into `@wolffm/cdn-client`:\n * the package ships headless hooks and no markup, because watchparty's chat\n * picker and a `/tube/` comment box want the same transport and nothing like\n * the same layout. `useCatalogueSearch` owns everything that is hard and\n * invisible when wrong — the 250ms debounce, cursor paging, and aborting a\n * superseded request so a slow \"ca\" cannot land after a fast \"cat\" — and this\n * file owns the grid.\n *\n * Styling is theme tokens (`bg-bg-card`, `text-text-secondary`) from\n * `@wolffm/themes`, which are ecosystem-wide rather than watchparty-local.\n */\n\n/**\n * Facts about the corpus that change how you write against it:\n *\n * **There are no GIFs.** dataplatform sniffed it by magic bytes: 690 JPEG, 189\n * PNG, 105 WebP, 266 MP4, and ZERO GIFs — short video was transcoded to animated\n * WebP. Call them **memes**, never GIFs. There is also no third-party provider\n * and no attribution owed; the library is ours.\n *\n * **Do NOT branch on `contentType`.** Animated and still items are mixed with no\n * flag, and the encoder is moving WebP -> animated AVIF. Treat any `image/*` as\n * renderable — there is no `<video>` path to build here.\n *\n * (255 long clips are simply not pickable — videos over 6s stayed on\n * dataplatform's tunnel. Nothing to work around.)\n */\n\n/** The catalogue this picker reads. */\nexport const MEME_CATALOGUE_PATH = '/dataplatform/api/memes';\n\nexport interface MemePickerProps {\n open: boolean;\n onClose: () => void;\n /** Called with the picked item. The host decides what that means. */\n onPick: (item: CatalogueItem) => void;\n /**\n * Fired when the catalogue answers 5xx. Hosts should hide the meme\n * affordance for the session — pasting an image has to keep working when the\n * library does not, so the two features never share a failure.\n */\n onUnavailable?: () => void;\n}\n\n/**\n * Hover-intent delay. Sweeping the pointer across a row of nine memes on the\n * way to the tenth should not strobe nine panels at you.\n */\nconst HOVER_INTENT_MS = 120;\n\ninterface Preview {\n item: CatalogueItem;\n left: number;\n top: number;\n}\n\n/**\n * The open panel. Mounted ONLY while the picker is open — see `MemePicker`.\n */\nfunction MemePickerPanel({ onClose, onPick, onUnavailable }: Omit<MemePickerProps, 'open'>) {\n const [query, setQuery] = useState('');\n const panelRef = useRef<HTMLDivElement | null>(null);\n const inputRef = useRef<HTMLInputElement | null>(null);\n const client = getCdnClient();\n\n // Trimmed so that adding a trailing space does not look like a NEW query to\n // the hook and refire an identical request. It is not a correctness fix:\n // dataplatform trims server-side (verified — 'warhammer', 'warhammer ' and\n // ' warhammer' return byte-identical result sets), and cdn-client trims as\n // of 1.0.2. This only avoids the redundant round trip.\n const { items, loading, error, hasMore, loadMore } = useCatalogueSearch(\n client,\n MEME_CATALOGUE_PATH,\n query.trim(),\n );\n\n // `@wolffm/cdn-client` >= 1.0.2 shares ONE `CdnError` across its entry\n // points, so a reason classified by the core survives the hook. In 1.0.0 it\n // did not — both entries inlined their own class, `instanceof` failed inside\n // the hooks, and every failure arrived as `unknown`. That is why the version\n // floor matters; do not relax it below 1.0.2 (1.0.1 still has the bug).\n const reason = error?.reason ?? null;\n\n /**\n * Dedup by key before rendering.\n *\n * Keyset pagination plus a content-addressed catalogue (one image filed\n * under several categories collapses to a single key) makes a repeat across\n * pages possible, and `useCatalogueSearch` appends pages verbatim. Two rows\n * with the same key is a duplicate React key — a rendering bug, not a\n * cosmetic one — so the grid dedups rather than trusting the feed.\n */\n const rows = useMemo(() => {\n const seen = new Set<string>();\n return items.filter((item) => {\n if (seen.has(item.key)) return false;\n seen.add(item.key);\n return true;\n });\n }, [items]);\n\n // ── hover-to-expand ────────────────────────────────────────────────────\n // Tiles are square and cropped with `object-fit: cover`, which is the wrong\n // shape for deciding WHICH meme this is — a wide one is showing you its\n // middle third. Resting on one expands it, uncropped.\n //\n // A `position: fixed` box, NOT a `transform: scale()` on the tile. That is\n // forced rather than stylistic: the grid scrolls (`overflow-y-auto`), which\n // clips a scaled child, so every tile in the first and last row would be\n // sliced by the container it lives in. Fixed positioning escapes the clip.\n // Same reasoning and same geometry as dataplatform's `/tube/` picker.\n const [preview, setPreview] = useState<Preview | null>(null);\n const hoverTimer = useRef<number | null>(null);\n\n const cancelHover = useCallback(() => {\n if (hoverTimer.current !== null) {\n window.clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n }, []);\n\n const hidePreview = useCallback(() => {\n cancelHover();\n setPreview(null);\n }, [cancelHover]);\n\n // Only where hovering is a real gesture. A touch screen synthesises a hover\n // on tap, so without this the preview flashes over the grid on the way to\n // selecting something.\n const canHover =\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(hover: hover)').matches;\n\n const showPreview = useCallback(\n (item: CatalogueItem, tile: HTMLElement, immediate = false) => {\n const panel = panelRef.current;\n if (!panel) return;\n const place = () =>\n setPreview({\n item,\n ...placePreview(tile.getBoundingClientRect(), panel.getBoundingClientRect(), {\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n });\n cancelHover();\n if (immediate) place();\n else hoverTimer.current = window.setTimeout(place, HOVER_INTENT_MS);\n },\n [cancelHover],\n );\n\n // A pending timer must not fire into an unmounted component.\n useEffect(() => () => cancelHover(), [cancelHover]);\n\n useEffect(() => {\n if (reason === 'unavailable') onUnavailable?.();\n }, [reason, onUnavailable]);\n\n useEffect(() => { inputRef.current?.focus(); }, []);\n\n // Escape closes, and a click outside closes. This component only exists\n // while open, so there is nothing to gate.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } };\n const onDown = (e: MouseEvent) => {\n if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();\n };\n document.addEventListener('keydown', onKey);\n document.addEventListener('mousedown', onDown);\n return () => {\n document.removeEventListener('keydown', onKey);\n document.removeEventListener('mousedown', onDown);\n };\n }, [onClose]);\n\n const pick = useCallback((item: CatalogueItem) => {\n onPick(item);\n onClose();\n }, [onPick, onClose]);\n\n const isEmpty = !loading && !error && rows.length === 0;\n\n return (\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-label=\"Meme library\"\n data-testid=\"meme-picker\"\n className=\"absolute bottom-full left-0 right-0 mb-2 z-30 flex max-h-80 flex-col rounded border border-border bg-bg-card shadow-lg\"\n >\n <div className=\"border-b border-border-light p-2\">\n <input\n ref={inputRef}\n type=\"text\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search memes...\"\n aria-label=\"Search memes\"\n data-testid=\"meme-search\"\n className=\"w-full rounded border border-border-light bg-bg-alt px-2 py-1 text-sm text-text placeholder:text-text-secondary focus:outline-none focus:border-primary\"\n />\n </div>\n\n <div\n className=\"flex-1 overflow-y-auto p-2\"\n /* onScroll must sit on the element that ACTUALLY scrolls — scroll does\n not bubble from it to the grid inside. Scrolling moves the tile the\n box was measured against, so the preview would hang beside nothing;\n dropping it is cheaper and more honest than recomputing per frame. */\n onScroll={hidePreview}\n /* Leaving the panel entirely (not just one tile) also drops it. */\n onMouseLeave={hidePreview}\n >\n {error && (\n <p\n className=\"m-2 p-2 text-center text-sm bg-danger-bg text-on-danger-bg rounded\"\n role=\"alert\"\n /* The reason drives both the copy and the host's decision to hide\n the affordance, so it is exposed for assertions — an outage and\n a permission failure look identical in the rendered text. */\n data-reason={reason}\n >\n {reason === 'unauthenticated'\n ? 'Sign in to browse memes'\n : reason === 'forbidden'\n ? \"Your account can't browse the meme library\"\n : 'Meme library is unavailable'}\n </p>\n )}\n\n {isEmpty && (\n <p className=\"p-4 text-center text-sm text-text-secondary\">\n {query.trim() ? `No memes match \"${query.trim()}\"` : 'The meme library is empty'}\n </p>\n )}\n\n {rows.length > 0 && (\n <div className=\"grid grid-cols-3 gap-1.5 sm:grid-cols-4\">\n {rows.map((item) => {\n // Compose from the KEY, never interpolate it. `null` means the\n // key failed the content-addressed shape check, so the row is\n // dropped rather than reaching an <img src>.\n const src = resolveChatImageUrl(item.key);\n if (!src) return null;\n return (\n <button\n key={item.key}\n type=\"button\"\n onClick={() => pick(item)}\n title={item.title}\n data-testid=\"meme-result\"\n onMouseEnter={(e) => {\n if (canHover) showPreview(item, e.currentTarget);\n }}\n onMouseLeave={hidePreview}\n /* Keyboard users get it too, and without the intent delay —\n arrowing onto a tile is already deliberate, so there is no\n sweep to protect against. */\n onFocus={(e) => showPreview(item, e.currentTarget, true)}\n onBlur={hidePreview}\n className=\"group relative aspect-square overflow-hidden rounded border border-border-light bg-bg-alt focus:outline-none focus:border-primary hover:border-primary\"\n >\n <img\n src={src}\n /* The title is frequently the ONLY text describing the\n image, and a meme-heavy chat log is unusable with a\n screen reader without it. */\n alt={item.title || 'Meme'}\n loading=\"lazy\"\n decoding=\"async\"\n className=\"h-full w-full object-cover\"\n />\n </button>\n );\n })}\n </div>\n )}\n\n {loading && (\n <p className=\"p-2 text-center text-xs text-text-secondary\">Loading…</p>\n )}\n\n {hasMore && !loading && (\n <button\n type=\"button\"\n onClick={loadMore}\n data-testid=\"meme-load-more\"\n className=\"mt-2 w-full rounded border border-border-light py-1 text-xs text-text-secondary hover:text-text hover:border-primary\"\n >\n Load more\n </button>\n )}\n </div>\n\n {preview && (\n <div\n data-testid=\"meme-preview\"\n className=\"fixed z-50 flex flex-col gap-1 rounded border border-border-light bg-bg-card p-1.5 shadow-2xl\"\n style={{\n left: preview.left,\n top: preview.top,\n width: PREVIEW_PX,\n /* The placement maths reserves exactly PREVIEW_PX of width.\n Without border-box the padding and border sit OUTSIDE that\n number and the box laps over the panel it is meant to sit\n beside. */\n boxSizing: 'border-box',\n /* Never eats the click it is describing. */\n pointerEvents: 'none',\n }}\n /* Decoration for a control that is already labelled: the tile it\n belongs to carries the same title as its `alt` and `title`, so a\n screen reader announcing this again would only echo. */\n aria-hidden=\"true\"\n >\n <img\n src={resolveChatImageUrl(preview.item.key) ?? undefined}\n alt=\"\"\n /* The box HUGS the image rather than being a fixed square. A wide\n meme in a tall box is mostly empty card, which reads as a\n loading state. */\n className=\"block w-full rounded bg-bg object-contain\"\n style={{ height: 'auto', maxHeight: PREVIEW_MAX_IMG_PX }}\n />\n {preview.item.title && (\n /* One line. A long title must not change the box height, because\n the box height is what the placement maths clamped against. */\n <span className=\"overflow-hidden text-ellipsis whitespace-nowrap text-center text-[10px] text-text-secondary\">\n {preview.item.title}\n </span>\n )}\n </div>\n )}\n </div>\n );\n}\n\n/**\n * Mount gate — the panel does not exist until the picker is opened.\n *\n * **This split is load-bearing, not organisation.** `useCatalogueSearch` fires\n * its first search on MOUNT (deliberately: an empty query returns the\n * catalogue's default set, so a picker opens populated). Hooks cannot be\n * called conditionally, so an `if (!open) return null` inside the panel sits\n * AFTER the hook and does not prevent it — the search still runs.\n *\n * With the panel rendered unconditionally, every page load fetched the meme\n * library for every viewer, whether or not they ever opened the picker: a\n * friend-gated request per load, and an error banner already showing the\n * moment someone first clicked. Keeping the mount itself behind `open` is what\n * makes \"opens populated\" cost nothing until it is opened.\n */\nexport default function MemePicker({ open, ...rest }: MemePickerProps) {\n if (!open) return null;\n return <MemePickerPanel {...rest} />;\n}\n","import { createContext, useContext } from 'react';\nimport { DEFAULT_HTTP_PORT, DEFAULT_CDN_BASE } from '@watchparty/shared';\nimport { setChatMediaConfig } from './chatMedia';\n\n/**\n * Where chat image bytes are read from in local dev.\n *\n * NOT a cosmetic difference. hadoku.me runs Cloudflare hotlink protection,\n * which answers a request carrying a `localhost` Referer with `403\n * text/plain`; Chrome surfaces that as `ERR_BLOCKED_BY_ORB` and the image\n * simply never appears. Its extension list (jpg/jpeg/png/gif/ico) predates\n * WebP, so `.webp` renders and everything else does not — a split that reads\n * like a bug in the app rather than an edge policy.\n *\n * `apps/ui/vite.config.ts` proxies this path to the CDN server-side with the\n * Referer stripped. Production keeps the real `cdnBase`, so no request path\n * differs in prod. Override either with `VITE_CDN_BASE`.\n */\nconst DEV_CDN_PROXY_BASE = '/_cdn';\n\nexport interface WatchpartyConfig {\n serverOrigin: string;\n defaultRoomKey: string;\n /** Public read base for chat image attachments (see `chatImages.ts`). Only\n * the RENDER side needs it — uploads go to the same-origin `/cdn/api/upload`\n * so the browser never handles a key. */\n cdnBase: string;\n}\n\nexport const DEFAULT_CONFIG: WatchpartyConfig = {\n serverOrigin: import.meta.env.VITE_SERVER_ORIGIN || `http://localhost:${DEFAULT_HTTP_PORT}`,\n defaultRoomKey: 'dev-room',\n cdnBase: import.meta.env.VITE_CDN_BASE || (import.meta.env.DEV ? DEV_CDN_PROXY_BASE : DEFAULT_CDN_BASE),\n};\n\n// Runtime config used by non-component code (socket, catalog, etc.)\nlet runtimeConfig: WatchpartyConfig = { ...DEFAULT_CONFIG };\n\nexport function setRuntimeConfig(config: Partial<WatchpartyConfig>) {\n // Filter out undefined values to preserve defaults\n const filtered = Object.fromEntries(\n Object.entries(config).filter(([_, v]) => v !== undefined)\n );\n runtimeConfig = { ...runtimeConfig, ...filtered };\n // The chat-media layer keeps its own copy so it can render a key without\n // reaching into this app's config — the property its extraction into\n // `@wolffm/chat-media` depends on. Mirrored here so a host that overrides\n // `cdnBase` (mount prop, VITE_CDN_BASE) configures both in one call.\n setChatMediaConfig({ cdnBase: runtimeConfig.cdnBase });\n}\n\nexport function getRuntimeConfig(): WatchpartyConfig {\n return runtimeConfig;\n}\n\n// React Context for component tree\nexport const ConfigContext = createContext<WatchpartyConfig>(DEFAULT_CONFIG);\n\nexport function useConfig(): WatchpartyConfig {\n return useContext(ConfigContext);\n}\n\n/** Returns the server origin with trailing slash stripped. */\nexport function getServerOrigin(config: WatchpartyConfig): string {\n return config.serverOrigin?.replace(/\\/$/, '') || '';\n}\n\n/** Returns the runtime config's server origin with trailing slash stripped. */\nexport function getRuntimeServerOrigin(): string {\n return getServerOrigin(getRuntimeConfig());\n}\n\n/** Returns the runtime config's CDN base with trailing slash stripped. */\nexport function getRuntimeCdnBase(): string {\n return getRuntimeConfig().cdnBase?.replace(/\\/$/, '') || '';\n}\n","import { createContext, useContext } from 'react';\n\n/**\n * Where a portal-rendered overlay (the chat image lightbox) mounts when there\n * is no fullscreen element to go into.\n *\n * `null` means `document.body` — right for the app, which owns the page. The\n * embeddable `RoomChrome` provides its own root instead: its stylesheet is\n * scoped under `.wp-room-chrome`, so an overlay appended to the host's <body>\n * would render unstyled, and it would be a node in the host's document that\n * the host never asked for.\n */\nexport const PortalTargetContext = createContext<HTMLElement | null>(null);\n\nexport function usePortalTarget(): HTMLElement | null {\n return useContext(PortalTargetContext);\n}\n","/**\n * Single source of truth for every localStorage key the UI reads or writes.\n *\n * - `ANON_KEY` — anonymous (public) user's preferences slot.\n * Authed users get `${ANON_KEY}:${userId}` (composed by `storageKey()`\n * in preferences.ts).\n * - `DEBUG_FLAG` — opt-in client log verbosity (see debug.ts).\n * - `REPORT_LOGS_FLAG` — opt-in to forward client logs to the server.\n * - `voteKey(owner, playlistId, mediaId)` — per-video vote in a playlist\n * session, scoped to WHO cast it (see `voteOwnerScope`). Unscoped, a\n * second person on the same browser inherited and re-submitted the first\n * person's ballot.\n * (see PlaylistPanel.tsx).\n *\n * This module is import-cycle-free on purpose: it has no other UI lib\n * dependencies so both debug.ts and preferences.ts can safely import it.\n */\nexport const STORAGE_KEYS = {\n ANON_KEY: 'watchparty_preferences',\n DEBUG_FLAG: 'wp:debug',\n REPORT_LOGS_FLAG: 'wp:reportLogs',\n voteKey: (owner: string, playlistId: string, mediaId: string) =>\n `watchparty_vote:${owner}:${playlistId}:${mediaId}`,\n} as const;\n","/**\n * localStorage access that cannot throw.\n *\n * Every call site in this app has to handle the same two facts: storage may be\n * unavailable (private mode, blocked site data, a sandboxed iframe — reading it\n * THROWS rather than returning null), and this bundle also runs where there is\n * no `window` at all. Twelve sites had hand-rolled that `try`/`catch`, each\n * with its own fallback spelling, and a missed one is an uncaught exception on\n * a path no test covers because the test environment always has storage.\n *\n * Absence and failure are deliberately NOT distinguished: no caller has ever\n * wanted to tell \"the user blocked storage\" apart from \"nothing stored yet\",\n * and a helper that returned a discriminated result would just get `?? null`'d\n * at every site.\n */\n\nfunction storage(): Storage | null {\n try {\n // `globalThis.localStorage`, NOT `window.localStorage` — they are the same\n // object in a browser, but the narrower spelling is wrong twice: it is\n // absent in a worker context, and the UI test suite runs in vitest's `node`\n // environment where specs stub the GLOBAL (`vi.stubGlobal('localStorage',\n // …)`). Requiring `window` made ten existing specs read every stub as\n // \"storage unavailable\".\n //\n // The property access itself throws in a sandboxed iframe, so it sits\n // inside the try alongside the read.\n return globalThis.localStorage ?? null;\n } catch {\n return null;\n }\n}\n\n/** Read a key. `null` when absent, unavailable, or the read threw. */\nexport function readStored(key: string): string | null {\n try {\n return storage()?.getItem(key) ?? null;\n } catch {\n return null;\n }\n}\n\n/** Write a key. Returns whether it landed — callers that care can fall back. */\nexport function writeStored(key: string, value: string): boolean {\n try {\n const s = storage();\n // NOT `storage()?.setItem(...); return true` — that optional chain\n // short-circuits when storage is absent and still reports success, which\n // is the exact lie this return value exists to prevent.\n if (!s) return false;\n s.setItem(key, value);\n return true;\n } catch {\n // Also covers a full quota, not just a blocked store.\n return false;\n }\n}\n\n/** Every key currently stored. Empty when storage is unavailable. */\nexport function storedKeys(): string[] {\n try {\n const s = storage();\n if (!s) return [];\n const keys: string[] = [];\n for (let i = 0; i < s.length; i++) {\n const k = s.key(i);\n if (k !== null) keys.push(k);\n }\n return keys;\n } catch {\n return [];\n }\n}\n\n/** Remove a key. Silent when storage is unavailable. */\nexport function removeStored(key: string): void {\n try {\n storage()?.removeItem(key);\n } catch { /* nothing to undo */ }\n}\n\n/**\n * Read a key that holds a boolean-ish flag. `null` when unset, so a caller can\n * tell \"explicitly false\" from \"never set\" — which is the distinction the\n * fragment-delivery override and the debug flag both depend on.\n */\nexport function readStoredFlag(key: string): boolean | null {\n const raw = readStored(key);\n if (raw === null) return null;\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n return null;\n}\n","/**\n * `window.__*` handles for devtools and E2E — registered everywhere, PUBLISHED\n * only when a host asks.\n *\n * Six modules used to assign their handle at import time (`__SOCKET__`,\n * `__ZUSTAND_STORE__`, `__wpDebug`, `__DISPLAY_NAMES__`, `__WP_PERF__`,\n * `__WP_SOUNDS__`). That is harmless when watchparty owns the whole page, and\n * wrong the moment it is a component on someone else's: importing\n * `@wolffm/watchparty-ui/room-chrome` would plant a live socket and the whole\n * store on the host's `window` without being asked.\n *\n * So a module REGISTERS its handle here, and nothing reaches `window` until\n * `exposeDebugGlobals()` is called. `mount()` — the whole-app entry, which\n * every E2E spec drives through these handles — calls it at boot, so the app\n * behaves exactly as before. `RoomChrome` calls it only for `debug`.\n *\n * A handle registered AFTER exposure is published immediately, so the order of\n * module evaluation and the call does not matter.\n */\n\nconst registry = new Map<string, unknown>();\nlet exposed = false;\n\nexport function registerDebugGlobal(name: string, value: unknown): void {\n registry.set(name, value);\n if (exposed) publish(name, value);\n}\n\nexport function exposeDebugGlobals(): void {\n exposed = true;\n for (const [name, value] of registry) publish(name, value);\n}\n\nfunction publish(name: string, value: unknown): void {\n if (typeof window === 'undefined') return;\n (window as unknown as Record<string, unknown>)[name] = value;\n}\n","/**\n * Debug logging utility for Watchparty UI\n *\n * Usage:\n * import { debug, warn, error } from './debug';\n *\n * debug('socket', 'Connected to server');\n * warn('chat', 'Message rate limited');\n * error('room', 'Failed to join room', err);\n *\n * Enable debug mode:\n * - Set localStorage.setItem('wp:debug', 'true')\n * - Or add ?debug=true to URL\n * - Or set window.__WP_DEBUG__ = true\n *\n * Filter by category:\n * - localStorage.setItem('wp:debug', 'socket,chat') - only show socket and chat logs\n *\n * Error reporting:\n * - All error() and warn() calls are automatically sent to the server\n * - Server endpoint: POST /api/client-log\n */\n\nimport { DEFAULT_UI_PORT, DEFAULT_HTTP_PORT } from '@watchparty/shared';\nimport { getRuntimeConfig } from './config';\nimport { STORAGE_KEYS } from './storageKeys';\nimport { readStored, writeStored, removeStored } from './browserStorage';\nimport { registerDebugGlobal } from './debugGlobals';\n\ndeclare global {\n interface Window {\n __WP_DEBUG__?: boolean;\n }\n}\n\ntype LogLevel = 'debug' | 'warn' | 'error';\n\ninterface LogEntry {\n level: LogLevel;\n category: string;\n message: string;\n data?: unknown;\n timestamp: number;\n}\n\n// Log history for debugging\nconst logHistory: LogEntry[] = [];\nconst MAX_HISTORY = 500;\n\n// Log queue for batching server reports\nlet logQueue: LogEntry[] = [];\nlet logFlushTimeout: ReturnType<typeof setTimeout> | null = null;\nconst LOG_FLUSH_DELAY = 1000; // Batch logs for 1 second\nconst LOG_FLUSH_DELAY_FAST = 200; // Faster flush when report-all is active\n\n// Room context for correlating client logs with server session logs\nlet currentRoomContext: { room_key: string; client_id: string } | null = null;\n\n/**\n * Set room context so client logs include room_key and client_id.\n * Call on join (with context) and on disconnect (with null).\n */\nexport function setLogRoomContext(ctx: { room_key: string; client_id: string } | null): void {\n currentRoomContext = ctx;\n}\n\n// Check if report-all mode is enabled (send all log levels to server)\nexport function isReportAllEnabled(): boolean {\n return readStored(STORAGE_KEYS.REPORT_LOGS_FLAG) === 'true';\n}\n\n// Check if debug mode is enabled\nexport function isDebugEnabled(): boolean {\n if (typeof window === 'undefined') return false;\n\n // Check window flag\n if (window.__WP_DEBUG__) return true;\n\n // Check URL param\n const params = new URLSearchParams(window.location.search);\n if (params.get('debug') === 'true') return true;\n\n // Check localStorage. NOT readStoredFlag: any value other than 'false'\n // enables debug, because the flag doubles as a comma-separated category\n // filter (see getAllowedCategories) — 'buffer,seek' means \"on, for those\".\n const stored = readStored(STORAGE_KEYS.DEBUG_FLAG);\n return stored !== null && stored !== 'false';\n}\n\n// Get allowed categories (empty = all)\nfunction getAllowedCategories(): Set<string> | null {\n const stored = readStored(STORAGE_KEYS.DEBUG_FLAG);\n if (!stored || stored === 'true') return null; // All categories\n\n // Parse comma-separated categories\n const categories = stored.split(',').map(c => c.trim().toLowerCase()).filter(Boolean);\n return categories.length > 0 ? new Set(categories) : null;\n}\n\n// Format timestamp\nfunction formatTime(): string {\n const now = new Date();\n return now.toLocaleTimeString('en-US', {\n hour12: false,\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n fractionalSecondDigits: 3\n });\n}\n\n// Add to history\nfunction addToHistory(entry: LogEntry) {\n logHistory.push(entry);\n if (logHistory.length > MAX_HISTORY) {\n logHistory.shift();\n }\n}\n\n// Style for console output\nconst styles = {\n debug: 'color: #64748b; font-weight: normal;',\n warn: 'color: #f59e0b; font-weight: bold;',\n error: 'color: #ef4444; font-weight: bold;',\n category: 'color: #3b82f6; font-weight: bold;',\n time: 'color: #6b7280; font-weight: normal;',\n};\n\n/** JSON.stringify with a fallback for circular or unserializable data. */\nfunction safeStringify(data: unknown): string {\n try { return JSON.stringify(data); } catch { return '[unserializable]'; }\n}\n\n/**\n * Internal shared log implementation.\n *\n * @param level - Log level (determines console style and history entry level)\n * @param consoleFn - The console function to call (console.log / console.warn / console.error)\n * @param alwaysReport - If true, always queue for server regardless of report-all mode\n * @param alwaysShow - If true, always print to console even when debug mode is off\n * @param category - Log category for filtering\n * @param message - Log message\n * @param data - Optional structured data\n */\nfunction _log(\n level: LogLevel,\n consoleFn: typeof console.log,\n alwaysReport: boolean,\n alwaysShow: boolean,\n category: string,\n message: string,\n data?: unknown,\n): void {\n const entry: LogEntry = {\n level,\n category,\n message,\n data,\n timestamp: Date.now(),\n };\n addToHistory(entry);\n\n if (alwaysReport) {\n queueLogForServer(entry);\n } else if (isReportAllEnabled()) {\n queueLogForServer(entry);\n }\n\n if (!alwaysShow) {\n if (!isDebugEnabled()) return;\n const allowed = getAllowedCategories();\n if (allowed && !allowed.has(category.toLowerCase())) return;\n }\n\n const time = formatTime();\n // Stringify data inline so Playwright msg.text() captures it\n // (msg.text() only returns the first console argument).\n const suffix = data !== undefined ? ' ' + safeStringify(data) : '';\n consoleFn(\n `%c[${time}]%c [${category}]%c ${message}${suffix}`,\n styles.time,\n styles.category,\n styles[level],\n );\n}\n\n/**\n * Debug log - only shows in debug mode\n */\nexport function debug(category: string, message: string, data?: unknown): void {\n _log('debug', console.log, false, false, category, message, data);\n}\n\n/**\n * Warning log - always reports to server, only shows in console in debug mode\n */\nexport function warn(category: string, message: string, data?: unknown): void {\n _log('warn', console.warn, true, false, category, message, data);\n}\n\n/**\n * Error log - always shows and reports to server\n */\nexport function error(category: string, message: string, data?: unknown): void {\n _log('error', console.error, true, true, category, message, data);\n}\n\n/**\n * Get log history (useful for debugging)\n */\nfunction getLogHistory(): LogEntry[] {\n return [...logHistory];\n}\n\n/**\n * Clear log history\n */\nfunction clearLogHistory(): void {\n logHistory.length = 0;\n}\n\n/**\n * Enable debug mode programmatically\n */\nfunction enableDebug(categories?: string[]): void {\n const value = categories && categories.length > 0 ? categories.join(',') : 'true';\n writeStored(STORAGE_KEYS.DEBUG_FLAG, value);\n console.log('[wp] Debug mode enabled');\n}\n\n/**\n * Disable debug mode\n */\nfunction disableDebug(): void {\n removeStored(STORAGE_KEYS.DEBUG_FLAG);\n console.log('[wp] Debug mode disabled');\n}\n\n// Max serialized size for a single log entry's data field (chars)\nconst MAX_DATA_SIZE = 1024;\n// Max payload size per request (stay well under server's 64kb limit)\nconst MAX_BATCH_SIZE = 50_000;\n\n/**\n * Truncate data field to prevent oversized payloads\n */\nfunction truncateData(data: unknown): unknown {\n if (data === undefined || data === null) return data;\n try {\n const json = JSON.stringify(data);\n if (json.length <= MAX_DATA_SIZE) return data;\n // For objects, try to keep keys but truncate values\n if (typeof data === 'object' && !Array.isArray(data)) {\n const truncated: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(data as Record<string, unknown>)) {\n const vStr = JSON.stringify(v);\n truncated[k] = vStr && vStr.length > 200 ? `[truncated ${vStr.length} chars]` : v;\n }\n return truncated;\n }\n return `[truncated ${json.length} chars]`;\n } catch {\n return '[unserializable]';\n }\n}\n\n/**\n * Synchronous best-effort log delivery for events that IMMEDIATELY precede a\n * page reload (window.location.reload). The normal async batch in\n * flushLogsToServer() is lost when the document tears down, so the reason a\n * client auto-reloaded never reached the server (observed 2026-06-16: 31\n * POST-AUTO-RELOAD with 0 trigger logs). navigator.sendBeacon is built for\n * exactly this — the browser guarantees delivery during unload. Also console.warns.\n */\nexport function beaconLog(level: string, category: string, message: string, data?: unknown): void {\n try { console.warn(`[${category}] ${message}`, data ?? ''); } catch { /* noop */ }\n try {\n if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '';\n if (origin === '/' || !origin) {\n if (typeof window !== 'undefined') {\n const loc = window.location;\n origin = (loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)\n ? `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`\n : loc.origin;\n }\n }\n const url = `${origin.replace(/\\/$/, '')}/api/client-log`;\n const body = JSON.stringify({ logs: [{\n level, category, message,\n data: truncateData(data),\n timestamp: Date.now(),\n url: typeof window !== 'undefined' ? window.location.href : undefined,\n userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,\n room_key: currentRoomContext?.room_key,\n client_id: currentRoomContext?.client_id,\n }] });\n navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));\n } catch { /* noop */ }\n}\n\n/**\n * Send queued logs to the server\n */\nasync function flushLogsToServer(): Promise<void> {\n if (logQueue.length === 0) return;\n\n const batch = logQueue;\n logQueue = [];\n logFlushTimeout = null;\n\n try {\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '';\n if (origin === '/' || !origin) {\n if (typeof window !== 'undefined') {\n const loc = window.location;\n if ((loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)) {\n origin = `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`;\n } else {\n origin = loc.origin;\n }\n }\n }\n\n const url = `${origin.replace(/\\/$/, '')}/api/client-log`;\n\n const prepared = batch.map(e => ({\n level: e.level,\n category: e.category,\n message: e.message,\n data: truncateData(e.data),\n timestamp: e.timestamp,\n url: typeof window !== 'undefined' ? window.location.href : undefined,\n userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,\n room_key: currentRoomContext?.room_key,\n client_id: currentRoomContext?.client_id,\n }));\n\n // Split into chunks that fit under the server limit\n let chunk: typeof prepared = [];\n let chunkSize = 0;\n for (const entry of prepared) {\n const entrySize = JSON.stringify(entry).length + 1; // +1 for comma\n if (chunkSize + entrySize > MAX_BATCH_SIZE && chunk.length > 0) {\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ logs: chunk }),\n });\n chunk = [];\n chunkSize = 0;\n }\n chunk.push(entry);\n chunkSize += entrySize;\n }\n if (chunk.length > 0) {\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ logs: chunk }),\n });\n }\n } catch (err: unknown) {\n console.warn('[wp] log flush failed (client logs may be lost):', errorFields(err).error);\n }\n}\n\n/**\n * Queue a log entry to be sent to the server\n */\nfunction queueLogForServer(entry: LogEntry): void {\n logQueue.push(entry);\n\n // Debounce the flush (faster in report-all mode)\n if (!logFlushTimeout) {\n const delay = isReportAllEnabled() ? LOG_FLUSH_DELAY_FAST : LOG_FLUSH_DELAY;\n logFlushTimeout = setTimeout(() => { void flushLogsToServer(); }, delay);\n }\n}\n\n/**\n * Narrow an unknown thrown value to the `{ error, name }` pair every log site\n * in this app reports.\n *\n * Catch sites used to annotate the variable `any` and reach into it directly\n * (`e?.message`), which turns off strict mode at precisely the point where the\n * value is least trustworthy — a thrown string or a rejected non-Error then\n * logs `undefined` instead of saying what happened.\n */\nexport function errorFields(err: unknown): { error: string; name: string } {\n return err instanceof Error\n ? { error: err.message, name: err.name }\n : { error: String(err), name: typeof err };\n}\n\n// Console access — published only when the host opts in (lib/debugGlobals.ts).\nregisterDebugGlobal('__wpDebug', {\n enable: enableDebug,\n disable: disableDebug,\n history: getLogHistory,\n clear: clearLogHistory,\n});\n","/**\n * Auth state passed in via mf-loader mount props.\n *\n * mf-loader (hadoku_site) resolves the HttpOnly `hadoku_session` cookie\n * through /session/whoami before our bundle loads and passes `userType` plus\n * `userId` — the key registry's userId, or null — as mount props. We stash\n * them in module scope so non-component code (socket setup, fetch wrappers)\n * can read identity without prop-drilling.\n *\n * There is NO session id here, on purpose (#151, hadoku_site#459). It is a\n * bearer credential for a 30-day sliding session, and about twenty bundles\n * share the hadoku.me origin, so a copy readable by page JavaScript is one\n * XSS away from being stolen. Same-origin requests carry the cookie by\n * default and edge-router resolves it exactly as it resolved `X-Session-Id`.\n * Do not read `props.sessionId` or `localStorage.hadoku_session_id` again.\n */\n\nimport { warn, errorFields } from './debug';\n\n// The full hadoku ladder. This mirrors the server's UserType; a tier missing\n// here is not a type error at the boundary (the value arrives as a string from\n// mount props) — it just makes every comparison against it fail silently.\ntype UserType = 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';\n\ntype AuthState = {\n /** False until the host's `setAuth` lands. Before it (the store's\n * module-init read) nobody knows who this is. */\n resolved: boolean;\n userType: UserType;\n /** Display name from /session/whoami; null until fetched (or for public users). */\n name: string | null;\n /** Registry userId: the host's `userId`, or whoami's. Null for anonymous\n * viewers and for a registry row that has none. */\n accountId: string | null;\n};\n\nconst _state: AuthState = {\n resolved: false,\n userType: 'public',\n name: null,\n accountId: null,\n};\n\n/**\n * The user id `@wolffm/prefs-client` builds its cache key from, mirroring its\n * `whoami` (`dist/index.js`, `whoami()`):\n * - account known -> the registry userId\n * - anonymous -> 'anon'\n * - before setAuth, or signed in with no account -> null: the SDK's key is\n * not knowable (for the latter it is `session:<id>`, which this bundle no\n * longer reads), and guessing is how a signed-in visitor gets seeded from\n * someone else's row (prod prefs-cross-user, 2026-10-05: the shared\n * `anon` row handed UserB UserA's name).\n */\nexport function getPrefsCacheUserId(): string | null {\n if (_state.accountId) return _state.accountId;\n if (!_state.resolved) return null;\n return _state.userType === 'public' ? 'anon' : null;\n}\n\nexport function setAuth(input: { userType?: UserType; userId?: string | null }) {\n _state.resolved = true;\n if (input.userType) _state.userType = input.userType;\n // Before hadoku_site 002584fe the prop was the SESSION id, or 'public' —\n // never an account. Only a registry userId names one.\n if (input.userId !== undefined) {\n _state.accountId = input.userId && input.userId !== 'public' ? input.userId : null;\n }\n}\n\nexport function getUserType(): UserType {\n return _state.userType;\n}\n\n// Rank ladder, LOW to HIGH — the browser-side mirror of TIER_RANK in\n// apps/server/src/middleware/whoami.ts. This bundle cannot import the server's\n// copy, so it must be kept complete: an unlisted tier gets rank -1 and every\n// affordance below is hidden from it.\nconst TIER_RANK: Record<UserType, number> = {\n public: 0,\n member: 1,\n friend: 2,\n service: 3,\n wife: 4,\n admin: 5,\n};\n\nfunction tierAtLeast(tier: UserType, minimum: UserType): boolean {\n return (TIER_RANK[tier] ?? -1) >= TIER_RANK[minimum];\n}\n\n/**\n * True when the user is service tier or above. Use this to gate UI affordances\n * tied to service-tier endpoints (room creation, leader URLs).\n *\n * Ranks, never equality. This read `=== 'service' || === 'admin'` until\n * 2026-08-04, which is the same exact-match allowlist that was fixed in the\n * SERVER's adminAuth at the same time — but this copy was missed, so the server\n * would have admitted a `wife` caller to those endpoints while the UI hid the\n * buttons that reach them. A UI-only version of the bug is still the bug.\n */\nexport function isPrivileged(): boolean {\n return tierAtLeast(_state.userType, 'service');\n}\n\n/** The registry userId, or 'public' when there is none. */\nexport function getUserId(): string {\n return _state.accountId ?? 'public';\n}\n\n/**\n * Whose browser-local copy of a ballot this is: the account when known, else\n * the shared anonymous scope, else `unscoped` for a signed-in viewer whose\n * account is not known — never another person's scope.\n */\nexport function voteOwnerScope(): string {\n return getPrefsCacheUserId() ?? 'unscoped';\n}\n\n/**\n * Fetch /session/whoami to populate `name` (display name from the friend-key\n * registry). The cookie is sent automatically; no headers needed. Idempotent\n * — safe to call multiple times. Returns the resolved name (or null).\n */\nexport async function loadWhoami(): Promise<string | null> {\n if (_state.userType === 'public') return null;\n if (_state.name) return _state.name;\n try {\n const res = await fetch('/session/whoami', {\n credentials: 'include',\n cache: 'no-store',\n });\n if (!res.ok) return null;\n const data = await res.json() as { valid?: boolean; name?: string | null; userId?: string | null };\n if (data.valid) {\n _state.accountId = typeof data.userId === 'string' && data.userId ? data.userId : null;\n }\n if (data.valid && typeof data.name === 'string') {\n _state.name = data.name;\n return data.name;\n }\n } catch (e: unknown) {\n warn('auth', 'whoami fetch failed', errorFields(e));\n }\n return null;\n}\n","import {\n CHARACTER_ORDER,\n CHARACTER_COLORS,\n isCharacterId,\n portraitPath,\n type CharacterId,\n} from '@watchparty/shared';\nimport { getRuntimeConfig } from './config';\nimport { debug, errorFields } from './debug';\nimport { registerDebugGlobal } from './debugGlobals';\n\n// Re-export so existing `from './characters'` imports keep working.\nexport { CHARACTER_ORDER, isCharacterId };\nexport type { CharacterId };\n\nexport function colorForSprite(sprite: string | null | undefined): string {\n return isCharacterId(sprite) ? CHARACTER_COLORS[sprite] : 'var(--color-neutral-bg)';\n}\n\n/**\n * The portrait URL for a sprite. Takes the raw value off the wire and answers\n * undefined for anything that is not a character, so a bogus sprite renders no\n * image instead of a 404. The path rule, cache-busting included, is\n * `portraitPath` in @watchparty/shared.\n */\nexport function spriteUrlFor(sprite: string | null | undefined): string | undefined {\n if (!isCharacterId(sprite)) return undefined;\n const origin = getRuntimeConfig().serverOrigin.replace(/\\/$/, '');\n return `${origin}${portraitPath(sprite)}`;\n}\n\n// --- Optional display name mapping (loaded from media/sprites/names.json) ---\n\nlet displayNames: Record<string, string> | null = null;\nlet loadPromise: Promise<void> | null = null;\n\n/** Fetch display names from server. No-op if already loaded or file doesn't exist. */\nexport function loadDisplayNames(): Promise<void> {\n if (displayNames) return Promise.resolve();\n if (loadPromise) return loadPromise;\n loadPromise = (async () => {\n try {\n const origin = getRuntimeConfig().serverOrigin.replace(/\\/$/, '');\n // no-cache, not force-cache: the server sent this file `immutable` for a\n // year until new characters were added, so a returning browser holds a\n // copy that predates them. Revalidating is what gets past it.\n const res = await fetch(`${origin}/media/sprites/names.json`, { cache: 'no-cache' });\n if (!res.ok) return; // File doesn't exist — use defaults\n const data = await res.json();\n displayNames = data;\n registerDebugGlobal('__DISPLAY_NAMES__', data);\n debug('characters', 'Loaded display names', data);\n } catch (e: unknown) {\n // names.json may not exist — degrade to sprite IDs.\n debug('characters', 'display names fetch failed (degrading to sprite IDs)', errorFields(e));\n }\n })();\n return loadPromise;\n}\n\n/** Get the display name for a sprite ID. Falls back to capitalized ID. */\nexport function displayNameFor(id: string): string {\n return displayNames?.[id] || id.charAt(0).toUpperCase() + id.slice(1);\n}\n\n/** Check if a name matches any display name or sprite ID (case-insensitive). */\nexport function isCharacterName(name: string): boolean {\n const lower = name.toLowerCase();\n if (CHARACTER_ORDER.some(c => c === lower)) return true;\n if (displayNames) {\n return Object.values(displayNames).some(dn => dn.toLowerCase() === lower);\n }\n return false;\n}\n","/**\n * Watchparty app-specific preferences client.\n *\n * Holds everything user-related that's NOT theme (theme lives in\n * themePrefs.ts under the platform-global 'portfolio' appId). Scope is\n * 'user' so the same identity sees the same settings across devices —\n * cross-device sync is the main reason for migrating off the old\n * watchparty_preferences localStorage + /api/me/prefs system.\n *\n * Bug pattern to avoid (per the migration brief): write-only\n * `void watchpartyPrefs.save(...)` without a corresponding usePrefs\n * subscription causes settings to revert on browser close. Either use\n * the React hook OR subscribe via `watchpartyPrefs.subscribe(...)` in\n * the boot path so the in-memory zustand store stays in sync with the\n * SDK's cache + server.\n */\nimport { z } from 'zod';\nimport { createPrefsClient } from '@wolffm/prefs-client';\n\nexport const WatchpartyPrefsSchema = z.object({\n // Identity. There is deliberately NO `name`: a person's name is their\n // hadoku key-registry name, resolved by the server from whoami and renamed\n // through hadoku_site's POST /session/name (operator decision 2026-10-08).\n // A watchparty-only copy disagreed with cardtable's and is not read.\n //\n // `sprite` is neither read nor written by watchparty any more: the\n // character is chosen in the fleet settings popout and read from prefs-api\n // (lib/avatar.ts). It stays in the schema because prefs-api still reads it:\n // it is the fallback character for a person who never chose one at the\n // top level (operator decision 2026-10-31).\n sprite: z.string().optional(),\n\n // Chat settings\n chatCollapsed: z.boolean().optional(),\n floatingChatEnabled: z.boolean().optional(),\n // RoomChrome's chat column (roomChrome/chatLayout.ts) — separate from the\n // app's chatCollapsed on purpose.\n chromeChatCollapsed: z.boolean().optional(),\n chromeChatWidth: z.number().optional(),\n\n // Media settings\n volume: z.number().min(0).max(1).optional(),\n muted: z.boolean().optional(),\n preferredAudioLang: z.string().optional(),\n subtitlesEnabled: z.boolean().optional(),\n subtitleStyle: z.string().optional(),\n\n});\n\nexport type WatchpartyPrefs = z.infer<typeof WatchpartyPrefsSchema>;\n\nexport const watchpartyPrefs = createPrefsClient({\n appId: 'watchparty',\n schema: WatchpartyPrefsSchema,\n});\n","/**\n * Compatibility adapter over @wolffm/prefs-client.\n *\n * Existing callers (store.ts, ChatPanel.tsx, entry.tsx, Room.tsx) keep\n * using `loadPreferences()` / `savePref()` / `syncFromServerIfFirstLoad()`.\n * Under the hood we route to two SDK clients:\n * - themePrefs (appId: 'portfolio', scope: 'device') — platform-global\n * - watchpartyPrefs (appId: 'watchparty', scope: 'user') — app-specific\n *\n * Why an adapter instead of rewriting every consumer per the brief: the\n * zustand store reads `loadPreferences()` synchronously at module\n * init (line 155 of store.ts), so flipping to the async `usePrefs()` hook\n * everywhere would mean restructuring store boot. Adapter keeps the API\n * sync and reads the SDK's localStorage cache directly for the\n * first-paint values; SDK-driven server refreshes then call into the\n * store's rehydrateFromPrefs() via subscribers wired up in\n * syncFromServerIfFirstLoad.\n *\n * Bug pattern to avoid (carried from the cross-ecosystem rollout):\n * write-only `void watchpartyPrefs.save(...)` without a usePrefs\n * subscription causes settings to revert on browser close. We satisfy\n * the subscription requirement via the `subscribe(...)` wiring at boot\n * — every change anywhere drives the store back into sync.\n */\n\nimport { debug, warn } from './debug';\nimport { getUserId, getPrefsCacheUserId } from './auth';\nimport { themePrefs, type ThemePrefs } from '@wolffm/themes';\nimport { watchpartyPrefs, type WatchpartyPrefs } from '../prefs/watchpartyPrefs';\nimport { readStored, removeStored, storedKeys, writeStored } from './browserStorage';\n\n// Keep the old type export so callers don't need re-imports.\ninterface UserPreferences extends ThemePrefs, WatchpartyPrefs {}\n\n// Keys routed to the platform-global theme client. Everything else lands\n// on the watchparty client.\nconst THEME_KEYS = new Set<string>(['theme', 'themeMode']);\ntype ThemeKey = keyof ThemePrefs;\n// `sprite` stays in the schema (prefs-api falls back to it for someone who\n// never chose a character at the top level) but watchparty no longer writes\n// it: the character is chosen in the fleet settings popout (lib/avatar.ts).\ntype WatchpartyKey = Exclude<keyof WatchpartyPrefs, 'sprite'>;\ntype PrefKey = ThemeKey | WatchpartyKey;\n\n/** SDK cache key, or null while the SDK's own key for this session is not yet\n * knowable. Mirrors @wolffm/prefs-client's construction\n * (`prefs-cache:<whoami userId>:<appId>`). It used to build the key from the\n * SESSION id, which the SDK never uses for a signed-in account — so the boot\n * read always missed and `writeThrough` wrote rows nothing read. See\n * `getPrefsCacheUserId`. */\nfunction sdkCacheKey(appId: string): string | null {\n const uid = getPrefsCacheUserId();\n return uid ? `prefs-cache:${uid}:${appId}` : null;\n}\n\n/**\n * A real user is coming, but we do not know which SDK cache row is theirs.\n *\n * Everything keyed on identity is untrustworthy in this window — the shared\n * `anon` row and the in-memory mirror can both hold the PREVIOUS user's\n * preferences. With the session→account record (`getPrefsCacheUserId`) this\n * window is normally only a signed-in visitor's FIRST boot in this browser.\n */\nfunction identityPendingUnresolved(): boolean {\n return getPrefsCacheUserId() === null;\n}\n\n/** Sync read of the SDK's localStorage cache for the current user.\n * Returns the merged blob if present; {} otherwise. Used by the zustand\n * store at module-init before the SDK has had a chance to async-init. */\nfunction readSdkCache(appId: string): Record<string, unknown> {\n // NEVER seed a signed-in visitor from the shared `anon` row.\n //\n // `sdkCacheKey` used to map an unresolved identity to `prefs-cache:anon:<app>`,\n // which every user of this browser shares and which keeps whatever the LAST\n // session left in it. At module-init the identity is unresolved (mf-loader\n // calls setAuth after importing this bundle), so a signed-in visitor was\n // seeded with the PREVIOUS user's name and sprite — and `rehydrateFromPrefs` only applies fields that\n // are PRESENT, so a user whose own prefs are empty never cleared them.\n // Reproduced 2026-08-10: UserB signs in with their own key and session and\n // shows UserA's identity indefinitely.\n //\n // Returning {} (no key) means such a visitor starts from defaults and gets\n // their real prefs when the pull lands, a beat later. A genuinely anonymous\n // visitor has no session mirror and still reads the anon row, so pre-auth\n // picks survive. A returning visitor reads their OWN row by account id.\n const key = sdkCacheKey(appId);\n if (!key) return {};\n const raw = readStored(key);\n if (!raw) return {};\n try {\n // The try still earns its place: JSON.parse throws on a corrupt cache\n // entry, which readStored cannot protect against.\n const parsed = JSON.parse(raw) as { blob?: Record<string, unknown> };\n return parsed?.blob ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Strip the retired `name` field from every watchparty prefs row this browser\n * holds. Returns how many rows it rewrote.\n *\n * A person's name is their key-registry name (2026-10-08); watchparty's own\n * `name` pref is gone and nothing reads it. hadoku_site sweeps the server rows;\n * these local copies are what that cannot reach — the SDK cache row of every\n * account that ever used this browser, the `anon` row (never synced at all),\n * and a pre-SDK `watchparty_preferences[:uid]` blob, which `loadPreferences`\n * still folds in. Only the one field goes; everything else in each row is left\n * exactly as it was. Idempotent: a row without `name` is not rewritten.\n */\nexport function stripRetiredNameFromCaches(): number {\n let rewritten = 0;\n for (const key of storedKeys()) {\n const sdkRow = key.startsWith('prefs-cache:') && key.endsWith(':watchparty');\n const legacyRow = key === LEGACY_PREFIX || (key.startsWith(`${LEGACY_PREFIX}:`) && !key.endsWith(':_serverSynced_v1'));\n if (!sdkRow && !legacyRow) continue;\n const raw = readStored(key);\n if (!raw) continue;\n let parsed: Record<string, unknown> | null;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown> | null;\n } catch {\n continue; // a corrupt row is its owner's to replace, not ours to guess at\n }\n // An SDK row is `{ blob, userVersion, ... }`; a legacy one is the blob itself.\n const blob = sdkRow ? (parsed?.blob as Record<string, unknown> | undefined) : parsed;\n if (!blob || typeof blob !== 'object' || !('name' in blob)) continue;\n const { name: _retired, ...rest } = blob;\n const next = sdkRow ? { ...parsed, blob: rest } : rest;\n if (writeStored(key, JSON.stringify(next))) rewritten++;\n }\n if (rewritten > 0) debug('preferences', 'Stripped retired name from cached prefs rows', { rewritten });\n return rewritten;\n}\n\n// IN-MEMORY MIRROR of the SDK's currentBlob. Updated by subscribe handlers\n// in syncFromServerIfFirstLoad. The 2026-05-27 cedar-ridge telemetry\n// showed `sdkAppBytesPost:0` IMMEDIATELY AFTER read() resolved with\n// data: the SDK's storage.set wrapper swallows quota / private-mode /\n// blocked-storage errors silently, so the localStorage cache stays\n// empty even though the SDK's in-memory currentBlob is populated. With\n// only-cache reads, every page load lost the data. These vars are the\n// sync-readable backstop: when localStorage write fails, subscribers\n// still capture the blob here, and loadPreferences picks it up. Means\n// even with completely broken localStorage, the store boots with the\n// user's real identity as soon as the first SDK fetch resolves.\nlet latestThemeBlob: ThemePrefs | null = null;\nlet latestAppBlob: WatchpartyPrefs | null = null;\n\n/** Synchronous read for store-init / rehydrate time.\n *\n * Legacy `watchparty_preferences[:userId]` is merged as a fallback ONCE\n * per userId per page session (tracked by `legacyMergedFor` below).\n * Subsequent reads for the same userId return SDK-only — because the\n * legacy blob can hold STALE values for fields the user has since\n * changed via the SDK in this session. Example bug if we kept merging:\n *\n * 1. Two settings change in a row → savePref('volume') +\n * savePref('subtitleStyle').\n * 2. save({volume}) fires its notify before save({subtitleStyle}) starts →\n * my subscribe handler rehydrates while SDK has ONLY {volume}.\n * 3. loadPreferences merges {...legacy{subtitleStyle:'old'}, ...sdk}\n * → returns subtitleStyle from legacy.\n * 4. rehydrateFromPrefs applies the old style — REVERTING the click the\n * user just made.\n *\n * Per-uid flag: module-init runs under userId='public' (pre-setAuth);\n * entry.tsx then calls rehydrateFromPrefs() post-setAuth which resolves\n * the real uid. Each gets its own one-shot legacy merge so the keyed\n * slot's data still flows on first authed read.\n *\n * Why the fallback exists at all: returning users hit this code with\n * EMPTY SDK caches (their data was on the old /api/me/prefs endpoint,\n * not the new hadoku.me/prefs prefs-store). Without it, the store boots\n * with defaults; ensureAutoIdentity in Room.tsx then picks Frieren+sage\n * off the unused-character list before the async migration completes,\n * and connectAndJoin ships the wrong identity (the 2026-05-26 cedar-\n * ridge regression). */\nconst legacyMergedFor = new Set<string>();\nexport function loadPreferences(): UserPreferences {\n // Read order (most → least authoritative for sync reads):\n // 1. SDK localStorage cache — written by SDK's writeCache after every\n // server read. Survives page reloads. Source of truth when working.\n // 2. In-memory subscriber state — backstop when storage.set silently\n // failed (quota/private mode/blocked). SDK still notified us with\n // currentBlob even though cache stayed empty.\n // 3. Legacy `watchparty_preferences[:userId]` — pre-SDK key, merged\n // ONCE per uid per page session. See legacyMergedFor below.\n // A session is coming but has not resolved: serve NOTHING, from any source.\n //\n // Guarding `readSdkCache` alone was not enough — verified against production,\n // the leak survived it. Source 2, the in-memory subscriber mirror, is fed by\n // the SDK's `subscribe`, and the SDK emits its ANON blob before it resolves\n // the user. So blanking the cache just moved the previous user's values one\n // fallback down: `Object.keys(cacheApp).length > 0 ? cacheApp : latestAppBlob`\n // handed back exactly the same data.\n //\n // Both sources are keyed on an identity we do not have yet, so neither can be\n // trusted to belong to THIS user. Defaults for a beat, then the real prefs.\n if (identityPendingUnresolved()) {\n debug('preferences', 'Boot read deferred — session pending, identity unresolved');\n return {};\n }\n\n const cacheTheme = readSdkCache('portfolio');\n const cacheApp = readSdkCache('watchparty');\n const themeBlob = Object.keys(cacheTheme).length > 0\n ? cacheTheme\n : ((latestThemeBlob as Record<string, unknown> | null) ?? {});\n const appBlob = Object.keys(cacheApp).length > 0\n ? cacheApp\n : ((latestAppBlob as Record<string, unknown> | null) ?? {});\n const uid = getUserId() || 'public';\n\n if (legacyMergedFor.has(uid)) {\n debug('preferences', 'Loaded (sync, SDK-only after first read)', {\n uid,\n sdkThemeKeys: Object.keys(themeBlob).length,\n sdkAppKeys: Object.keys(appBlob).length,\n // 'mem' indicates in-memory backstop fired (cache was empty,\n // we used latestBlob instead).\n themeSource: Object.keys(cacheTheme).length > 0 ? 'cache' : 'mem',\n appSource: Object.keys(cacheApp).length > 0 ? 'cache' : 'mem',\n });\n return { ...appBlob, ...themeBlob };\n }\n\n legacyMergedFor.add(uid);\n const legacy = readLegacy() ?? {};\n const merged = { ...legacy, ...appBlob, ...themeBlob } as UserPreferences;\n debug('preferences', 'Loaded (sync, first read — legacy + SDK)', {\n uid,\n sdkThemeKeys: Object.keys(themeBlob).length,\n sdkAppKeys: Object.keys(appBlob).length,\n legacyKeys: Object.keys(legacy).length,\n themeSource: Object.keys(cacheTheme).length > 0 ? 'cache' : 'mem',\n appSource: Object.keys(cacheApp).length > 0 ? 'cache' : 'mem',\n });\n return merged;\n}\n\n/** Save a single preference. Routes to the correct SDK client. Fire-and-\n * forget — the SDK debounces PUTs internally + we don't want callsites\n * to be async. `.catch(log)` per the cross-ecosystem bug pattern.\n *\n * Note: theme writes ALWAYS go to scope:'device' (per platform contract\n * — see hadoku_site themePrefs.ts). Everything else is scope:'user'\n * which is the SDK default. */\n/**\n * Did THIS page load write any preference while genuinely anonymous?\n *\n * Gates `promoteAnonPrefsToUser()` — see the call site in `syncFromServer` for\n * why promoting unconditionally leaks one user's identity to the next.\n */\nlet anonWritesThisLoad = false;\n\n/** True only while no real session identity has resolved yet. */\nfunction isAnonymousNow(): boolean {\n const uid = getUserId();\n return !uid || uid === 'public';\n}\n\nexport function savePref<K extends PrefKey>(key: K, value: UserPreferences[K]): void {\n if (isAnonymousNow()) anonWritesThisLoad = true;\n if (THEME_KEYS.has(key as string)) {\n void themePrefs\n .save({ [key]: value } as Partial<ThemePrefs>, { scope: 'device' })\n .catch(err => warn('preferences', 'themePrefs.save failed', { key, err: (err as Error)?.message }));\n } else {\n void watchpartyPrefs\n .save({ [key]: value } as Partial<WatchpartyPrefs>)\n .catch(err => warn('preferences', 'watchpartyPrefs.save failed', { key, err: (err as Error)?.message }));\n }\n debug('preferences', 'Saved (via SDK)', { key, value });\n}\n\n/** First-load server pull + subscriber wiring.\n *\n * The old implementation pulled from `/api/me/prefs` and gated with a\n * per-user \"_serverSynced_v1\" localStorage flag. The SDK handles the\n * pull itself (server-driven cache refresh on first `read()`); we just\n * need to:\n * 1. Trigger the read for both clients (so the server is consulted).\n * 2. Subscribe to ongoing updates and rehydrate the zustand store on\n * each change — drives cross-tab sync + late-server-resolve.\n * 3. Promote anon prefs to the user slot if applicable (auth landed\n * after the user already saved some anon settings).\n *\n * Returns true if the SDK reads succeeded. Errors are non-fatal; callers\n * should proceed to join regardless. In-flight dedup via the cached\n * promise so entry.tsx + Room.tsx can both call it without racing.\n */\nlet syncInFlight: Promise<boolean> | null = null;\nlet subscribedOnce = false;\nexport function syncFromServerIfFirstLoad(): Promise<boolean> {\n if (syncInFlight) return syncInFlight;\n syncInFlight = (async () => {\n if (typeof window === 'undefined') return false;\n try {\n // Subscribe FIRST so the read()'s initial cache notification +\n // any background refresh both flow into the store. Idempotent —\n // we only attach once per page lifetime.\n if (!subscribedOnce) {\n subscribedOnce = true;\n // Capture the blob into the in-memory mirror first, then trigger\n // a rehydrate. The mirror is loadPreferences's backstop for when\n // localStorage cache writes silently fail (the 2026-05-27 bug\n // where the SDK successfully fetched data but couldn't persist\n // it to localStorage, so every reload re-fetched async + lost\n // the race against ensureAutoIdentity → Frieren).\n //\n // ALSO write through to localStorage ourselves with explicit\n // error logging. If the SDK's storage.set silently failed, this\n // surfaces the cause + lets us see whether it's the SDK or the\n // browser. If our write succeeds, next page load gets a hot\n // cache and the in-memory backstop won't be needed.\n themePrefs.subscribe(blob => {\n latestThemeBlob = blob;\n writeThrough('portfolio', blob);\n rehydrate('theme');\n });\n watchpartyPrefs.subscribe(blob => {\n latestAppBlob = blob;\n writeThrough('watchparty', blob);\n rehydrate('watchparty');\n });\n }\n\n const [themeFromServer, appFromServer] = await Promise.all([\n themePrefs.read(),\n watchpartyPrefs.read(),\n ]);\n\n // CRITICAL: synchronously rehydrate the store BEFORE this promise\n // resolves. The subscribe-driven rehydrate path uses a dynamic\n // `import('./store')` (to break the store↔preferences module\n // cycle), which means notify → subscribe → rehydrate is ASYNC\n // — the import-promise resolves on a later microtask. Room.tsx\n // awaits syncFromServerIfFirstLoad and then reads\n // useAppStore.getState() immediately; without this inline\n // rehydrate, the store hasn't picked up the SDK data yet AT THAT\n // POINT, so ensureAutoIdentity sees empty name and assigns\n // Frieren+sage even though the SDK successfully fetched Hadoku\n // from the server (the 2026-05-26/27 cedar-ridge regression).\n // After this line: the store reflects the SDK's just-fetched\n // values, and Room.tsx's getState() will see them.\n try {\n // Static import would create a cycle (store imports preferences\n // at module top), but at the moment THIS function is invoked the\n // store module IS fully initialized — module-init has long since\n // completed. So we can safely require()-at-call-time without\n // tripping the cycle.\n const { useAppStore } = await import('./store');\n useAppStore.getState().rehydrateFromPrefs();\n } catch (err) {\n warn('preferences', 'inline rehydrate after SDK read failed', { err: (err as Error)?.message });\n }\n\n // Boot diagnostic: log what the SDK actually read from hadoku.me/prefs.\n // Lets us see whether the server has data for this user vs whether\n // the issue is purely client-side. 2026-05-26 added during cedar-ridge\n // regression investigation — pair with the entry.tsx boot beacon.\n //\n // Also re-check localStorage RIGHT NOW: the SDK's refreshFromServer\n // is supposed to writeCache after every fetch. If sdkAppBytes is 0\n // here despite a non-empty appFromServer, then writeCache is\n // silently failing (quota? private mode? some other code wiping?)\n // — that's the entire reason the boot beacon shows empty cache\n // even though the server clearly has data. This second snapshot\n // tells us whether the cache write is the problem.\n const themeKeyPost = sdkCacheKey('portfolio');\n const appKeyPost = sdkCacheKey('watchparty');\n const sdkThemePost = themeKeyPost ? readStored(themeKeyPost) : null;\n const sdkAppPost = appKeyPost ? readStored(appKeyPost) : null;\n // debug, not warn — same rationale as the boot beacon in entry.tsx:\n // closed-investigation instrumentation that fired on every boot.\n debug('preferences', 'sdk.read', {\n themeKeys: Object.keys(themeFromServer ?? {}),\n appKeys: Object.keys(appFromServer ?? {}),\n // Post-fetch cache state: should be non-zero if SDK wrote it.\n sdkThemeBytesPost: sdkThemePost?.length ?? 0,\n sdkAppBytesPost: sdkAppPost?.length ?? 0,\n });\n\n // One-shot migration: users with prefs only in the legacy\n // `watchparty_preferences[:userId]` localStorage key (from the pre-SDK\n // implementation) would otherwise see fresh defaults on the new code,\n // since the SDK fetches from hadoku.me/prefs which has nothing for\n // them yet. If the SDK is empty for this user AND the legacy key has\n // data, split it across the two clients and save. SDK save() does\n // API-Always-Wins reconciliation on 409, so cross-device conflicts\n // (another device already migrated under this user) resolve cleanly.\n await migrateLegacyIfNeeded(themeFromServer, appFromServer);\n\n // Surface pre-auth picks under the newly-known identity — but ONLY if\n // this page load actually made some.\n //\n // `promoteAnonPrefsToUser()` copies the SDK's `anon` row into the current\n // user's row. The anon row is shared by every user of the browser, and it\n // keeps whatever the LAST session left in it, so promoting on every boot\n // hands one user's preferences to the next one who signs in. Reproduced\n // 2026-08-10: UserA signs in and sets an identity; UserB then signs in\n // with their own key and session and sees UserA's name and sprite —\n // persistently, because B's own (empty) server prefs never overwrite it.\n // It stayed hidden because a user with existing server prefs has them\n // pulled over the promoted values a moment later.\n //\n // A genuine pre-auth pick can only exist if we WROTE one while anonymous\n // in this page's lifetime, which `savePref` records. A user who arrived\n // already authenticated has nothing to promote, and the anon row they can\n // see belongs to somebody else.\n if (anonWritesThisLoad) {\n debug('preferences', 'Promoting this load\\'s pre-auth picks to the signed-in user');\n await Promise.all([\n themePrefs.promoteAnonPrefsToUser().catch(() => {}),\n watchpartyPrefs.promoteAnonPrefsToUser().catch(() => {}),\n ]);\n } else {\n debug('preferences', 'Skipping anon promotion — no pre-auth writes this load');\n }\n\n debug('preferences', 'First-load SDK sync complete');\n return true;\n } catch (err) {\n warn('preferences', 'First-load SDK sync failed (non-fatal)', { err: (err as Error)?.message });\n return false;\n }\n })();\n return syncInFlight;\n}\n\n// ── Legacy localStorage migration ─────────────────────────────────────────\n//\n// Pre-SDK builds wrote everything to ONE localStorage key:\n// anon: `watchparty_preferences`\n// authed: `watchparty_preferences:{userId}`\n//\n// On first load with the SDK in place, the migration below splits that\n// blob across the two new SDK clients (themePrefs / watchpartyPrefs) and\n// deletes the legacy key. Only runs when the SDK is empty for the current\n// user — never overwrites real SDK data. Cross-device conflicts (another\n// device already migrated under this user) resolve via the SDK's\n// API-Always-Wins 409 reconciliation in save().\n\nconst LEGACY_PREFIX = 'watchparty_preferences';\n\n// Before anything reads a cached row (LEGACY_PREFIX must be initialised first).\nstripRetiredNameFromCaches();\n\nfunction legacyKey(): string {\n const uid = getUserId();\n return !uid || uid === 'public' ? LEGACY_PREFIX : `${LEGACY_PREFIX}:${uid}`;\n}\n\nfunction readLegacy(): Record<string, unknown> | null {\n const raw = readStored(legacyKey());\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n } catch {\n return null; // corrupt entry — JSON.parse is what throws here\n }\n}\n\nfunction deleteLegacy(): void {\n removeStored(legacyKey());\n // Also wipe the per-user \"_serverSynced_v1\" flag from the old\n // syncFromServerIfFirstLoad implementation — it has no meaning under the SDK\n // and would just be dead storage.\n removeStored(`${legacyKey()}:_serverSynced_v1`);\n}\n\nasync function migrateLegacyIfNeeded(\n themeFromServer: ThemePrefs,\n appFromServer: WatchpartyPrefs,\n): Promise<void> {\n const legacy = readLegacy();\n if (!legacy || Object.keys(legacy).length === 0) return;\n\n // Per-client \"is SDK populated for this user yet?\" — checked separately\n // so a user whose theme is already on the platform-global portfolio row\n // (e.g. set elsewhere in the hadoku ecosystem) still gets their app\n // prefs migrated. Previous all-or-nothing check skipped the entire\n // migration when EITHER client had any field set, which silently\n // stranded watchparty-specific prefs in legacy localStorage forever.\n const sdkHasTheme = Object.keys(themeFromServer ?? {}).length > 0;\n const sdkHasApp = Object.keys(appFromServer ?? {}).length > 0;\n\n // Field-by-field allowlist with type guards. Older clients may have\n // written stray pre-schema fields; they get dropped without tripping\n // Zod parse downstream.\n const themePatch: Partial<ThemePrefs> = {};\n const appPatch: Partial<WatchpartyPrefs> = {};\n for (const [k, v] of Object.entries(legacy)) {\n if (sdkHasTheme) {\n // Theme SDK row is authoritative; skip the theme half entirely.\n } else if (k === 'theme' && typeof v === 'string') {\n themePatch.theme = v;\n } else if (k === 'themeMode' && (v === 'simple' || v === 'advanced')) {\n themePatch.themeMode = v;\n }\n if (sdkHasApp) {\n // App SDK row is authoritative; skip the app half entirely.\n } else if (k === 'chatCollapsed' && typeof v === 'boolean') appPatch.chatCollapsed = v;\n else if (k === 'floatingChatEnabled' && typeof v === 'boolean') appPatch.floatingChatEnabled = v;\n else if (k === 'volume' && typeof v === 'number' && v >= 0 && v <= 1) appPatch.volume = v;\n else if (k === 'muted' && typeof v === 'boolean') appPatch.muted = v;\n else if (k === 'preferredAudioLang' && typeof v === 'string') appPatch.preferredAudioLang = v;\n else if (k === 'subtitlesEnabled' && typeof v === 'boolean') appPatch.subtitlesEnabled = v;\n else if (k === 'subtitleStyle' && typeof v === 'string') appPatch.subtitleStyle = v;\n }\n\n const themeKeys = Object.keys(themePatch);\n const appKeys = Object.keys(appPatch);\n\n if (themeKeys.length === 0 && appKeys.length === 0) {\n // Either both SDK rows already had data (legacy is now stale) OR the\n // legacy blob had no recognizable schema fields. Either way, drop it.\n deleteLegacy();\n return;\n }\n\n debug('preferences', 'Migrating legacy localStorage prefs to SDK', {\n themeKeys,\n appKeys,\n sdkHasTheme,\n sdkHasApp,\n });\n try {\n await Promise.all([\n themeKeys.length > 0 ? themePrefs.save(themePatch, { scope: 'device' }) : Promise.resolve(),\n appKeys.length > 0 ? watchpartyPrefs.save(appPatch) : Promise.resolve(),\n ]);\n deleteLegacy();\n debug('preferences', 'Legacy migration complete');\n } catch (err) {\n warn('preferences', 'Legacy migration save failed — will retry next load', {\n err: (err as Error)?.message,\n });\n // Don't deleteLegacy — leave it for the next first-load to try again.\n }\n}\n\n/** Mirror the SDK's currentBlob into localStorage ourselves. The SDK's\n * built-in storage.set wraps localStorage.setItem in a try/catch that\n * swallows errors silently — so the cache stayed empty even though the\n * SDK had data in memory (the 2026-05-27 sdkAppBytesPost:0 bug).\n *\n * Here we attempt the write + warn on failure with the actual exception.\n * If THIS write succeeds when the SDK's didn't, the SDK's silent-catch\n * is the bug. If THIS write also fails, localStorage is genuinely\n * broken in this browser (quota / private mode / blocked) and the\n * in-memory mirror is the only path forward. Either way the warn\n * text in server.jsonl tells us which.\n */\nlet writeFailureWarned = false;\n// Deliberately NOT lib/browserStorage's writeStored: that returns a boolean and\n// discards the exception, and the whole point of this function is to warn with\n// the actual error so server.jsonl can say WHICH failure happened.\nfunction writeThrough(appId: 'portfolio' | 'watchparty', blob: Record<string, unknown>): void {\n // Not knowable yet → nothing to write through TO. Writing a guessed row is\n // how the old sessionId-keyed shim piled up rows nothing ever read.\n const key = sdkCacheKey(appId);\n if (!key) return;\n try {\n const env = {\n blob,\n userVersion: 0,\n deviceVersion: 0,\n cachedAt: Date.now(),\n };\n localStorage.setItem(key, JSON.stringify(env));\n } catch (err) {\n if (!writeFailureWarned) {\n writeFailureWarned = true;\n warn('preferences', 'writeThrough localStorage failed (in-memory backstop only)', {\n appId,\n err: (err as Error)?.message,\n name: (err as Error)?.name,\n });\n }\n }\n}\n\n/** Lazy import + invoke rehydrateFromPrefs on the zustand store. Imported\n * inside the call to avoid the module-init cycle (store.ts imports from\n * here at top level). */\nfunction rehydrate(reason: string): void {\n void import('./store')\n .then(({ useAppStore }) => {\n try {\n useAppStore.getState().rehydrateFromPrefs();\n } catch (err) {\n warn('preferences', 'rehydrateFromPrefs threw', { reason, err: (err as Error)?.message });\n }\n })\n .catch(err => warn('preferences', 'store import failed', { reason, err: (err as Error)?.message }));\n}\n","/**\n * The chat column's geometry inside `RoomChrome`: collapsed or open, and how\n * wide when open. Pure, so the rules a host's layout depends on are tested\n * without a browser.\n *\n * Saved per viewer under their OWN pref keys (`chromeChatCollapsed`,\n * `chromeChatWidth`), not the app's `chatCollapsed`: a host's table and\n * watchparty's player want the chat differently, and sharing the key would\n * make hiding chat over a card game also hide it beside the next episode.\n */\n\n/** The column's width before the viewer has dragged it: the 20rem it always was. */\nexport const CHAT_WIDTH_DEFAULT_PX = 320;\n/** Narrower than this and the toolbar's buttons and the input stop fitting. */\nexport const CHAT_WIDTH_MIN_PX = 240;\nexport const CHAT_WIDTH_MAX_PX = 640;\n/** However wide the viewer drags, the host keeps at least this much. */\nexport const HOST_MIN_PX = 320;\n/** The collapsed column: just wide enough for the button that reopens it. */\nexport const COLLAPSED_RAIL_PX = 36;\n/** One arrow-key press on the resize handle. */\nexport const KEYBOARD_STEP_PX = 16;\n\n/**\n * A width the column may take. `frameWidth` (the whole chrome's width, when\n * known) caps it so the host always keeps `HOST_MIN_PX`; on a frame too narrow\n * for both, the chat's minimum wins, since a chat narrower than its toolbar is\n * broken while a squeezed host is merely squeezed. A saved value that is not a\n * finite number (a corrupt or foreign pref) is the default, never NaN px.\n */\nexport function clampChatWidth(px: number, frameWidth?: number): number {\n if (!Number.isFinite(px)) return CHAT_WIDTH_DEFAULT_PX;\n let max = CHAT_WIDTH_MAX_PX;\n if (frameWidth !== undefined && Number.isFinite(frameWidth) && frameWidth > 0) {\n max = Math.min(max, frameWidth - HOST_MIN_PX);\n }\n max = Math.max(max, CHAT_WIDTH_MIN_PX);\n return Math.round(Math.min(max, Math.max(CHAT_WIDTH_MIN_PX, px)));\n}\n\n/** The column sits on the RIGHT, so dragging its left edge leftward widens it. */\nexport function widthFromDrag(startWidth: number, startX: number, currentX: number, frameWidth?: number): number {\n return clampChatWidth(startWidth + (startX - currentX), frameWidth);\n}\n\n/** The handle's keyboard contract (a vertical `separator`): Left widens,\n * Right narrows, Home/End jump to the bounds. `null` for any other key, so\n * the caller leaves it alone. */\nexport function widthFromKey(width: number, key: string, frameWidth?: number): number | null {\n switch (key) {\n case 'ArrowLeft': return clampChatWidth(width + KEYBOARD_STEP_PX, frameWidth);\n case 'ArrowRight': return clampChatWidth(width - KEYBOARD_STEP_PX, frameWidth);\n case 'Home': return clampChatWidth(CHAT_WIDTH_MIN_PX, frameWidth);\n case 'End': return clampChatWidth(CHAT_WIDTH_MAX_PX, frameWidth);\n default: return null;\n }\n}\n\nexport interface ChatLayoutState {\n collapsed: boolean;\n /** The column's RENDERED width in CSS px — the rail's while collapsed —\n * i.e. how much of the frame the host does not have. */\n width: number;\n}\n\nexport function chatLayoutState(collapsed: boolean, width: number): ChatLayoutState {\n return { collapsed, width: collapsed ? COLLAPSED_RAIL_PX : width };\n}\n","import { create } from 'zustand';\nimport type { THealthSnapshot, TBufferStatus, TPlaylistConfig, TVoteValue, TPlaylistVoteProgressOut, TPlaylistResultsOut, TTier, TTierlistSnapshotOut } from '@watchparty/shared';\nimport type { TChatImage } from '@watchparty/shared';\nimport { DEFAULT_CHAT_UI_BUFFER } from '@watchparty/shared';\nimport { CHARACTER_ORDER, displayNameFor } from './characters';\nimport { debug, warn } from './debug';\nimport { loadPreferences, savePref } from './preferences';\nimport type { ProbedRoomKeyType } from './leaderAffordance';\nimport { registerDebugGlobal } from './debugGlobals';\nimport { clampChatWidth, CHAT_WIDTH_DEFAULT_PX } from '../roomChrome/chatLayout';\n\n// --- Phase 2 types ---\nexport interface RosterUser {\n clientId: string;\n /** Participant handle (see `PresenceUser.author`); absent from older servers. */\n author?: string;\n name: string;\n sprite: string | null;\n isLeader?: boolean;\n /** `name` is the person's key-registry name — see `PresenceUser.registry_name`. */\n registryName?: boolean;\n /** Their avatar picture (Discord, Steam, uploaded), shown INSTEAD of the\n * character portrait wherever the room shows who they are — never in a\n * game, which keeps the character. Absent = the character is the picture. */\n avatarUrl?: string;\n}\n\nexport interface SnapshotState {\n /** The room's id (not the key this client joined with); null before join. */\n roomId: string | null;\n mediaId: string | null;\n playheadMs: number;\n playing: boolean;\n serverSeq: number; // last authoritative seq applied\n leaderId: string | null;\n}\n\ninterface MeState {\n clientId: string | null;\n nextClientSeq: number; // local incrementing control seq (client scoped)\n}\n\ninterface DriftState {\n rtts: number[]; // last 5 RTT samples (ms)\n offsetMs: number; // estimated serverTime - clientTime\n}\n\nexport interface ChatMessage {\n id: string; // could be server seq or timestamp derived\n clientId: string;\n /** Sender's participant handle — survives their reconnect, unlike clientId. */\n author?: string;\n name: string;\n sprite: string | null;\n text: string;\n ts: number; // epoch ms\n isSystem?: boolean;\n systemType?: string; // 'join' | 'leave' | 'media_change'\n /** CDN image attachments. Content-addressed KEYS, never absolute URLs —\n * see `chatImages.ts` in @watchparty/shared for why that matters. */\n images?: TChatImage[];\n}\n\n// Media preferences state\ninterface MediaPrefsState {\n volume: number;\n muted: boolean;\n preferredAudioLang: string;\n subtitlesEnabled: boolean;\n subtitleStyle: string;\n}\n\n// Buffer state for UI feedback\nexport interface BufferState {\n isBuffering: boolean;\n bufferedEndMs: number;\n}\n\ninterface AppState {\n // existing identity\n ready: boolean;\n name: string;\n sprite: string | null;\n // networking + protocol state\n me: MeState;\n snapshot: SnapshotState;\n roster: Record<string, RosterUser>; // keyed by clientId\n chat: ChatMessage[];\n chatCollapsed: boolean;\n /** RoomChrome's own collapse + width (roomChrome/chatLayout.ts). */\n chromeChatCollapsed: boolean;\n chromeChatWidth: number;\n floatingChatEnabled: boolean;\n drift: DriftState;\n snapshotReceivedAt: number; // client timestamp when playheadMs was last updated from server\n loadGeneration: number; // bumped on every load broadcast to force seek effect re-evaluation\n readiness: { mediaId: string | null; ready: boolean; readyCount: number; total: number };\n bufferState: BufferState;\n toasts: { id: string; kind: 'error' | 'info'; msg: string; ts: number }[];\n // media preferences\n mediaPrefs: MediaPrefsState;\n // admin health monitor\n healthSnapshot: THealthSnapshot | null;\n sidebarTab: 'chat' | 'health' | 'playlist';\n // buffer loading overlay (all clients)\n bufferStatus: TBufferStatus | null;\n // Autoplay muted bridge: true when the browser blocked AUDIBLE autoplay so\n // we started muted; cleared when sound resumes on the first interaction.\n // Drives a \"tap for sound\" hint. Transient — never persisted.\n mutedForAutoplay: boolean;\n // playlist mode (Phase 4)\n playlist: TPlaylistConfig | null;\n activePlaylistIndex: number | null;\n myVotes: Record<string, TVoteValue>; // mediaId -> vote\n voteProgress: TPlaylistVoteProgressOut['progress']; // leader: per-client progress\n playlistResults: TPlaylistResultsOut | null; // set after reveal\n // The GLOBAL tierlist. Not room state and not a playlist: one board the\n // whole site shares, fetched over HTTP and openable from anywhere.\n tierlistOpen: boolean;\n tierlist: TTierlistSnapshotOut | null;\n /** The board being edited right now — starts as the saved one, diverges as\n * you drag, and is what Save sends. Kept apart from `tierlist.myBoard` so\n * an incoming refresh (someone else saved) cannot overwrite your edits. */\n tierlistDraft: Partial<Record<TTier, string[]>>;\n tierlistSaving: boolean;\n // Ready-check (PR-6): leader-triggered \"are you ready?\" prompt. Set of\n // clientIds that have flagged ready. Cleared automatically by the server\n // on the next leader unpause.\n readyCheck: { active: boolean; ready: string[] };\n // What a room key entitles this client to, from the /api/rooms/validate\n // probe, tagged with the key it answers for. Lets the presenter-only toolbar\n // buttons render before the socket join resolves leadership — see\n // lib/leaderAffordance.ts. null = no probe has answered yet (or it failed).\n roomKeyType: ProbedRoomKeyType | null;\n // Waiting-room game: leader-selected room-wide active game. null = no\n // game; non-null = every client's idle screen auto-mounts the iframe.\n activeGameId: string | null;\n // The open game's settings, for the \"App preferences\" section of the\n // settings popout: draws them into an element and returns the cleanup\n // (gameBundleHost's `mountSettings`). null when no open game offers any.\n // Set by the overlay GameBundleSlot only.\n gameSettings: ((el: HTMLElement) => (() => void) | null) | null;\n // actions\n setReady: () => void;\n /** This connection's identity. Never saved: the character is chosen in the\n * fleet settings popout and lives in prefs-api (lib/avatar.ts), the name is\n * the key registry's, and anything else is what the server or an automatic\n * retry handed this connection. */\n setSessionIdentity: (name: string, sprite: string | null) => void;\n ensureAutoIdentity: () => void;\n ensureMe: (clientId: string) => void;\n applySnapshot: (snap: Partial<SnapshotState> & { users?: RosterUser[] }) => void;\n upsertUser: (u: RosterUser) => void;\n removeUser: (clientId: string) => void;\n appendChat: (m: ChatMessage) => void;\n setChat: (messages: ChatMessage[]) => void;\n toggleChatCollapsed: () => void;\n toggleChromeChatCollapsed: () => void;\n setChromeChatWidth: (px: number) => void;\n toggleFloatingChat: () => void;\n allocateClientSeq: () => number;\n // leaderId is `| null` on purpose: null means \"no leader\", a real state the\n // server broadcasts. Typing it `string | undefined` made that unrepresentable\n // (surfaced when the UI was first typechecked, 2026-08-17).\n updateControlState: (delta: { playheadMs?: number; playing?: boolean; serverSeq?: number; leaderId?: string | null }) => void;\n bumpLoadGeneration: () => void;\n addRttSample: (rtt: number, serverTime?: number, clientSendTs?: number) => void;\n pushToast: (kind: 'error' | 'info', msg: string) => void;\n popToast: (id: string) => void;\n setReadiness: (r: { mediaId: string | null; ready: boolean; readyCount: number; total: number }) => void;\n setBufferState: (state: Partial<BufferState>) => void;\n setMutedForAutoplay: (v: boolean) => void;\n // media pref actions\n setVolume: (volume: number) => void;\n setMuted: (muted: boolean) => void;\n setPreferredAudioLang: (lang: string) => void;\n setSubtitlesEnabled: (enabled: boolean) => void;\n setSubtitleStyle: (style: string) => void;\n // admin health monitor actions\n setHealthSnapshot: (snapshot: THealthSnapshot) => void;\n setSidebarTab: (tab: 'chat' | 'health' | 'playlist') => void;\n setBufferStatus: (status: TBufferStatus) => void;\n // playlist actions\n applyPlaylistState: (state: { playlist: TPlaylistConfig | null; activeIndex: number | null; myVotes: Record<string, TVoteValue> }) => void;\n setVoteProgress: (progress: TPlaylistVoteProgressOut['progress']) => void;\n setPlaylistResults: (results: TPlaylistResultsOut | null) => void;\n setTierlistOpen: (open: boolean) => void;\n setTierlistSnapshot: (snap: TTierlistSnapshotOut, adoptDraft: boolean) => void;\n setTierlistSaving: (saving: boolean) => void;\n moveTierlistItem: (itemId: string, tier: TTier | null, index?: number) => void;\n // Ready-check actions\n setReadyCheckState: (state: { active: boolean; ready: string[] }) => void;\n // Waiting-room game action — set on inbound game_active.\n setActiveGameId: (gameId: string | null) => void;\n setGameSettings: (mount: ((el: HTMLElement) => (() => void) | null) | null) => void;\n setRoomKeyType: (probed: ProbedRoomKeyType) => void;\n // Prefs rehydrate — called by entry.tsx after setAuth() resolves the\n // userId. The eager loadPreferences() at module-init below runs with\n // userId='public' (auth hasn't been wired yet), so for authed users it\n // reads the anonymous slot — which is typically empty after the first\n // migration, leaving every reload starting from defaults. rehydrate\n // re-reads from the correct user-keyed slot once auth is known.\n rehydrateFromPrefs: () => void;\n}\n\n// Load saved preferences at startup\nconst savedPrefs = loadPreferences();\n\nexport const useAppStore = create<AppState>((set, get) => ({\n ready: false,\n name: '',\n sprite: null,\n me: { clientId: null, nextClientSeq: 1 },\n snapshot: { roomId: null, mediaId: null, playheadMs: 0, playing: false, serverSeq: 0, leaderId: null },\n roster: {},\n chat: [],\n chatCollapsed: savedPrefs.chatCollapsed ?? false,\n chromeChatCollapsed: savedPrefs.chromeChatCollapsed ?? false,\n chromeChatWidth: clampChatWidth(savedPrefs.chromeChatWidth ?? CHAT_WIDTH_DEFAULT_PX),\n // Hardcoded on for everyone — the per-user toggle was removed from the\n // control bar; FloatingChat + toggleFloatingChat code is kept for future\n // re-enablement but the value is no longer user-controllable.\n floatingChatEnabled: true,\n drift: { rtts: [], offsetMs: 0 },\n snapshotReceivedAt: Date.now(),\n loadGeneration: 0,\n readiness: { mediaId: null, ready: false, readyCount: 0, total: 0 },\n bufferState: { isBuffering: false, bufferedEndMs: 0 },\n toasts: [],\n mediaPrefs: {\n volume: savedPrefs.volume ?? 1,\n muted: savedPrefs.muted ?? false,\n preferredAudioLang: savedPrefs.preferredAudioLang || 'jpn',\n subtitlesEnabled: savedPrefs.subtitlesEnabled ?? true,\n subtitleStyle: (['classic', 'cinema', 'minimal', 'contrast'].includes(savedPrefs.subtitleStyle as string)\n ? savedPrefs.subtitleStyle\n : 'classic') as string,\n },\n healthSnapshot: null,\n sidebarTab: 'chat' as const,\n bufferStatus: null,\n mutedForAutoplay: false,\n playlist: null,\n activePlaylistIndex: null,\n myVotes: {},\n voteProgress: [],\n playlistResults: null,\n tierlistOpen: false,\n tierlist: null,\n tierlistDraft: {},\n tierlistSaving: false,\n readyCheck: { active: false, ready: [] },\n activeGameId: null,\n gameSettings: null,\n roomKeyType: null,\n setReady: () => {\n debug('store', 'App ready');\n set({ ready: true });\n },\n setSessionIdentity: (name, sprite) => {\n debug('store', 'Session identity set (not persisted)', { name, sprite });\n set({ name, sprite });\n },\n ensureAutoIdentity: () => set((s) => {\n if (s.name) return {};\n // A requested character (prefs-api, lib/avatar.ts) names the identity\n // rather than being overwritten by an auto-assignment.\n if (s.sprite) {\n const name = displayNameFor(s.sprite);\n debug('store', 'Auto-identity: deriving name from the requested sprite', { name, sprite: s.sprite });\n return { name };\n }\n // No requested sprite — pick the first available from the current roster.\n const usedSprites = new Set(Object.values(s.roster).map(r => r.sprite).filter(Boolean));\n debug('store', 'ensureAutoIdentity checking', {\n rosterSize: Object.keys(s.roster).length,\n usedSprites: Array.from(usedSprites)\n });\n for (const c of CHARACTER_ORDER) {\n if (!usedSprites.has(c)) {\n const name = displayNameFor(c);\n debug('store', 'Auto-identity assigned', { name, sprite: c });\n return { name, sprite: c };\n }\n }\n // All characters taken — leave identity unset; the server error handler\n // will retry with the next available once we get a sprite_taken response.\n warn('store', 'All characters taken, leaving identity unset');\n return {};\n }),\n ensureMe: (clientId) => {\n debug('store', 'Client ID set', { clientId });\n set((s) => ({ me: { ...s.me, clientId } }));\n },\n applySnapshot: (snap) => set((s) => {\n const rosterUpdates: Record<string, RosterUser> = { ...s.roster };\n if (snap.users) {\n for (const u of snap.users) rosterUpdates[u.clientId] = u;\n }\n // Use !== undefined (not ??) so that explicit null values (e.g. mediaId: null from 'home')\n // are applied correctly instead of falling back to the previous value\n const mediaId = snap.mediaId !== undefined ? snap.mediaId : s.snapshot.mediaId;\n const leaderId = snap.leaderId !== undefined ? snap.leaderId : s.snapshot.leaderId;\n debug('store', 'Snapshot applied', {\n mediaId,\n playing: snap.playing ?? s.snapshot.playing,\n serverSeq: snap.serverSeq ?? s.snapshot.serverSeq,\n userCount: snap.users?.length ?? 0\n });\n const newPlayheadMs = snap.playheadMs ?? s.snapshot.playheadMs;\n return {\n snapshot: {\n roomId: snap.roomId !== undefined ? snap.roomId : s.snapshot.roomId,\n mediaId,\n playheadMs: newPlayheadMs,\n playing: snap.playing ?? s.snapshot.playing,\n serverSeq: snap.serverSeq ?? s.snapshot.serverSeq,\n leaderId\n },\n roster: rosterUpdates,\n // Always update — initial snapshot on join often has playheadMs=0\n // matching the store default, so the old playheadChanged guard left\n // snapshotReceivedAt at page-load time. This inflated viewer correction\n // elapsed by browser launch + buffer gate duration, causing 60-115s drift.\n snapshotReceivedAt: Date.now(),\n };\n }),\n upsertUser: (u) => {\n debug('store', 'User upserted', { clientId: u.clientId, name: u.name, isLeader: u.isLeader });\n set((s) => ({ roster: { ...s.roster, [u.clientId]: u } }));\n },\n removeUser: (clientId) => {\n debug('store', 'User removed', { clientId });\n set((s) => {\n const copy = { ...s.roster }; delete copy[clientId]; return { roster: copy };\n });\n },\n appendChat: (m) => {\n debug('chat', 'Message appended', { id: m.id, from: m.name, isSystem: m.isSystem });\n set((s) => ({ chat: [...s.chat, m].slice(-DEFAULT_CHAT_UI_BUFFER) }));\n },\n setChat: (messages) => set({ chat: messages.slice(-DEFAULT_CHAT_UI_BUFFER) }),\n toggleChatCollapsed: () => set((s) => {\n const newVal = !s.chatCollapsed;\n savePref('chatCollapsed', newVal);\n return { chatCollapsed: newVal };\n }),\n toggleChromeChatCollapsed: () => set((s) => {\n const newVal = !s.chromeChatCollapsed;\n savePref('chromeChatCollapsed', newVal);\n return { chromeChatCollapsed: newVal };\n }),\n setChromeChatWidth: (px) => set(() => {\n const width = clampChatWidth(px);\n savePref('chromeChatWidth', width);\n return { chromeChatWidth: width };\n }),\n toggleFloatingChat: () => set((s) => {\n const newVal = !s.floatingChatEnabled;\n savePref('floatingChatEnabled', newVal);\n return { floatingChatEnabled: newVal };\n }),\n allocateClientSeq: () => {\n const n = get().me.nextClientSeq;\n set((s) => ({ me: { ...s.me, nextClientSeq: n + 1 } }));\n return n;\n },\n bumpLoadGeneration: () => set((s) => ({ loadGeneration: s.loadGeneration + 1 })),\n updateControlState: (delta) => {\n debug('store', 'updateControlState called', delta);\n set((s) => ({\n snapshot: { ...s.snapshot, ...delta },\n // Always update snapshotReceivedAt — any control_broadcast from the\n // server resets the elapsed-time calculation. The old condition\n // (playheadMs !== s.snapshot.playheadMs) missed media reloads that\n // set playheadMs=0 when it was already 0, causing prefetch to use\n // wall-clock-since-page-load as the expected position.\n snapshotReceivedAt: Date.now(),\n }));\n },\n addRttSample: (rtt, serverTime, clientSendTs) => set((s) => {\n const rtts = [...s.drift.rtts, rtt].slice(-5);\n let offsetMs = s.drift.offsetMs;\n if (serverTime && clientSendTs) {\n const clientMid = clientSendTs + rtt / 2;\n offsetMs = serverTime - clientMid;\n }\n return { drift: { rtts, offsetMs } };\n })\n ,\n pushToast: (kind, msg) => {\n debug('store', 'Toast pushed', { kind, msg });\n set((s) => ({ toasts: [...s.toasts, { id: Math.random().toString(36).slice(2), kind, msg, ts: Date.now() }].slice(-5) }));\n },\n popToast: (id) => set((s) => ({ toasts: s.toasts.filter(t => t.id !== id) })),\n setReadiness: (r) => set({ readiness: { mediaId: r.mediaId, ready: r.ready, readyCount: r.readyCount, total: r.total } }),\n setBufferState: (state) => set((s) => ({ bufferState: { ...s.bufferState, ...state } })),\n setMutedForAutoplay: (v) => set({ mutedForAutoplay: v }),\n // Media preference actions\n setVolume: (volume) => set((s) => {\n savePref('volume', volume);\n return { mediaPrefs: { ...s.mediaPrefs, volume } };\n }),\n setMuted: (muted) => set((s) => {\n savePref('muted', muted);\n return { mediaPrefs: { ...s.mediaPrefs, muted } };\n }),\n setPreferredAudioLang: (lang) => set((s) => {\n savePref('preferredAudioLang', lang);\n return { mediaPrefs: { ...s.mediaPrefs, preferredAudioLang: lang } };\n }),\n setSubtitlesEnabled: (enabled) => set((s) => {\n savePref('subtitlesEnabled', enabled);\n return { mediaPrefs: { ...s.mediaPrefs, subtitlesEnabled: enabled } };\n }),\n setSubtitleStyle: (style) => set((s) => {\n savePref('subtitleStyle', style);\n return { mediaPrefs: { ...s.mediaPrefs, subtitleStyle: style } };\n }),\n setHealthSnapshot: (snapshot) => set({ healthSnapshot: snapshot }),\n setBufferStatus: (status) => set({ bufferStatus: status }),\n applyPlaylistState: (state) => set({\n playlist: state.playlist,\n activePlaylistIndex: state.activeIndex,\n myVotes: state.myVotes,\n // Clear stale results when a new playlist state arrives\n playlistResults: null,\n }),\n setVoteProgress: (progress) => set({ voteProgress: progress }),\n setPlaylistResults: (results) => set({ playlistResults: results }),\n\n setTierlistOpen: (open) => set({ tierlistOpen: open }),\n setTierlistSaving: (saving) => set({ tierlistSaving: saving }),\n /**\n * Apply a server snapshot.\n *\n * `adoptDraft` is false for a refresh triggered by SOMEONE ELSE saving: the\n * shared result must update live, but replacing the draft would yank cards\n * out from under someone mid-drag. It is true only when this client opens the\n * board or saves its own, which are the two moments its draft is not ahead of\n * the server.\n */\n setTierlistSnapshot: (snap, adoptDraft) => set(prev => ({\n tierlist: snap,\n tierlistDraft: adoptDraft ? { ...snap.myBoard } : prev.tierlistDraft,\n })),\n /**\n * Move one item on the draft board. Local only — nothing reaches the server\n * until Save, which is the whole point of the Save button.\n *\n * Remove from every row FIRST, so a move within a row reorders rather than\n * duplicating, then insert at `index`. Absent from every row — not\n * present-with-a-low-value — is what keeps \"not rated\" distinguishable from\n * \"rated F\" in placeTierlist.\n */\n moveTierlistItem: (itemId, tier, index) => set(state => {\n const next: Partial<Record<TTier, string[]>> = {};\n for (const [t, row] of Object.entries(state.tierlistDraft)) {\n const without = (row ?? []).filter(id => id !== itemId);\n if (without.length > 0) next[t as TTier] = without;\n }\n if (tier !== null) {\n const row = next[tier] ?? [];\n const at = Math.max(0, Math.min(index ?? row.length, row.length));\n row.splice(at, 0, itemId);\n next[tier] = row;\n }\n return { tierlistDraft: next };\n }),\n setReadyCheckState: (state) => set({ readyCheck: state }),\n setActiveGameId: (gameId) => set({ activeGameId: gameId }),\n setGameSettings: (mount) => set({ gameSettings: mount }),\n setRoomKeyType: (probed) => set({ roomKeyType: probed }),\n rehydrateFromPrefs: () => set((s) => {\n // Re-read from localStorage. Triggered both from entry.tsx after\n // setAuth (boot path) AND from the SDK subscribe callback in\n // preferences.ts (when SDK cache changes — saves, cross-tab events,\n // background server refresh).\n //\n // ONLY write fields that are PRESENT in prefs. Older logic always\n // wrote all fields and used `|| default` for absent ones — that\n // reverted in-flight user actions any time the SDK had partial data.\n // With the present-only check, fields absent from prefs simply leave the\n // store value alone.\n //\n // The prefs row's `sprite` is NOT read: the character comes from\n // prefs-api's avatar settings (lib/avatar.ts), which already fall back to\n // that field for someone who never chose at the top level.\n const prefs = loadPreferences();\n const next: Partial<AppState> = {};\n if (prefs.chatCollapsed !== undefined) next.chatCollapsed = prefs.chatCollapsed;\n if (prefs.chromeChatCollapsed !== undefined) next.chromeChatCollapsed = prefs.chromeChatCollapsed;\n if (prefs.chromeChatWidth !== undefined) next.chromeChatWidth = clampChatWidth(prefs.chromeChatWidth);\n // floatingChatEnabled is hardcoded on (toggle removed from UI); never rehydrate.\n\n // Media prefs: only build the nested object if AT LEAST ONE media key\n // is present in prefs — otherwise we'd write a whole mediaPrefs object\n // with defaults, undoing in-memory tweaks the user made without saving.\n const mediaKeys = ['volume', 'muted', 'preferredAudioLang', 'subtitlesEnabled', 'subtitleStyle'] as const;\n const hasMedia = mediaKeys.some(k => prefs[k] !== undefined);\n if (hasMedia) {\n const m = { ...s.mediaPrefs };\n if (prefs.volume !== undefined) m.volume = prefs.volume;\n if (prefs.muted !== undefined) m.muted = prefs.muted;\n if (prefs.preferredAudioLang !== undefined) m.preferredAudioLang = prefs.preferredAudioLang;\n if (prefs.subtitlesEnabled !== undefined) m.subtitlesEnabled = prefs.subtitlesEnabled;\n if (prefs.subtitleStyle !== undefined && ['classic', 'cinema', 'minimal', 'contrast'].includes(prefs.subtitleStyle)) {\n m.subtitleStyle = prefs.subtitleStyle;\n }\n next.mediaPrefs = m;\n }\n return next;\n }),\n setSidebarTab: (tab) => set((s) => {\n // If switching to the same tab, toggle back to chat\n // Also uncollapse the sidebar if it's collapsed\n if (s.sidebarTab === tab && tab !== 'chat') {\n return { sidebarTab: 'chat' };\n }\n return { sidebarTab: tab, chatCollapsed: false };\n }),\n}));\n\n// E2E handle — published only when the host opts in (lib/debugGlobals.ts).\nregisterDebugGlobal('__ZUSTAND_STORE__', useAppStore);\n","import { FRAGMENT_MP4_FILENAME } from '@watchparty/shared';\nimport { getRuntimeServerOrigin } from './config';\n\n/** Derive the fMP4 and index URLs for a given mediaId. Single source of truth used by Room and usePlaylistPreBuffer. */\nexport function resolveMediaUrls(mediaId: string): { mediaUrl: string; indexUrl: string } {\n const origin = getRuntimeServerOrigin();\n const root = `${origin}/media/${mediaId}`.replace(/\\\\/g, '/');\n return { mediaUrl: `${root}/${FRAGMENT_MP4_FILENAME}`, indexUrl: `${root}/index.json` };\n}\n\n/** Resolve a media-relative asset path (e.g. a playlist poster/cover) to a full URL. */\nexport function resolveMediaAsset(relPath: string): string {\n const origin = getRuntimeServerOrigin();\n return `${origin}/media/${relPath}`.replace(/\\\\/g, '/');\n}\n\n/**\n * The card image for one playlist/tierlist entry.\n *\n * An explicit `thumbnail` always wins — that is what flat-layout ingest writes\n * into playlist.json, and it may point anywhere. Everything else falls back to\n * `<mediaId>/poster.jpg`, which is the SAME convention that ingest's\n * `copy_poster` already uses; `scripts/fetch-posters.py` fills it in for the\n * entries ingest never covered (subfolder-layout clips, and anime SERIES, which\n * have no video of their own to grab a frame from).\n *\n * Deriving it by convention rather than writing paths into every playlist.json\n * means a poster appears the moment the file exists — no manifest to re-emit,\n * and no migration for the playlists that already ship a thumbnail. A missing\n * file is a 404 the caller hides, which is exactly the blank tile it replaced.\n */\nexport function resolveCardImage(mediaId: string, thumbnail?: string): string {\n return resolveMediaAsset(thumbnail ?? `${mediaId}/poster.jpg`);\n}\n","/**\n * Client for the global tierlist.\n *\n * HTTP, not socket events: the board is not room state, so it must be readable\n * from anywhere — including before a room socket exists. The only socket\n * involvement is the `tierlist_updated` ping that tells this client to re-fetch\n * (see `wireTierlist` in socket.ts).\n */\nimport { getRuntimeServerOrigin } from './config';\nimport { resolveCardImage } from './mediaUrls';\nimport { useAppStore } from './store';\nimport { debug, warn, errorFields } from './debug';\nimport type { TTierlistSnapshotOut, TTier } from '@watchparty/shared';\n\nfunction url(path: string): string {\n return `${getRuntimeServerOrigin()}${path}`.replace(/\\\\/g, '/');\n}\n\n/**\n * Pull the board.\n *\n * `adoptDraft` decides whether the fetched `myBoard` replaces what is on screen.\n * True when this client opens the board or saves its own; false for a refresh\n * caused by someone ELSE saving — their result must land live, but overwriting\n * a draft mid-drag would move cards under the user's hands.\n */\nexport async function refreshTierlist(adoptDraft: boolean): Promise<TTierlistSnapshotOut | null> {\n try {\n const res = await fetch(url('/api/tierlist'));\n if (!res.ok) {\n warn('tierlist', 'Board fetch failed', { status: res.status });\n return null;\n }\n const snap = (await res.json()) as TTierlistSnapshotOut;\n useAppStore.getState().setTierlistSnapshot(snap, adoptDraft);\n debug('tierlist', 'Board loaded', {\n items: snap.items.length, voters: snap.voterCount, placed: snap.placed.length,\n });\n return snap;\n } catch (err: unknown) {\n warn('tierlist', 'Board fetch threw', errorFields(err));\n return null;\n }\n}\n\n/**\n * Pull the poster images into the browser cache.\n *\n * Called on INTENT — hovering or focusing the toolbar button — not on mount.\n * The board is ~7 images of ~50KB; fetching them for everyone who never opens\n * it would put ~400KB in front of the media fetches during a load, and this\n * codebase has a long history of exactly that kind of opportunistic prefetch\n * starving playback (see usePlaylistPreBuffer). Intent-triggered costs nothing\n * until someone reaches for it, and by the time the click lands the images are\n * already cached, so the board opens populated instead of filling in.\n *\n * Fire-and-forget and idempotent: the browser dedupes in-flight requests for\n * the same URL, so repeated hovers cost nothing.\n */\nlet warmed = false;\nexport function warmTierlistPosters(): void {\n if (warmed || typeof Image === 'undefined') return;\n const snap = useAppStore.getState().tierlist;\n if (!snap || snap.items.length === 0) return; // nothing known yet; the mount fetch will land first\n warmed = true;\n for (const item of snap.items) {\n const img = new Image();\n img.decoding = 'async';\n img.src = resolveCardImage(item.id, item.thumbnail);\n }\n debug('tierlist', 'Warming posters', { count: snap.items.length });\n}\n\n/**\n * Publish this client's draft.\n *\n * The response is the freshly-recomputed board, so the saver sees their own\n * effect without waiting for the broadcast to come back around. Everyone else\n * is updated by the `tierlist_updated` ping the server emits.\n */\nexport async function saveTierlist(\n board: Partial<Record<TTier, string[]>>,\n): Promise<boolean> {\n const store = useAppStore.getState();\n store.setTierlistSaving(true);\n try {\n // Only the board: the server names a voter by their account and draws their\n // character from prefs-api, so nothing about the person travels with it.\n const res = await fetch(url('/api/tierlist/me'), {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ board }),\n });\n if (!res.ok) {\n warn('tierlist', 'Save rejected', { status: res.status });\n return false;\n }\n const snap = (await res.json()) as TTierlistSnapshotOut;\n useAppStore.getState().setTierlistSnapshot(snap, true);\n return true;\n } catch (err: unknown) {\n warn('tierlist', 'Save threw', errorFields(err));\n return false;\n } finally {\n useAppStore.getState().setTierlistSaving(false);\n }\n}\n","import { CHARACTER_ORDER, displayNameFor, isCharacterName } from './characters';\nimport { useAppStore } from './store';\nimport { debug, warn } from './debug';\n\n/**\n * The server refused our sprite (`conflict / sprite_taken`): take the next free\n * character and ask for it.\n *\n * Two rules, both because this retry is AUTOMATIC, not a choice the person made:\n * - a custom name is kept. Only a missing or character-default name takes the\n * new character's default;\n * - the result is NOT saved. This used to write `user`-scope prefs, so one\n * retry on a second browser rewrote the account's saved name and sprite\n * everywhere. Nothing in watchparty saves an identity now: the character is\n * chosen in the fleet settings popout (lib/avatar.ts).\n *\n * Returns the payload to emit, or null when no character is free.\n */\nexport function retryTakenSprite(): { name: string; sprite: string } | null {\n const st = useAppStore.getState();\n const used = new Set(Object.values(st.roster).map(r => r.sprite).filter(Boolean));\n const free = CHARACTER_ORDER.find(c => !used.has(c));\n if (!free) {\n warn('socket', 'sprite_taken: no available sprites to retry with');\n return null;\n }\n const keepName = !!st.name && !isCharacterName(st.name);\n const name = keepName ? st.name : displayNameFor(free);\n debug('socket', 'sprite_taken: retrying with next available', { from: st.sprite, to: free, name, keptCustomName: keepName });\n st.setSessionIdentity(name, free);\n return { name, sprite: free };\n}\n\n/**\n * Whether a `sprite_taken` refusal is already settled, so retrying would only\n * ask for a sprite nobody needs: there is no seat for us in the roster yet\n * (the first presence brings the server's pick), or the seat's sprite is the\n * one we hold.\n */\nexport function isStaleSpriteRefusal(myClientId: string | undefined): boolean {\n const st = useAppStore.getState();\n const seat = myClientId ? st.roster[myClientId] : undefined;\n return !seat || (!!seat.sprite && seat.sprite === st.sprite);\n}\n","/**\n * The person's avatar, as hadoku_site's prefs-api resolves it.\n *\n * A person picks their CHARACTER and their PICTURE (Discord, Steam, an upload,\n * or the character itself) at the top level, in the fleet settings popout\n * (`ConnectedSettings` → `AvatarPicker`, @wolffm/task-ui-components). Watchparty\n * has no picker of its own and no longer reads or writes the `sprite` field of\n * its prefs row: prefs-api falls back to that field for someone who never chose\n * at the top level, so it stays in the schema, but only prefs-api reads it.\n *\n * - The character is REQUESTED at join (`fetchRequestedSprite`, awaited by the\n * pre-join wait) and live whenever the popout changes it (`AVATAR_EVENT`).\n * The room still decides: a taken character falls back as it always has.\n * - The picture is never read here. The server resolves each participant's by\n * registry userId and sends it in presence (`avatar_url`, apps/server\n * `rooms/avatars.ts`), so a person sees everyone's, not only their own.\n */\nimport { createAvatarClient, AvatarError, type AvatarSettings } from '@wolffm/prefs-client/avatar';\nimport { useAppStore } from './store';\nimport { displayNameFor, isCharacterId, isCharacterName } from './characters';\nimport { debug, warn, errorFields } from './debug';\n\n/**\n * The settings popout's window event, `detail: AvatarSettings`, fired after\n * every change the person makes. @wolffm/task-ui-components exports it as\n * `AVATAR_EVENT` from 4.22.3, which is not on the registry yet (4.22.1 is\n * installed) — and the room chrome must never import that package anyway (a\n * game host need not install it), so the value is spelled here.\n */\nexport const AVATAR_EVENT = 'hadoku:avatar';\n\n/** The character a person asked for: their top-level pick, else the one\n * prefs-api resolved (it falls back to the old watchparty pref). */\nexport function requestedSpriteOf(settings: Pick<AvatarSettings, 'sprite' | 'avatar'> | null | undefined): string | null {\n const id = settings?.sprite ?? settings?.avatar?.sprite ?? null;\n return isCharacterId(id) ? id : null;\n}\n\n/**\n * The signed-in person's requested character, or null. Never throws.\n *\n * Normal answers, logged at debug: a signed-out visitor's 401/403 (they keep\n * today's auto-assign), and a 404 — an origin with no prefs-api mounted, which\n * is the local dev/E2E server and any non-hadoku host of the room chrome.\n * Anything else is a fault worth a warn, and still resolves null: no answer\n * from prefs-api must never block or break a join.\n */\nexport async function fetchRequestedSprite(opts: { baseUrl?: string; fetch?: typeof fetch } = {}): Promise<string | null> {\n const f = opts.fetch ?? (typeof fetch === 'undefined' ? undefined : fetch);\n const origin = typeof window === 'undefined' ? undefined : window.location?.origin;\n const baseUrl = opts.baseUrl ?? (origin ? `${origin}/prefs` : undefined);\n if (!f || !baseUrl) return null;\n try {\n const settings = await createAvatarClient({ baseUrl, fetch: f }).get();\n const sprite = requestedSpriteOf(settings);\n debug('avatar', 'Requested character from prefs-api', { sprite });\n return sprite;\n } catch (err: unknown) {\n if (err instanceof AvatarError && (err.status === 401 || err.status === 403 || err.status === 404)) {\n debug('avatar', 'No avatar settings here (signed out, or no prefs-api on this origin)', { status: err.status });\n } else {\n warn('avatar', 'Avatar settings read failed — keeping the auto-assigned character', {\n status: err instanceof AvatarError ? err.status : undefined, ...errorFields(err),\n });\n }\n return null;\n }\n}\n\n/**\n * Make `sprite` the identity this tab asks for. A SESSION identity: nothing is\n * saved — the choice lives in prefs-api. The name follows the character only\n * when it is a character name; a registry name or a host-offered one stays.\n * Returns whether anything changed.\n */\nexport function adoptRequestedSprite(sprite: string | null): boolean {\n if (!sprite) return false;\n const st = useAppStore.getState();\n if (st.sprite === sprite) return false;\n const registryNamed = !!(st.me.clientId && st.roster[st.me.clientId]?.registryName);\n // An empty name stays empty, so ensureAutoIdentity derives it at join.\n const name = registryNamed || !st.name || !isCharacterName(st.name) ? st.name : displayNameFor(sprite);\n st.setSessionIdentity(name, sprite);\n return true;\n}\n\n/** Call `onChange` for every settings-popout avatar change; returns the unsubscribe.\n * A no-op where there is no window (the chrome mounts under jsdom/happy-dom\n * in consumers' tests, and the UI's own tests run in node). */\nexport function listenForAvatarChanges(\n onChange: (settings: AvatarSettings) => void,\n target: EventTarget | undefined = typeof window === 'undefined' ? undefined : window,\n): () => void {\n if (!target) return () => {};\n const handler = (e: Event) => {\n const detail = (e as CustomEvent<AvatarSettings | undefined>).detail;\n if (!detail || typeof detail !== 'object') return;\n onChange(detail);\n };\n target.addEventListener(AVATAR_EVENT, handler);\n return () => target.removeEventListener(AVATAR_EVENT, handler);\n}\n","/**\n * How is this app framed?\n *\n * Watchparty is reachable two ways that are INDISTINGUISHABLE in every log we\n * currently write:\n *\n * 1. Direct — `https://hadoku.me/watchparty/...` as a top-level document.\n * 2. From the hadoku.me home page — the shell mounts a scene iframe, and the\n * scene mounts watchparty in a second iframe inside it (depth 2).\n *\n * `client_url` is read from the app's OWN window, so it is byte-identical in\n * both cases (`https://hadoku.me/watchparty/room/...`). During the 2026-08-04\n * investigation that made \"did this viewer enter from the home page?\"\n * unfalsifiable from logs — the hypothesis could be neither confirmed nor\n * refuted, only argued about. This closes that gap: `framed`/`depth` go out\n * with `join` and land in the `session_open` record.\n *\n * Cross-origin safe: reading `window.parent`/`window.top` and comparing\n * WindowProxy identity is permitted across origins (only reaching *into* their\n * document is blocked). The walk is still wrapped defensively — a telemetry\n * field must never be able to break a join.\n */\n\nexport interface EmbedContext {\n /** True when this document is not the top-level window. */\n framed: boolean;\n /** Nesting depth: 0 = top-level, 1 = one iframe, 2 = the home-page path. */\n depth: number;\n}\n\n/** Hard cap on the parent walk, so a malformed/cyclic frame tree can't hang join. */\nconst MAX_FRAME_DEPTH = 10;\n\nexport function describeEmbedding(win: Window | undefined = typeof window !== 'undefined' ? window : undefined): EmbedContext {\n if (!win) return { framed: false, depth: 0 };\n try {\n let depth = 0;\n let w: Window = win;\n while (w !== w.parent && depth < MAX_FRAME_DEPTH) {\n depth++;\n w = w.parent;\n }\n // `top` can read as null in exotic/detached cases; fall back to the walk.\n const framed = win.top ? win.top !== win.self : depth > 0;\n return { framed, depth };\n } catch {\n // Never let telemetry break the join path.\n return { framed: false, depth: 0 };\n }\n}\n","/**\n * Edge-cacheable fragment delivery — URL construction and the enable decision.\n *\n * Two ways to fetch the same bytes:\n *\n * range GET <base>/output_frag.mp4 Range: bytes=S-E -> 206\n * fragment GET <base>/v/<token>/frag/<n> (no Range header) -> 200\n *\n * The fragment form exists so Cloudflare can cache a ~1MB object instead of failing to cache a\n * 700-800MB one (and dropping the Range on every fill — measured in prod 2026-08-05). The bytes\n * are identical; only the addressing differs. Server side is `media/fragmentRoute.ts`, and\n * `fragment-route.realmedia.test.ts` proves byte-identity against the real library.\n *\n * This module is deliberately pure. Getting a URL wrong here does not throw — it fetches a\n * *valid-looking* wrong range and appends plausible garbage to a SourceBuffer, which is\n * undetectable client-side and surfaces later as the corrupt-fragment / structural-gap failures\n * this codebase has spent months on. So the URL math lives in one tested place rather than\n * inline in FragmentManager.\n *\n * DEFAULT OFF. See `isFragmentDeliveryEnabled`.\n */\n\nimport {\n resolveDeliveryArm, normalizeFragmentDeliveryFlag, DEFAULT_FRAGMENT_DELIVERY_FLAG,\n type FragmentDeliveryFlag as FragmentDeliveryFlagT, type DeliveryArm,\n} from '@watchparty/shared';\nimport { debug, warn } from './debug';\nimport { readStored, writeStored, readStoredFlag } from './browserStorage';\n\nexport const FRAGMENT_DELIVERY_FLAG = 'watchparty_fragment_delivery';\n\n/** Everything needed to address one FragmentManager's media via the fragment route. */\nexport interface DeliveryConfig {\n /** Leaf base, no trailing slash: `<origin>/media/<mediaId>` */\n baseUrl: string;\n /** Full 64-hex SHA-256 of output_frag.mp4, from index.json. */\n token: string;\n /** Present for audio FragmentManagers only; identifies the track in the index. */\n trackId?: string;\n}\n\n/** `init`, or a zero-based fragment index. */\nexport type DeliveryTarget = 'init' | number;\n\nconst TOKEN_RE = /^[0-9a-f]{64}$/;\nconst TRACK_ID_RE = /^[A-Za-z0-9_.-]+$/;\n\n/**\n * Is the config usable for fragment-route requests?\n *\n * Mirrors the server's route regex (`fragmentRoute.ts` ROUTE / resolveTarget). A config that\n * fails here can only ever produce 404s, so we keep using Range instead — never a broken URL.\n */\nexport function isDeliveryConfigValid(cfg: DeliveryConfig | null | undefined): cfg is DeliveryConfig {\n if (!cfg) return false;\n if (!cfg.baseUrl || cfg.baseUrl.endsWith('/')) return false;\n // Full digest only. A truncated token would reintroduce a collision path on the one value\n // whose wrongness means a YEAR of an edge serving wrong bytes under `immutable`.\n if (!TOKEN_RE.test(cfg.token)) return false;\n if (cfg.trackId !== undefined && !TRACK_ID_RE.test(cfg.trackId)) return false;\n return true;\n}\n\n/**\n * Build the fragment-route URL, or null when this config/target cannot be addressed.\n *\n * Null is always safe: the caller falls back to a Range request for the same bytes.\n */\nexport function buildFragmentUrl(cfg: DeliveryConfig | null | undefined, target: DeliveryTarget): string | null {\n if (!isDeliveryConfigValid(cfg)) return null;\n let tail: string;\n if (target === 'init') {\n tail = 'init';\n } else {\n // Fragment indices are array positions in index.json. A non-integer or negative index would\n // 404 server-side, but building it at all means a bug upstream — refuse instead.\n if (!Number.isInteger(target) || target < 0) return null;\n tail = `frag/${target}`;\n }\n const prefix = cfg.trackId ? `audio/${cfg.trackId}/` : '';\n return `${cfg.baseUrl}/v/${cfg.token}/${prefix}${tail}`;\n}\n\n/** Local override from `?fragmentDelivery=0|1`, or null when the URL says nothing. */\nexport function readDeliveryOverride(): boolean | null {\n if (typeof window === 'undefined') return null;\n try {\n const q = new URLSearchParams(window.location.search).get('fragmentDelivery');\n if (q === '1' || q === 'true') return true;\n if (q === '0' || q === 'false') return false;\n } catch { /* no location */ }\n return readStoredFlag(FRAGMENT_DELIVERY_FLAG);\n}\n\n// ── Server-controlled rollout ────────────────────────────────────────────────\n//\n// The operator flips this from `POST /api/flags/fragment-delivery` (service-tier) with no\n// deploy, and can run `ab` to split one room between both delivery paths at once. See\n// packages/shared/src/fragmentDeliveryFlag.ts for why the split is per-client and deterministic.\n//\n// Until the flag is fetched we resolve OFF. That is the safe direction: a client that starts\n// before the answer arrives uses the delivery path that has always worked.\n\nlet serverFlag: FragmentDeliveryFlagT = { ...DEFAULT_FRAGMENT_DELIVERY_FLAG };\nlet resolvedArm: DeliveryArm | null = null;\n/** True once something that ACTS on the arm has read it (i.e. FragmentManager). */\nlet armPinned = false;\n\nexport const DELIVERY_CLIENT_ID_KEY = 'watchparty_delivery_client_id';\n\n/**\n * Stable per-browser id used ONLY for A/B bucketing.\n *\n * Deliberately not the socket id. That is what `me.clientId` holds, and it is reassigned on\n * every reconnect — bucketing on it would let a viewer flip delivery paths mid-party on a\n * transient disconnect, which both breaks the \"never change path mid-episode\" invariant and\n * turns the A/B comparison into noise (the same person contributing samples to both arms).\n * It is also unavailable at join time, when the arm has to be reported.\n *\n * Persisted so a viewer stays in one arm across reloads and across sessions, which is what\n * makes a percentage rollout mean anything. Not an identity or a tracking id: it is a random\n * value with no server-side meaning, read by nothing except this bucketing.\n */\nexport function deliveryClientId(): string {\n const existing = readStored(DELIVERY_CLIENT_ID_KEY);\n if (existing) return existing;\n const fresh = (globalThis.crypto?.randomUUID?.() ?? `d${Math.random().toString(36).slice(2)}`);\n // An id we could not persist is worse than none: it would put this client in\n // a DIFFERENT arm on every reload, feeding samples to both. Empty id =>\n // resolveDeliveryArm keeps it out of the experiment entirely, which is the\n // safe direction.\n return writeStored(DELIVERY_CLIENT_ID_KEY, fresh) ? fresh : '';\n}\n\n/**\n * Store the flag fetched from the server.\n *\n * Clears a provisional arm, but never a PINNED one.\n *\n * The distinction is the whole reason this exists. `/api/flags` is fetched during app boot and\n * lands after the socket join, so an arm resolved at join time is decided against the default\n * (off) — which on 2026-08-08 made an `ab`/50 rollout produce 0 of 5 clients in the fragment\n * arm. Every client had pinned `range` milliseconds before learning the flag. So a resolution\n * made purely for reporting stays provisional and is recomputed when the flag arrives.\n *\n * Once FragmentManager has actually read the arm (isFragmentDeliveryEnabled), it is pinned and\n * a later flag change cannot move it — that upholds the invariant that a client never switches\n * delivery path mid-episode. Media loads seconds after boot, long after the flag lands, so in\n * practice the pin happens on the correct value.\n */\nexport function setServerDeliveryFlag(raw: unknown): void {\n serverFlag = normalizeFragmentDeliveryFlag(raw);\n if (!armPinned) resolvedArm = null;\n}\n\nexport function getServerDeliveryFlag(): FragmentDeliveryFlagT {\n return { ...serverFlag };\n}\n\n/**\n * Which arm is this client in? Memoised for the session.\n *\n * Memoisation is load-bearing, not an optimisation: an in-flight media load must not change\n * delivery path halfway through because an operator moved the flag. The arm is fixed for the\n * session and a change takes effect on the next page load.\n */\nexport function resolveSessionArm(): DeliveryArm {\n if (resolvedArm === null) {\n resolvedArm = resolveDeliveryArm(serverFlag, deliveryClientId(), readDeliveryOverride());\n }\n return resolvedArm;\n}\n\n/**\n * Fetch the rollout flag. Idempotent, safe to call more than once, never throws.\n *\n * Deliberately NOT tied to any React effect's AbortController. It was, chained onto the\n * app's health-check effect, and `if (controller.signal.aborted) return` then discarded the\n * flag whenever that effect cleaned up before the response landed — a pure race. Two\n * consecutive 8-viewer DO runs on 2026-08-08 disagreed completely because of it: one put 5 of\n * 7 clients in the fragment arm, the next 0 of 6, with the same flag and the same build.\n *\n * A rollout flag is not tied to a component's lifetime. It is a tiny idempotent GET whose only\n * job is to land before media loads (seconds later), so it gets one retry and no cancellation.\n */\nlet flagFetchStarted = false;\nexport async function loadServerDeliveryFlag(origin: string): Promise<void> {\n if (flagFetchStarted) return;\n flagFetchStarted = true;\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n const res = await fetch(`${origin}/api/flags`, { cache: 'no-store' });\n if (res.ok) {\n const body = await res.json();\n setServerDeliveryFlag(body?.fragment_delivery);\n debug('app', 'Delivery flag loaded', { ...getServerDeliveryFlag(), attempt });\n return;\n }\n } catch { /* fall through to the retry */ }\n if (attempt === 0) await new Promise((r) => setTimeout(r, 500));\n }\n // Both attempts failed — stay on the default (range), which is the path that always works.\n warn('app', 'Delivery flag fetch failed; staying on the default delivery path');\n}\n\n/** Test seam — drop the memoised arm. */\nexport function resetSessionArm(): void { resolvedArm = null; armPinned = false; flagFetchStarted = false; }\n\n/**\n * The arm for this session, for telemetry. Safe to call at join time — the bucketing id is a\n * persisted per-browser value, not the socket id, so it exists before the socket does.\n */\nexport function currentDeliveryArm(): DeliveryArm {\n return resolveSessionArm(); // provisional until something pins it — see setServerDeliveryFlag\n}\n\n/**\n * Is fragment delivery active for this session?\n *\n * **Off unless the server says otherwise.** As of 2026-08-07 Cloudflare returns\n * `cf-cache-status: DYNAMIC` for these URLs (extensionless paths are not cached by default),\n * and the route sits ahead of the Range gate in `priorityQueue.ts` so it consumes no\n * `MAX_CONCURRENT_MEDIA` slot and is never sorted by `X-Buffer-Ahead-Ms`. Enabled today that is\n * strictly worse than Range: no edge cache AND no fairness queue.\n */\nexport function isFragmentDeliveryEnabled(): boolean {\n const arm = resolveSessionArm();\n // Reading it to ACT on it fixes it for the session.\n armPinned = true;\n return arm === 'fragment';\n}\n","import { DEVICE_ID_KEY, getDeviceId } from '@wolffm/prefs-client';\nimport { readStored } from './browserStorage';\nimport { debug } from './debug';\n\n/**\n * The browser-persisted device id, reported on join so the server can derive a\n * `dev:`-tier participant identity for a client with no friend key.\n *\n * ## Why this id and not a new one\n *\n * `@wolffm/prefs-client` already owns `hadoku_device_id` — one id per browser,\n * shared across every hadoku microfrontend by design. Minting a\n * watchparty-specific id would give the same physical browser a different\n * identity per app, for no gain: the id is opaque, carries no privileges, and\n * the whole point is \"this is the same browser as last time\".\n *\n * `lib/fragmentDelivery.ts` keeps its OWN `watchparty_delivery_client_id`, and\n * that is deliberate rather than an oversight to clean up — it is an A/B\n * bucketing salt, and pointing it at a shared ecosystem id would silently\n * re-bucket every viewer the first time any other hadoku app touched the key.\n * Two ids, two purposes.\n *\n * ## Why there is a synchronous read at all\n *\n * `getDeviceId()` is async (so the SDK can swap in Capacitor storage), but the\n * join payload is assembled synchronously and a join that waits on I/O is a\n * join that races the socket. So: read localStorage directly for the common\n * case — a returning browser already has the key — and use the async path only\n * to MINT one, priming the cache for the next join.\n *\n * Reading `DEVICE_ID_KEY` ourselves is not reaching into the SDK's internals;\n * the package exports that constant precisely so consumers can.\n *\n * ## The unresolved case is a normal outcome, not an error\n *\n * A brand-new browser, or one with storage blocked (private mode, blocked site\n * data), yields `undefined`. The server then derives an `eph:` identity — the\n * pre-existing behaviour, where a name lasts as long as the socket. It must\n * never block or delay the join: an anonymous viewer losing their display name\n * on reload is a small thing, and not joining at all is not.\n */\n\nlet cached: string | undefined;\n\n/** Synchronous read of an id this browser already holds. */\nfunction storedDeviceId(): string | undefined {\n return readStored(DEVICE_ID_KEY) || undefined;\n}\n\n/**\n * Kick off a mint if this browser has no id yet. Fire-and-forget: callers get\n * the id from `cachedDeviceId()` when it is there, and the ephemeral tier when\n * it isn't. Call it as early as possible so the mint is done before join.\n */\nexport function primeDeviceId(): void {\n if (cached) return;\n const stored = storedDeviceId();\n if (stored) {\n cached = stored;\n return;\n }\n void getDeviceId()\n .then((id) => {\n if (id) cached = id;\n })\n .catch(() => {\n // Storage refused the write. Nothing to recover: the ephemeral tier is\n // the honest answer, and swallowing this is the whole point — a device\n // id is an optimisation, never a requirement for joining a room.\n debug('identity', 'device id unavailable — joining without one');\n });\n}\n\n/**\n * The device id, if this browser has one *right now*. Never blocks, never\n * mints. Returns undefined on a first-ever visit whose mint has not landed.\n */\nexport function cachedDeviceId(): string | undefined {\n return (cached ??= storedDeviceId());\n}\n\n/** Test seam: drop the memoised value so a spec can re-read storage. */\nexport function resetDeviceIdCacheForTests(): void {\n cached = undefined;\n}\n","import { removeStored } from './browserStorage';\nimport { STORAGE_KEYS } from './storageKeys';\n\n/**\n * Forget this browser's stored ballot for a playlist. Called when the leader\n * removes us from the results (`playlist_votes_cleared`). The ballot is kept in\n * localStorage and RE-SENT whenever the playlist loads (PlaylistPanel's restore\n * effect), so clearing only the server's copy would be undone by the next\n * reload. Returns how many entries were removed.\n */\nexport function clearStoredVotes(playlist: { id: string; videos: ReadonlyArray<{ mediaId: string }> }, owner: string): number {\n let removed = 0;\n for (const video of playlist.videos) {\n const key = STORAGE_KEYS.voteKey(owner, playlist.id, video.mediaId);\n if (globalThis.localStorage?.getItem(key) != null) removed++;\n removeStored(key);\n }\n return removed;\n}\n","/**\n * Client-side liveness of the socket, judged by time_pong replies.\n *\n * The browser only learns a connection died when something closes it. A\n * connection that goes quiet without closing — half-open, or stalled somewhere\n * upstream — keeps `socket.connected === true` indefinitely: engine.io's own\n * timeout is pingInterval + SOCKET_PING_TIMEOUT_MS, over five minutes here.\n * mistral-bluff 2026-10-20 had both kinds: four IPv4 viewers' sockets were cut\n * and reconnected on their own, while an IPv6 viewer's sat silent for 96s on\n * the same socket and recovered only when the network did.\n *\n * Rule: a ping unanswered for PONG_DEAD_AFTER_MS of AWAKE time means the\n * socket is dead, and the caller replaces it. \"Awake\" matters — a frozen or\n * throttled background page stops running timers, so a long gap between two\n * ticks is the page sleeping, not the network failing. Such a gap restarts the\n * clock instead of counting toward it.\n *\n * No timer of its own: it is evaluated on the existing time-ping tick.\n */\n\n/** Pings go out every DEFAULT_CLIENT_TIME_PING_INTERVAL_MS (5s); four of them unanswered. */\nexport const PONG_DEAD_AFTER_MS = 20_000;\n/** A tick this late after the previous one means the page was asleep. */\nexport const SLEEP_GAP_MS = 10_000;\n\nexport interface Liveness {\n /** When the oldest ping with no pong since was sent; null when all answered. */\n unansweredSince: number | null;\n lastPongAt: number | null;\n lastTickAt: number | null;\n}\n\nexport function createLiveness(): Liveness {\n return { unansweredSince: null, lastPongAt: null, lastTickAt: null };\n}\n\nexport function notePong(l: Liveness, now: number): void {\n l.lastPongAt = now;\n l.unansweredSince = null;\n}\n\n/**\n * Called on each ping tick, before the ping is sent. Returns true when the\n * socket should be treated as dead.\n */\nexport function tick(l: Liveness, now: number, connected: boolean): boolean {\n const slept = l.lastTickAt !== null && now - l.lastTickAt > SLEEP_GAP_MS;\n l.lastTickAt = now;\n if (!connected) {\n l.unansweredSince = null;\n return false;\n }\n if (slept) {\n l.unansweredSince = now;\n return false;\n }\n if (l.unansweredSince === null) {\n l.unansweredSince = now;\n return false;\n }\n return now - l.unansweredSince > PONG_DEAD_AFTER_MS;\n}\n\n/** ms since the last pong, for the health report; undefined before the first. */\nexport function pongAge(l: Liveness, now: number): number | undefined {\n return l.lastPongAt === null ? undefined : Math.max(0, now - l.lastPongAt);\n}\n","import type { Socket } from 'socket.io-client';\nimport { io } from 'socket.io-client';\nimport { DEFAULT_UI_PORT, DEFAULT_HTTP_PORT, DEFAULT_WS_PATH, PLAYLIST_EVENTS, TIERLIST_EVENTS, READY_EVENTS, CORE_EVENTS, GAME_EVENTS, DEFAULT_CLIENT_TIME_PING_INTERVAL_MS } from '@watchparty/shared';\nimport { isChatImageKey, DEFAULT_CHAT_MAX_IMAGES } from '@watchparty/shared';\nimport type { TVoteValue, TChatImage } from '@watchparty/shared';\nimport type { RosterUser, ChatMessage } from './store';\nimport { useAppStore } from './store';\nimport { refreshTierlist } from './tierlistApi';\nimport { getRuntimeConfig } from './config';\nimport { displayNameFor, isCharacterName } from './characters';\nimport { debug, warn, error, setLogRoomContext } from './debug';\nimport { isStaleSpriteRefusal, retryTakenSprite } from './spriteRetry';\nimport { adoptRequestedSprite } from './avatar';\nimport { describeEmbedding } from './embedContext';\nimport { currentDeliveryArm } from './fragmentDelivery';\nimport { cachedDeviceId } from './deviceId';\nimport { resolveIdentityReassert } from '@wolffm/rooms-protocol';\nimport type {\n SnapshotPayload, ControlBroadcastPayload, ControlAckPayload, TimePongPayload,\n ReadyStatePayload, PresencePayload, ChatBroadcastPayload, ChatHistoryPayload,\n PlaylistStatePayload, PlaylistVoteProgressPayload, PlaylistResultsPayload, PlaylistVotesClearedPayload,\n ReadyCheckStatePayload, GameActivePayload, HealthSnapshotPayload,\n BufferStatusPayload, ErrorPayload,\n} from './wireEvents';\nimport { registerDebugGlobal } from './debugGlobals';\nimport { clearStoredVotes } from './playlistVotes';\nimport { voteOwnerScope } from './auth';\nimport { createLiveness, notePong, tick as livenessTick, pongAge } from './connectionLiveness';\n\nlet socket: Socket | null = null;\nlet timePingInflight: { ts: number } | null = null;\nlet timePingInterval: ReturnType<typeof setInterval> | null = null;\nlet timePingTimeout: ReturnType<typeof setTimeout> | null = null;\nlet lastJoinPayload: { room_key: string; name?: string; sprite?: string | null; embed?: { framed: boolean; depth: number }; device_id?: string; tab_id: string } | null = null;\n/** One per page load, sent on the join and every rejoin, so the server can tell\n * this tab reconnecting from a second tab and retire the old connection. */\nconst TAB_ID = makeTabId();\nconst liveness = createLiveness();\n/** How the previous connection ended, reported on the next rejoin. */\nlet lastDisconnect: { reason: string; at: number } | null = null;\n/** Set just before the pong watchdog closes the transport, so the rejoin says why. */\nlet pendingDisconnectCause: string | null = null;\nlet reconnectCount = 0;\n/** The identity the latest join (or rejoin) carried — what the server has\n * already been asked for, so the first snapshot never asks again. */\nlet joinedIdentity: { name?: string; sprite?: string | null } | null = null;\n/** True while this module sets an identity it is about to emit itself, so the\n * store-change reassert below does not send a second copy of it. */\nlet identityEmitInHand = false;\n/** Set when the server said our wanted sprite is held by this account's other connection; cleared per connect. */\nlet spriteHeldByOwnOtherConnection = false;\nlet hasConnectedOnce = false; // Distinguishes initial connect from reconnects\nlet lastReadyMediaId: string | null = null; // Tracks last client_ready so it can be re-emitted on reconnect\n\ninterface JoinOpts {\n room_key: string;\n name?: string;\n sprite?: string | null;\n}\n\nexport function connectAndJoin(opts: JoinOpts): Socket {\n const s = getSocket();\n const state = useAppStore.getState();\n // If we already have a clientId and roster contains it, avoid duplicate join.\n if (state.me.clientId && state.roster[state.me.clientId]) {\n if (opts.name || opts.sprite) {\n debug('socket', 'Identity update (already joined)', { name: opts.name, sprite: opts.sprite });\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: opts.name, sprite: opts.sprite });\n }\n return s;\n }\n const payload = sanitizeJoin(opts);\n lastJoinPayload = payload; // Store for reconnection\n joinedIdentity = { name: payload.name, sprite: payload.sprite };\n if (s.connected) {\n debug('socket', 'Emitting join', payload);\n s.emit(CORE_EVENTS.JOIN, payload);\n } else {\n s.once('connect', () => { debug('socket', 'Emitting join', payload); s.emit(CORE_EVENTS.JOIN, payload); });\n }\n return s;\n}\n\nfunction sanitizeJoin(o: JoinOpts) {\n return {\n room_key: o.room_key,\n name: o.name,\n sprite: o.sprite,\n // Entry-path telemetry — see lib/embedContext.ts. Sent on every join AND\n // rejoin (lastJoinPayload carries it), so a reconnect keeps the attribution.\n embed: describeEmbedding(),\n delivery_arm: currentDeliveryArm(),\n // Identity tier input — see lib/deviceId.ts. Sent on every join AND rejoin\n // (lastJoinPayload carries it) so a reconnect lands on the same\n // ParticipantId rather than minting a new person.\n device_id: cachedDeviceId(),\n tab_id: TAB_ID,\n };\n}\n\nfunction makeTabId(): string {\n const c = globalThis.crypto;\n if (c && typeof c.randomUUID === 'function') return c.randomUUID();\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') c.getRandomValues(bytes);\n else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');\n}\n\n/** ms since this connection's last time_pong, for the health report. */\nexport function currentPongAgeMs(): number | undefined {\n return pongAge(liveness, Date.now());\n}\n\nexport function getSocket(): Socket {\n if (!socket) {\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '/';\n if (origin === '/' && typeof window !== 'undefined') {\n const loc = window.location;\n if ((loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)) {\n origin = `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`;\n debug('socket', 'Using dev fallback origin', { origin });\n }\n }\n // Strip any path from the origin - socket.io treats URL paths as namespaces\n const socketOrigin = new URL(origin.startsWith('http') ? origin : `${window.location.origin}${origin}`).origin;\n socket = io(socketOrigin, { path: DEFAULT_WS_PATH, transports: ['websocket', 'polling'] });\n debug('socket', 'Socket initialized', { origin });\n\n // E2E handle — published only when the host opts in (lib/debugGlobals.ts).\n registerDebugGlobal('__SOCKET__', socket);\n\n // Start time ping loop immediately when socket is created.\n // This must live here (not in wireCoreHandlers) so that cleanupTimers() + getSocket()\n // restarts the interval correctly. wireCoreHandlers is called once at creation time\n // but not on reconnect, so placing the interval there would leave it dead after cleanup.\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n timePingInflight = null;\n timePingInterval = setInterval(() => {\n if (livenessTick(liveness, Date.now(), !!socket?.connected)) {\n // Half-open or stalled upstream: nothing will ever close it for us.\n // Closing the transport hands it to socket.io's reconnect, and the\n // rejoin's tab id lets the server retire the old row at once.\n warn('socket', 'PONG-WATCHDOG no time_pong for 20s — replacing the connection', {\n sid: socket?.id, lastPongAgeMs: pongAge(liveness, Date.now()) ?? null,\n });\n pendingDisconnectCause = 'pong-watchdog';\n liveness.unansweredSince = null;\n socket?.io.engine?.close();\n return;\n }\n initiateTimePing();\n }, DEFAULT_CLIENT_TIME_PING_INTERVAL_MS);\n\n socket.on('connect', () => {\n spriteHeldByOwnOtherConnection = false;\n const sid = socket?.id;\n debug('socket', 'Connected', { id: sid, isReconnect: hasConnectedOnce });\n if (sid) {\n try {\n useAppStore.getState().ensureMe(sid);\n // Reset client sequence on (re)connect to match server's fresh connection state\n // Server starts with lastClientSeq=0, so we need nextClientSeq=1\n useAppStore.setState(s => ({ me: { ...s.me, nextClientSeq: 1 } }));\n debug('socket', 'Reset client sequence for new connection');\n } catch (e) {\n error('socket', 'Failed to initialize store on connect', { error: (e as Error).message, sid });\n }\n }\n // Re-join on reconnect only — initial join is handled by connectAndJoin()\n if (hasConnectedOnce && lastJoinPayload) {\n const st = useAppStore.getState();\n reconnectCount += 1;\n const rejoinPayload = {\n ...lastJoinPayload, name: st.name, sprite: st.sprite,\n reconnect: {\n count: reconnectCount,\n reason: (lastDisconnect?.reason ?? 'unknown').slice(0, 64),\n offline_ms: lastDisconnect ? Math.max(0, Date.now() - lastDisconnect.at) : 0,\n },\n };\n const readyMediaId = lastReadyMediaId;\n debug('socket', 'Re-emitting join after reconnect', { room_key: rejoinPayload.room_key, name: rejoinPayload.name, sprite: rejoinPayload.sprite });\n // Wire snapshot ack BEFORE emitting join so we never miss the response.\n // The server emits `snapshot` once per successful join — we use it as\n // the join-ack. Re-emitting client_ready before snapshot arrives loses\n // the event server-side because the new socket isn't yet in the room\n // (Bug 2 follow-up: the previous fix race-condition'd by re-emitting\n // client_ready unconditionally; this version waits.)\n if (readyMediaId) {\n let resolved = false;\n const onSnapshot = () => {\n if (resolved) return;\n resolved = true;\n socket!.off(CORE_EVENTS.SNAPSHOT, onSnapshot);\n socket!.emit(CORE_EVENTS.CLIENT_READY, { media_id: readyMediaId });\n debug('socket', 'Re-emitted client_ready after reconnect snapshot', { mediaId: readyMediaId });\n };\n socket!.on(CORE_EVENTS.SNAPSHOT, onSnapshot);\n setTimeout(() => {\n if (resolved) return;\n resolved = true;\n socket!.off(CORE_EVENTS.SNAPSHOT, onSnapshot);\n warn('socket', 'client_ready re-emit timed out waiting for snapshot', { mediaId: readyMediaId });\n }, 10_000);\n }\n joinedIdentity = { name: rejoinPayload.name, sprite: rejoinPayload.sprite };\n socket!.emit(CORE_EVENTS.JOIN, rejoinPayload);\n // Abort any stale in-flight fetches from the previous connection.\n // On socket.io reconnect the FragmentManager carries over pending\n // fetch indices from the old session which block new fetches at\n // lower indices indefinitely (until their 60-90s adaptive timeouts\n // expire). Clearing them here lets prefetch restart immediately.\n // Notify MediaPlayer via event rather than reaching into __FRAGMENT_MANAGER__\n // directly. MediaPlayer owns the FM; socket should not depend on the window global.\n window.dispatchEvent(new CustomEvent('watchparty:socket-reconnect'));\n } else if (hasConnectedOnce) {\n warn('socket', 'Reconnected — no join payload, not re-joining any room');\n }\n hasConnectedOnce = true;\n });\n socket.on('disconnect', (reason) => {\n setLogRoomContext(null);\n lastDisconnect = { reason: pendingDisconnectCause ?? reason, at: Date.now() };\n pendingDisconnectCause = null;\n debug('socket', 'Disconnected', { reason });\n });\n socket.on('connect_error', (err) => error('socket', 'Connection error', { message: err?.message }));\n socket.on('reconnect_attempt', (n) => warn('socket', 'Reconnect attempt', { attempt: n }));\n socket.on('reconnect_failed', () => error('socket', 'Reconnect failed'));\n wireCoreHandlers(socket);\n }\n return socket;\n}\n\n// --- Helpers ---\n\nfunction isServerPlaying(payload: { play_state?: string; playing?: boolean }): boolean {\n return payload.play_state === 'playing' || payload.playing === true;\n}\n\nfunction normalizeChatMessage(m: ChatBroadcastPayload, seqFallback?: number): ChatMessage {\n return {\n id: String(m.id ?? m.server_seq ?? seqFallback ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),\n clientId: m.client_id,\n author: typeof m.author === 'string' ? m.author : undefined,\n name: m.name || 'Anon',\n sprite: m.sprite ?? null,\n text: m.text || '',\n ts: m.ts || Date.now(),\n // Attachments arrive from other clients, so the key shape is re-checked\n // here at the boundary rather than trusted at render time. A malformed\n // entry is dropped, not rendered and not allowed near an <img src>.\n images: Array.isArray(m.images)\n ? m.images\n .filter((im: unknown): im is TChatImage =>\n !!im && isChatImageKey((im as TChatImage).key))\n .slice(0, DEFAULT_CHAT_MAX_IMAGES)\n : undefined,\n isSystem: m.isSystem || false,\n systemType: m.systemType,\n };\n}\n\n// --- Domain-split wire functions ---\n\nfunction wireSync(s: Socket): void {\n // snapshot\n s.on(CORE_EVENTS.SNAPSHOT, (payload: SnapshotPayload) => {\n // First snapshot = join confirmed. Ask which game is open, so a late\n // joiner mounts it without waiting for the next leader broadcast.\n // Idempotent on later snapshots.\n s.emit(GAME_EVENTS.SYNC);\n // NOTE: the snapshot carries no roster and no client_id — `buildSnapshot`\n // emits exactly seven fields (room_id, play_state, playhead_ms, leader_id,\n // media_id, server_seq, server_ts). Anything roster-shaped must come from the store,\n // which the `presence` handler maintains.\n // Server sends play_state: 'playing'|'paused', convert to boolean\n useAppStore.getState().applySnapshot({\n roomId: payload.room_id ?? null,\n mediaId: payload.media_id ?? null,\n playheadMs: payload.playhead_ms || 0,\n playing: isServerPlaying(payload),\n serverSeq: payload.server_seq || 0,\n leaderId: payload.leader_id || null,\n });\n // No auto identity assignment here: with no roster in the snapshot,\n // ensureAutoIdentity() would always pick the first character (sage) and\n // send an identity_update the server accepts for name but rejects for\n // sprite, leaving duplicate names across clients. `presence` arrives right\n // after with the full roster and its handler owns that correction.\n if (!useAppStore.getState().name) {\n debug('socket', 'Deferring identity to presence event (snapshot carries no roster)');\n }\n\n // Reassert local identity (name + sprite) on the first snapshot after\n // join. Two motivations:\n // 1. Custom NAME — replaces the old 40ms postJoinIdentity setTimeout.\n // 2. Saved SPRITE — when /api/me/prefs resolves AFTER Room.tsx fires\n // connectAndJoin (the typical fresh-device case: SPA bundle on a\n // direct /room/{key} link), the join payload sends the auto-\n // assigned sprite. rehydrateFromPrefs then updates the LOCAL store\n // to the saved sprite, but joinedRef pins the join — without this\n // reassert, other clients keep seeing the auto-assigned sprite\n // forever. Bug 1 from the 2026-05-20 cedar-ridge retrospective\n // (Coz + LittleMiss with wrong avatars).\n // Each field is gated independently: only emit it if the local store has a\n // non-default value AND the server's roster entry for us still shows a\n // different value, so once the server reflects us later snapshots are\n // no-ops. That entry comes from the STORE roster (maintained by the\n // `presence` handler) — it used to be looked up in `payload.users`, which\n // the snapshot has never carried, so the lookup was permanently undefined\n // and every client with a saved identity re-emitted on every snapshot.\n // Rule + regression tests: @wolffm/rooms-protocol resync.ts.\n const stAfter = useAppStore.getState();\n const myClientId = socket?.id;\n const myEntry = myClientId ? stAfter.roster[myClientId] : undefined;\n // The rule lives in @wolffm/rooms-protocol (resync.ts) so both ends of the\n // room layer agree on it. It speaks `avatarId`; the store and the WIRE both\n // say `sprite`, so the mapping happens here at the edge rather than\n // spreading watchparty's vocabulary into the package.\n // Before the first presence there is no roster entry, and the server's\n // view of us is what the join just carried: asserting that again only\n // repeats a request it has already answered (with a sprite_taken, when\n // the joined sprite was held — which used to start a retry cascade into\n // identity.rate_limit). Only what changed SINCE the join is asserted.\n const known = myEntry ?? joinedIdentity;\n const idResult = resolveIdentityReassert(\n { name: stAfter.name, avatarId: stAfter.sprite },\n known ? { name: known.name, avatarId: known.sprite ?? null } : undefined,\n // A join that carried no name counts as a default one, so a name that\n // landed after the join (prefs) is still asserted.\n (n) => n === '' || isCharacterName(n),\n );\n if (idResult) {\n const idPayload = {\n ...(idResult.name !== undefined ? { name: idResult.name } : {}),\n ...(idResult.avatarId !== undefined && !spriteHeldByOwnOtherConnection ? { sprite: idResult.avatarId } : {}),\n };\n if (Object.keys(idPayload).length > 0) {\n debug('socket', 'Identity update on snapshot', idPayload);\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, idPayload);\n }\n }\n\n debug('socket', 'Received snapshot', {\n mediaId: payload.media_id ?? null,\n playheadMs: payload.playhead_ms || 0,\n playing: isServerPlaying(payload),\n leaderId: payload.leader_id || null,\n serverSeq: payload.server_seq || 0,\n rosterSize: Object.keys(stAfter.roster).length,\n });\n\n // Set room context for client log correlation with server session logs\n if (lastJoinPayload?.room_key && socket?.id) {\n setLogRoomContext({ room_key: lastJoinPayload.room_key, client_id: socket.id });\n }\n });\n\n // control broadcasts (simplified before MSE): { server_seq, playhead_ms, playing, leader_id? }\n s.on(CORE_EVENTS.CONTROL_BROADCAST, (msg: ControlBroadcastPayload) => {\n const state = useAppStore.getState();\n // If media_id present and changed, promote it into snapshot + reset readiness gate\n if (Object.prototype.hasOwnProperty.call(msg, 'media_id')) {\n const newMediaId = msg.media_id ?? null;\n if (state.snapshot.mediaId !== newMediaId) {\n state.applySnapshot({ mediaId: newMediaId });\n // Reset readiness (server will emit fresh ready_state after clients append)\n if (newMediaId) state.setReadiness({ mediaId: newMediaId, ready: false, readyCount: 0, total: 0 });\n // Bump generation so the seek effect re-fires even when playheadMs doesn't\n // change (e.g. 0 → 0 on same-media reload). Only bump on actual media\n // changes — bumping on every play/pause/seek broadcast (which all include\n // media_id) disables the DEFAULT_DRIFT_SOFT_MS deduplication guard in\n // usePlaybackSync, causing cascading seek preemptions on multi-viewer seeks.\n state.bumpLoadGeneration();\n }\n }\n // Server sends play_state: 'playing'|'paused', convert to boolean\n state.updateControlState({\n playheadMs: msg.playhead_ms,\n playing: isServerPlaying(msg),\n serverSeq: msg.server_seq,\n leaderId: msg.leader_id ?? state.snapshot.leaderId\n });\n debug('socket', 'Control broadcast received', {\n kind: msg.kind, serverSeq: msg.server_seq, playheadMs: msg.playhead_ms,\n playState: msg.play_state, mediaId: msg.media_id,\n });\n });\n\n // control ack (map client_seq -> server_seq)\n s.on(CORE_EVENTS.CONTROL_ACK, (ack: ControlAckPayload) => {\n if (typeof ack?.client_seq === 'number' && typeof ack?.server_seq === 'number') {\n debug('socket', 'Control ack received', ack);\n }\n });\n\n // time_pong: server response to our time_ping { server_ts, client_sent_ts }\n s.on(CORE_EVENTS.TIME_PONG, (p: TimePongPayload) => {\n // Any pong proves the connection carries traffic — even one too late for\n // an RTT sample.\n notePong(liveness, Date.now());\n if (timePingInflight && timePingInflight.ts) {\n const rtt = Date.now() - timePingInflight.ts;\n useAppStore.getState().addRttSample(rtt, p?.server_ts, timePingInflight.ts);\n timePingInflight = null;\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n debug('socket', 'Time sync RTT', { rtt, offset: useAppStore.getState().drift.offsetMs });\n }\n });\n\n // ready_state: server-authoritative readiness broadcast\n s.on(CORE_EVENTS.READY_STATE, (payload: ReadyStatePayload) => {\n if (!payload) return;\n const mediaId = payload.media_id ?? null;\n const readyCount = typeof payload.ready_count === 'number' ? payload.ready_count : 0;\n const total = typeof payload.total === 'number' ? payload.total : 0;\n const ready = payload.ready === true;\n useAppStore.getState().setReadiness({ mediaId, ready, readyCount, total });\n debug('socket', 'Ready state received', { mediaId, readyCount, total, ready });\n });\n}\n\nfunction wirePresence(s: Socket): void {\n s.on(CORE_EVENTS.PRESENCE, (ev: PresencePayload) => {\n if (!ev) return;\n // Server currently emits { users: [...] }\n if (Array.isArray(ev.users)) {\n const roster: Record<string, RosterUser> = {};\n for (const u of ev.users) {\n roster[u.client_id] = {\n clientId: u.client_id,\n ...(u.author ? { author: u.author } : {}),\n name: u.name || 'Anon',\n sprite: u.sprite_id ?? u.sprite ?? null,\n isLeader: u.is_leader,\n ...(u.registry_name ? { registryName: true } : {}),\n ...(u.avatar_url ? { avatarUrl: u.avatar_url } : {}),\n };\n }\n // Server is authoritative for sprite assignments - update local state to match\n const st = useAppStore.getState();\n const myId = socket?.id;\n const mine = myId ? roster[myId] : undefined;\n if (mine?.registryName) {\n // The server named us from the key registry: that IS our name, and it\n // never takes ours instead (ws/join.ts). Adopt it, and the sprite with\n // it. The roster goes in FIRST so the store-change re-assert below sees\n // the server already agreeing and stays quiet.\n useAppStore.setState({ roster });\n if (mine.name !== st.name || (mine.sprite && mine.sprite !== st.sprite)) {\n debug('socket', 'Adopting registry name', { from: st.name, to: mine.name });\n useAppStore.getState().setSessionIdentity(mine.name, mine.sprite ?? st.sprite);\n }\n } else if (myId && roster[myId]) {\n const serverName = roster[myId].name;\n const serverSprite = roster[myId].sprite;\n // Keep roster display name in sync with local custom name\n if (st.name && !isCharacterName(st.name)) {\n roster[myId].name = st.name; // user chose a custom name\n }\n // The roster goes in FIRST, as in the registry branch: the store-change\n // reassert then sees the server agreeing with the sprite adopted below\n // and stays quiet, instead of sending it straight back.\n useAppStore.setState({ roster });\n // Server is authoritative for sprite (prevents duplicates), but name is user-owned.\n // If the server reassigned our sprite (e.g., another user took ours during disconnect),\n // accept the new sprite but preserve a custom name the user set.\n if (serverSprite && serverSprite !== st.sprite) {\n const hasCustomName = st.name && !isCharacterName(st.name);\n // Use the display name for the newly-assigned sprite, not the server's\n // stale name (which may still reflect the old sprite's default name).\n // Custom names are always preserved.\n const nameToUse = hasCustomName ? st.name : displayNameFor(serverSprite);\n debug('socket', 'Server reassigned sprite', { from: st.sprite, to: serverSprite, newName: nameToUse, preservingCustomName: hasCustomName });\n useAppStore.getState().setSessionIdentity(nameToUse, serverSprite);\n // Re-assert the name so other clients see it immediately — unless the\n // server already shows it.\n if (nameToUse !== serverName) s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: nameToUse });\n }\n }\n }\n debug('socket', 'Received presence update', ev);\n // If we still have no identity after presence, attempt assignment\n const stFinal = useAppStore.getState();\n if (!stFinal.name) {\n stFinal.ensureAutoIdentity?.();\n const st3 = useAppStore.getState();\n if (st3.name) {\n debug('socket', 'Auto-assigned identity on presence', { name: st3.name, sprite: st3.sprite });\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: st3.name, sprite: st3.sprite || undefined });\n }\n }\n });\n}\n\nfunction wireChat(s: Socket): void {\n // chat_broadcast: { id, client_id, text, name, sprite, ts, isSystem?, systemType? }\n s.on(CORE_EVENTS.CHAT_BROADCAST, (m: ChatBroadcastPayload) => {\n const chatMsg = normalizeChatMessage(m, m.server_seq);\n useAppStore.getState().appendChat(chatMsg);\n debug('chat', 'Message received', chatMsg);\n });\n\n // chat_history: { messages: [...] } - sent on join\n s.on(CORE_EVENTS.CHAT_HISTORY, (data: ChatHistoryPayload) => {\n if (!data?.messages || !Array.isArray(data.messages)) return;\n const messages: ChatMessage[] = data.messages.map((m) => normalizeChatMessage(m));\n useAppStore.getState().setChat(messages);\n debug('chat', 'History loaded', { count: messages.length });\n });\n}\n\nfunction wirePlaylist(s: Socket): void {\n s.on(PLAYLIST_EVENTS.STATE, (payload: PlaylistStatePayload) => {\n useAppStore.getState().applyPlaylistState({\n playlist: payload?.playlist ?? null,\n activeIndex: payload?.activeIndex ?? null,\n myVotes: payload?.myVotes ?? {},\n });\n debug('playlist', 'Playlist state received', { playlistId: payload?.playlist?.id ?? null, activeIndex: payload?.activeIndex, myVoteCount: Object.keys(payload?.myVotes || {}).length });\n });\n\n s.on(PLAYLIST_EVENTS.VOTE_PROGRESS, (payload: PlaylistVoteProgressPayload) => {\n useAppStore.getState().setVoteProgress(payload?.progress ?? []);\n debug('playlist', 'Vote progress', { progressCount: payload?.progress?.length });\n });\n\n s.on(PLAYLIST_EVENTS.RESULTS, (payload: PlaylistResultsPayload) => {\n useAppStore.getState().setPlaylistResults(payload ?? null);\n debug('playlist', 'Results received', { playlistId: payload?.playlist?.id, update: payload?.update === true });\n });\n\n // The leader removed us from the results: drop the ballot here too, or the\n // restore effect re-sends it on the next playlist load.\n // On rejoin this arrives BEFORE playlist_state, so the store may not know the\n // playlist yet — clear from the ids in the payload, never from the store.\n s.on(PLAYLIST_EVENTS.VOTES_CLEARED, (payload: PlaylistVotesClearedPayload) => {\n if (!payload?.playlistId || !Array.isArray(payload.mediaIds)) return;\n const removed = clearStoredVotes(\n { id: payload.playlistId, videos: payload.mediaIds.map(mediaId => ({ mediaId })) },\n voteOwnerScope(),\n );\n const st = useAppStore.getState();\n if (st.playlist?.id === payload.playlistId) {\n st.applyPlaylistState({ playlist: st.playlist, activeIndex: st.activePlaylistIndex, myVotes: {} });\n }\n debug('playlist', 'Votes cleared by leader', { playlistId: payload.playlistId, removed });\n });\n}\n\nfunction wireTierlist(s: Socket): void {\n // A \"go re-fetch\" ping, not a payload. The shared board is identical for\n // everyone but `myBoard` is not, so one broadcast cannot serve both — each\n // client pulls its own view. `adoptDraft: false` because this fires when\n // SOMEONE ELSE saved: their result must land live, but replacing this\n // client's draft would yank cards out from under a drag in progress.\n s.on(TIERLIST_EVENTS.UPDATED, () => {\n debug('tierlist', 'Someone saved — refreshing the board');\n void refreshTierlist(false);\n });\n}\n\nfunction wireReadyCheck(s: Socket): void {\n // Server-authoritative state: { active: boolean, ready: clientId[] }.\n // Client never derives this locally — every transition is a broadcast.\n s.on(READY_EVENTS.STATE, (payload: ReadyCheckStatePayload) => {\n if (!payload || typeof payload.active !== 'boolean') return;\n const ready = Array.isArray(payload.ready) ? payload.ready.filter((x: unknown) => typeof x === 'string') : [];\n useAppStore.getState().setReadyCheckState({ active: payload.active, ready });\n debug('ready-check', 'State received', { active: payload.active, readyCount: ready.length });\n });\n}\n\n/** Leader-only emit — opens a fresh ready check. Server validates role. */\nexport function emitReadyCheckStart(): void {\n socket?.emit(READY_EVENTS.START, {});\n}\n\n/** Any-client emit — toggles current user's ready flag. */\nexport function emitReadyCheckToggle(ready: boolean): void {\n socket?.emit(READY_EVENTS.TOGGLE, { ready });\n}\n\n// ---- Waiting-room game ---------------------------------------------------\n\nfunction wireGame(s: Socket): void {\n // The game open in the room (null = none). Every game is a bundle that\n // talks to its own server; the room only says which one to mount.\n s.on(GAME_EVENTS.ACTIVE, (payload: GameActivePayload) => {\n const gameId = typeof payload?.gameId === 'string' ? payload.gameId : null;\n useAppStore.getState().setActiveGameId(gameId);\n debug('game', 'game_active', { gameId });\n });\n}\n\n/** Leader-only emit: open a game for the whole room, or close it with null.\n * Server broadcasts game_active back to everyone. */\nexport function emitGameSelect(gameId: string | null): void {\n socket?.emit(GAME_EVENTS.SELECT, { gameId });\n}\n\nfunction wireHealth(s: Socket): void {\n // health_snapshot: aggregated client health (leader only)\n s.on(CORE_EVENTS.HEALTH_SNAPSHOT, (payload: HealthSnapshotPayload) => {\n useAppStore.getState().setHealthSnapshot(payload);\n });\n // buffer_status: lightweight buffer data broadcast to all clients in room\n s.on(CORE_EVENTS.BUFFER_STATUS, (payload: BufferStatusPayload) => {\n useAppStore.getState().setBufferStatus(payload);\n });\n}\n\nfunction wireCoreHandlers(s: Socket): void {\n wireSync(s);\n wirePresence(s);\n wireChat(s);\n wireHealth(s);\n wirePlaylist(s);\n wireTierlist(s);\n wireReadyCheck(s);\n wireGame(s);\n\n // error: { code, message? }\n s.on(CORE_EVENTS.ERROR, (e: ErrorPayload) => {\n const code = e?.code || 'error/unknown';\n\n // Sprite conflict: silently retry with next available sprite from roster.\n // This fires when identity_update requests a sprite that's already claimed.\n // The refusal's other reason: the sprite is held by ANOTHER CONNECTION OF THE SAME\n // ACCOUNT (a second browser). Accept what the server gave us; re-asking is what\n // tripped sprite_taken in the first place.\n if (code === 'conflict' && e?.message === 'sprite_held_by_you') {\n spriteHeldByOwnOtherConnection = true;\n debug('socket', 'sprite held by this account\\'s other connection: keeping the server-assigned sprite');\n return;\n }\n if (code === 'conflict' && e?.message === 'sprite_taken') {\n // A refusal can be stale. Until the first presence we have no seat to\n // compare against — the presence itself brings the server's pick, which\n // the handler above adopts. And once our sprite matches the server's,\n // the refusal answered an earlier ask the server has since settled.\n // Retrying in either case asked for a sprite nobody needed, drew a fresh\n // refusal, and cascaded into identity.rate_limit.\n if (isStaleSpriteRefusal(socket?.id)) {\n debug('socket', 'sprite_taken: stale, the server already has our sprite');\n return;\n }\n // The retry emits its own payload; the store-change reassert must not\n // send the new sprite a second time.\n identityEmitInHand = true;\n let retry: ReturnType<typeof retryTakenSprite>;\n try {\n retry = retryTakenSprite();\n } finally {\n identityEmitInHand = false;\n }\n if (retry) s.emit(CORE_EVENTS.IDENTITY_UPDATE, retry);\n return;\n }\n\n const map: Record<string, string> = {\n 'control/forbidden': 'You are not the leader.',\n 'control/stale_seq': 'Stale control sequence.',\n 'control/seq_gap': 'Control sequence gap detected.',\n 'rate/limited': 'Rate limited. Slow down.',\n 'payload/too_large': 'Payload too large.',\n 'room/full': 'Room is full.',\n 'auth/invalid_key': 'Invalid room key.',\n 'auth/leader_occupied': 'A leader is already connected to this room.',\n 'auth/friend_required': 'Sign in to join this room.',\n 'origin_block': 'Origin not allowed.',\n 'conflict': 'Character is already taken by another user.',\n 'recap/no_media': 'Load an episode first.',\n 'recap/no_previous': 'This is the first episode of the season — nothing to recap.',\n 'recap/not_episode': 'Recaps are only available for episodes.',\n 'recap/not_found': 'No recap available for the previous episode.'\n };\n const message = map[code] || code;\n // recap/* are informational outcomes of a command the user ran, not\n // faults: \"this is the first episode\" is an answer, and an error toast\n // (plus an error-level log) overstates it.\n if (code.startsWith('recap/')) {\n debug('socket', `recap unavailable: ${message}`, { code });\n useAppStore.getState().pushToast('info', message);\n return;\n }\n // An access refusal the UI shows (auth/*: invalid key, leader occupied,\n // friend required) is an expected outcome of a room visit, not a client\n // fault — it fires in normal use (a stale link, a signed-out visitor)\n // and in tests that deliberately provoke it (room-chrome's unprovisioned-\n // room-without-a-friend-key case). Logging it at error() forwards to the\n // server at pino ERROR, which scripts/e2e.js's summary.txt error count\n // fails the WHOLE SUITE on (test-runner.js: serverErrors !== 0) — outside\n // and unfiltered by the per-test/per-suite warn allowlist, which only\n // ever sees warn-level entries. Every other server error code keeps\n // logging at error(); only auth/* is downgraded (2026-10-01, board H7).\n const logRefusal = code.startsWith('auth/') ? warn : error;\n logRefusal('socket', `Server error: ${message}`, { code, raw: e });\n useAppStore.getState().pushToast('error', message);\n });\n}\n\n// outbound helpers\nexport function sendChat(text: string, images?: TChatImage[]) {\n // `images` is omitted rather than sent as [] so a text-only message is the\n // exact payload it was before attachments existed.\n getSocket().emit(CORE_EVENTS.CHAT_SEND, images?.length ? { text, images } : { text });\n}\n\nfunction initiateTimePing() {\n if (timePingInflight) return; // wait for prior\n timePingInflight = { ts: Date.now() };\n getSocket().emit(CORE_EVENTS.TIME_PING, {});\n // Clear inflight after 10s if pong never arrives, so future pings aren't blocked forever\n timePingTimeout = setTimeout(() => {\n if (timePingInflight) {\n debug('socket', 'Time ping timed out after 10s, clearing inflight');\n timePingInflight = null;\n }\n timePingTimeout = null;\n }, 10_000);\n}\n\n/**\n * The fleet settings popout changed this person's avatar (`hadoku:avatar`,\n * lib/avatar.ts). `sprite` is the character they now ask for, or null.\n *\n * Not joined yet: the identity is adopted and the join carries it. Joined: ONE\n * identity_update, carrying the new character (the room's uniqueness rules\n * still decide; a taken one falls back exactly as a join's does) and\n * `avatar: true`, which makes the server re-read the picture fresh — so a\n * picture-only change, with the same character, reaches the room too.\n */\nexport function sendAvatarChange(sprite: string | null): void {\n const before = useAppStore.getState();\n const myId = before.me.clientId;\n if (!myId || !before.roster[myId]) {\n adoptRequestedSprite(sprite);\n debug('socket', 'Avatar change before join: the join will carry it', { sprite });\n return;\n }\n // This module emits the change itself; the store-change reassert must not\n // send a second copy of it.\n identityEmitInHand = true;\n try {\n adoptRequestedSprite(sprite);\n } finally {\n identityEmitInHand = false;\n }\n const after = useAppStore.getState();\n const payload: { name?: string; sprite?: string; avatar: true } = { avatar: true };\n if (after.sprite && after.sprite !== before.sprite) payload.sprite = after.sprite;\n // A character name follows the character; a registry name never changes here.\n if (after.name && after.name !== before.name) payload.name = after.name;\n debug('socket', 'Sending avatar change', payload);\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n}\n\nexport function sendIdentityUpdate(name?: string, sprite?: string | null) {\n const payload: { name?: string; sprite?: string } = {};\n if (name) payload.name = name;\n if (sprite) payload.sprite = sprite;\n if (!payload.name && !payload.sprite) return;\n debug('socket', 'Sending identity update', payload);\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n}\n\n// Identity-reassert subscription: when the local store's name or sprite\n// changes AFTER we've joined a room, push the change to the server so\n// other clients' rosters update. Without this, a fresh-device direct\n// /room/{key} link races:\n// - Room.tsx fires connectAndJoin BEFORE syncFromServerIfFirstLoad\n// resolves (the prefs fetch is async, the join is sync-from-mount).\n// - Server auto-assigns a sprite; presence broadcasts it to everyone.\n// - Later, /api/me/prefs lands; rehydrateFromPrefs sets local sprite\n// to the saved one. But joinedRef in Room.tsx is now true — no\n// re-join fires, and the snapshot reassert handler can't help\n// because no new snapshot lands (it's only pushed on server events).\n// Bug 1 from the 2026-05-20 cedar-ridge retrospective.\n//\n// Guards: only emit if we're actually joined (me.clientId is in our own\n// roster, set by applySnapshot post-join) AND the new value differs from\n// the server roster's view of us (avoids emitting on the join's own\n// snapshot-applied update). Idempotent server-side anyway.\nlet _lastReasserted = { name: '', sprite: '' as string | null };\nuseAppStore.subscribe((state, prev) => {\n if (state.name === prev.name && state.sprite === prev.sprite) return;\n if (identityEmitInHand) return;\n const myId = state.me.clientId;\n if (!myId) return;\n const myEntry = state.roster[myId];\n if (!myEntry) return; // not yet joined\n const wantsName = !!state.name\n && !isCharacterName(state.name)\n && state.name !== _lastReasserted.name\n && (isCharacterName(myEntry.name || '') || myEntry.name !== state.name);\n const wantsSprite = !!state.sprite\n && state.sprite !== _lastReasserted.sprite\n && myEntry.sprite !== state.sprite;\n if (!wantsName && !wantsSprite) return;\n const payload: { name?: string; sprite?: string } = {};\n if (wantsName) { payload.name = state.name; _lastReasserted.name = state.name; }\n if (wantsSprite) { payload.sprite = state.sprite!; _lastReasserted.sprite = state.sprite!; }\n debug('socket', 'Identity reassert (store change)', payload);\n if (socket?.connected) {\n socket.emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n } else {\n // Buffered emit fires once connected.\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n }\n});\n\n/** Clear time-sync timers. Call on navigation/unmount to prevent leaked intervals. */\nfunction cleanupTimers() {\n if (timePingInterval) { clearInterval(timePingInterval); timePingInterval = null; }\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n timePingInflight = null;\n liveness.unansweredSince = null;\n liveness.lastTickAt = null;\n}\n\n/** Clear stored join payload. Call on intentional disconnect to prevent auto-rejoin. */\nfunction clearJoinState(): void {\n debug('socket', 'clearJoinState called', { hadPayload: lastJoinPayload !== null, room_key: lastJoinPayload?.room_key });\n lastJoinPayload = null;\n hasConnectedOnce = false;\n lastReadyMediaId = null;\n}\n\n/**\n * Leave the current room: clear join state, stop timers, disconnect the\n * socket, and reset room-session store state.\n *\n * The disconnect is the important part — without it the socket stays open,\n * the server keeps counting us in the room's presence, and the Home page\n * (which reads /api/rooms) shows our avatar until a manual refresh. Nulling\n * the socket + resetting `me`/`roster` also ensures a later re-join emits a\n * fresh `join` rather than tripping connectAndJoin's \"already joined\" guard.\n */\nexport function leaveRoom(): void {\n debug('socket', 'leaveRoom called', { room_key: lastJoinPayload?.room_key });\n clearJoinState();\n cleanupTimers();\n if (socket) {\n socket.disconnect();\n socket = null;\n }\n useAppStore.setState({\n me: { clientId: null, nextClientSeq: 1 },\n roster: {},\n snapshot: { roomId: null, mediaId: null, playheadMs: 0, playing: false, serverSeq: 0, leaderId: null },\n chat: [],\n playlist: null,\n activePlaylistIndex: null,\n myVotes: {},\n voteProgress: [],\n playlistResults: null,\n readiness: { mediaId: null, ready: false, readyCount: 0, total: 0 },\n readyCheck: { active: false, ready: [] },\n activeGameId: null,\n });\n}\n\n// ── Playlist outbound emitters ──────────────────────────────────────────────\n\nexport function emitPlaylistLoad(playlistId: string): void {\n debug('playlist', 'Emitting playlist_load', { playlistId });\n getSocket().emit(PLAYLIST_EVENTS.LOAD, { playlistId });\n}\n\nexport function emitPlaylistSelect(index: number): void {\n debug('playlist', 'Emitting playlist_select', { index });\n getSocket().emit(PLAYLIST_EVENTS.SELECT, { index });\n}\n\nexport function emitVoteUpdate(playlistId: string, mediaId: string, vote: TVoteValue): void {\n debug('playlist', 'Emitting playlist_vote_update', { playlistId, mediaId, vote });\n getSocket().emit(PLAYLIST_EVENTS.VOTE_UPDATE, { playlistId, mediaId, vote });\n}\n\n/**\n * Ask the server to broadcast the previous episode's recap to the room.\n *\n * The server owns the whole decision — which episode is \"previous\", whether a\n * recap was prebuilt, and the rendering — because it is the only side that can\n * see the media tree. The client just asks. Failures come back as a normal\n * socket `error` with a recap/* code.\n */\nexport function emitRecapRequest(): void {\n debug('chat', 'Emitting recap_request');\n getSocket().emit('recap_request');\n}\n\nexport function emitRevealRequest(): void {\n debug('playlist', 'Emitting playlist_reveal_request');\n getSocket().emit(PLAYLIST_EVENTS.REVEAL_REQUEST, {});\n}\n\n/** Leader: remove one voter (a key of the results' `votes`) from the reveal. */\nexport function emitVoteRemove(voterKey: string): void {\n debug('playlist', 'Emitting playlist_vote_remove', { voterKey });\n getSocket().emit(PLAYLIST_EVENTS.VOTE_REMOVE, { voterKey });\n}\n\n\n\n/**\n * Emit client_ready and remember the mediaId so it can be re-emitted on reconnect.\n * Call this instead of getSocket().emit('client_ready', ...) directly.\n * On reconnect, the server loses the ready state for the old socket ID.\n * Re-emitting ensures the readiness count is restored without requiring a full re-init.\n */\nexport function emitClientReady(mediaId: string): void {\n lastReadyMediaId = mediaId;\n getSocket().emit(CORE_EVENTS.CLIENT_READY, { media_id: mediaId });\n debug('socket', 'client_ready emitted', { mediaId });\n}\n","import { useEffect } from 'react';\nimport { listenForAvatarChanges, requestedSpriteOf } from '../lib/avatar';\nimport { sendAvatarChange } from '../lib/socket';\nimport { debug } from '../lib/debug';\n\n/**\n * Carry a settings-popout avatar change into the room live (character and\n * picture), for as long as the room is mounted. Used by both `Room` and the\n * embeddable `RoomChrome`; the listener is removed on unmount, which the\n * chrome's contract with its host requires (e2e/room-chrome.spec.ts).\n */\nexport function useAvatarChanges(): void {\n useEffect(() => listenForAvatarChanges((settings) => {\n const sprite = requestedSpriteOf(settings);\n debug('avatar', 'Avatar changed in the settings popout', { sprite, source: settings.avatar?.source });\n sendAvatarChange(sprite);\n }), []);\n}\n","import { useEffect, useSyncExternalStore } from 'react';\nimport { colorForSprite } from './characters';\nimport { debug } from './debug';\n\n/**\n * A person's colour — their chat name, their ring in the roster, the ready\n * check and the buffer rings. With an avatar picture it is the picture's\n * average colour; otherwise it is their character's colour, which was itself\n * picked off the portrait the same way.\n *\n * The picture is read through a canvas, so it is loaded once more with\n * `crossOrigin=\"anonymous\"`. Discord and Steam both answer\n * `Access-Control-Allow-Origin: *`; a host that does not taints the canvas,\n * and that person keeps their character's colour. One read per URL per page,\n * cached, failures included.\n */\n\n/** Lightness bounds: a black or white picture must still read as a name on a card. */\nconst MIN_LIGHTNESS = 0.3;\nconst MAX_LIGHTNESS = 0.82;\n/** The canvas the picture is averaged on. Small on purpose: an average needs no detail. */\nconst SAMPLE_SIZE = 24;\n/** Below this alpha a pixel is background, not picture. */\nconst MIN_ALPHA = 32;\n\n/**\n * The alpha-weighted mean of RGBA pixels as `#rrggbb`, lightness clamped to\n * the readable band. Null when no pixel is opaque enough to count.\n */\nexport function averageColor(pixels: ArrayLike<number>): string | null {\n let r = 0, g = 0, b = 0, weight = 0;\n for (let i = 0; i + 3 < pixels.length; i += 4) {\n const a = pixels[i + 3];\n if (a < MIN_ALPHA) continue;\n r += pixels[i] * a;\n g += pixels[i + 1] * a;\n b += pixels[i + 2] * a;\n weight += a;\n }\n if (weight === 0) return null;\n return clampLightness(r / weight, g / weight, b / weight);\n}\n\nfunction clampLightness(r: number, g: number, b: number): string {\n const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255);\n const clamped = Math.min(MAX_LIGHTNESS, Math.max(MIN_LIGHTNESS, l));\n const [r2, g2, b2] = hslToRgb(h, s, clamped);\n const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0');\n return `#${hex(r2)}${hex(g2)}${hex(b2)}`;\n}\n\nfunction rgbToHsl(r: number, g: number, b: number): [number, number, number] {\n const max = Math.max(r, g, b), min = Math.min(r, g, b);\n const l = (max + min) / 2;\n if (max === min) return [0, 0, l];\n const d = max - min;\n const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n let h: number;\n if (max === r) h = (g - b) / d + (g < b ? 6 : 0);\n else if (max === g) h = (b - r) / d + 2;\n else h = (r - g) / d + 4;\n return [h / 6, s, l];\n}\n\nfunction hslToRgb(h: number, s: number, l: number): [number, number, number] {\n if (s === 0) return [l, l, l];\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n const channel = (t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];\n}\n\n/** url → colour once read; null when it could not be (no CORS, failed load). */\nconst colors = new Map<string, string | null>();\nconst pending = new Set<string>();\nconst listeners = new Set<() => void>();\n\nfunction notify(): void {\n for (const l of listeners) l();\n}\n\nfunction readPicture(url: string): void {\n if (colors.has(url) || pending.has(url)) return;\n if (typeof Image === 'undefined' || typeof document === 'undefined') return;\n pending.add(url);\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.referrerPolicy = 'no-referrer';\n const settle = (color: string | null) => {\n pending.delete(url);\n colors.set(url, color);\n notify();\n };\n img.onload = () => {\n try {\n const canvas = document.createElement('canvas');\n canvas.width = SAMPLE_SIZE;\n canvas.height = SAMPLE_SIZE;\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n if (!ctx) { settle(null); return; }\n ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);\n settle(averageColor(ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data));\n } catch (err: unknown) {\n // A tainted canvas: the host sent no CORS header. The character's colour stands.\n debug('avatar', 'picture colour unreadable', { url, error: err instanceof Error ? err.message : String(err) });\n settle(null);\n }\n };\n img.onerror = () => settle(null);\n img.src = url;\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n return () => listeners.delete(listener);\n}\n\n/** The colour for someone with this character and (optional) picture, as far\n * as is known now: until the picture has been read, the character's. */\nexport function personColor(sprite: string | null | undefined, url: string | undefined): string {\n if (url) {\n const read = colors.get(url);\n if (read) return read;\n }\n return colorForSprite(sprite);\n}\n\n/** {@link personColor}, re-rendering once the picture's colour has been read. */\nexport function usePersonColor(sprite: string | null | undefined, url: string | undefined): string {\n useEffect(() => { if (url) readPicture(url); }, [url]);\n return useSyncExternalStore(subscribe, () => personColor(sprite, url), () => colorForSprite(sprite));\n}\n","import type { RosterUser } from './store';\n\n/**\n * Which picture a chat line shows. A message carries its sender's character\n * as of sending (`sprite`), but not their picture: that is looked up from the\n * roster by participant handle, so a line shows the sender's CURRENT picture\n * while they are here and falls back to the character once they have left.\n */\nexport function pictureForAuthor(roster: Record<string, RosterUser>, author: string | undefined): string | undefined {\n if (!author) return undefined;\n for (const u of Object.values(roster)) {\n if (u.author === author && u.avatarUrl) return u.avatarUrl;\n }\n return undefined;\n}\n","import { useState, type CSSProperties, type ReactNode } from 'react';\nimport { spriteUrlFor } from '../lib/characters';\n\ninterface Props {\n /** The person's character: the portrait, and the fallback for `url`. */\n sprite: string | null | undefined;\n /** Their avatar picture (presence `avatar_url`), preferred when present. */\n url?: string;\n alt?: string;\n className: string;\n style?: CSSProperties;\n /** Shown when there is neither a picture nor a character. */\n fallback: ReactNode;\n}\n\n/**\n * Who someone is, as a picture: their avatar picture (Discord, Steam, an\n * upload) when they have one, else their character portrait.\n *\n * The picture is a third-party URL loaded by the browser alone — no referrer,\n * no CORS mode (it is never read back, so it needs none) — and one that fails\n * falls back to the portrait for as long as that URL stays the same. Games\n * never use this: their piece is always the character.\n */\nexport default function AvatarImage({ sprite, url, alt = '', className, style, fallback }: Props) {\n const [failedUrl, setFailedUrl] = useState<string | null>(null);\n if (url && url !== failedUrl) {\n return (\n <img\n src={url}\n alt={alt}\n referrerPolicy=\"no-referrer\"\n className={className}\n style={style}\n draggable={false}\n onError={() => setFailedUrl(url)}\n />\n );\n }\n const portrait = spriteUrlFor(sprite);\n if (!portrait) return <>{fallback}</>;\n return <img src={portrait} alt={alt} crossOrigin=\"anonymous\" className={className} style={style} draggable={false} />;\n}\n","/**\n * What a click on a chat image means.\n *\n * The attachment stays a real `<a href>`, and that is the whole trick. Middle\n * click, ctrl/cmd+click, shift+click and the right-click context menu are\n * browser behaviours on anchors that no handler has to reimplement — and\n * reimplementing them is how you end up with a \"new tab\" that ignores the\n * user's modifier conventions. We only intercept the ONE gesture we want to\n * repurpose, the plain left click, and let the anchor keep the rest.\n *\n * Concretely, on an `<a>`:\n *\n * | gesture | fires | result |\n * | -------------------- | ------------ | ----------------------------- |\n * | left click | `click` | intercepted -> lightbox |\n * | middle click | `auxclick` | never reaches us -> new tab |\n * | ctrl / cmd + click | `click` | NOT intercepted -> new tab |\n * | shift + click | `click` | NOT intercepted -> new window |\n * | right click | `contextmenu`| never reaches us -> menu |\n *\n * Middle click is the case the user asked for explicitly, and it is the one\n * that needs no code at all — a `click` handler simply never sees it. The\n * modifier cases are here because intercepting them would silently break\n * habits people already have.\n */\n\n/** The parts of a mouse event this decision reads. */\nexport interface ClickIntent {\n /** 0 = primary/left. `auxclick` (middle) never reaches a `click` handler. */\n button: number;\n ctrlKey: boolean;\n metaKey: boolean;\n shiftKey: boolean;\n altKey: boolean;\n}\n\n/**\n * True when the click should open the in-page lightbox (and the anchor's\n * default navigation must be prevented).\n *\n * False means \"let the browser do its normal thing with this anchor\" — which\n * covers every modifier gesture above, so the fallback is always the\n * previously-shipped behaviour rather than nothing.\n */\nexport function shouldOpenLightbox(e: ClickIntent): boolean {\n if (e.button !== 0) return false;\n // Any modifier is a deliberate \"not the normal action\" signal.\n if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;\n return true;\n}\n","import React, { useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { usePortalTarget } from '../lib/portalTarget';\n\n/**\n * Full-view for a chat image, Discord-style: the image pops out above the rest\n * of the site on a plain left click, and everything else about the anchor\n * (middle click, ctrl+click, right-click menu) keeps working — see\n * `lib/chatMedia/imageClickIntent.ts`.\n *\n * **Portals into the fullscreen element when there is one.** Fullscreen is\n * requested on the MediaPlayer root (`Room.tsx`), and only that element and its\n * descendants paint — so a lightbox appended to `document.body` while the video\n * is fullscreen renders to nothing at all. Chat is reachable in fullscreen via\n * `FloatingChat`, so a meme clicked there has to open somewhere visible. This\n * is the same problem `FullscreenPortal` solves for the overlays, but the\n * target is resolved per-open rather than by wrapping, because the lightbox is\n * mounted from a leaf component rather than a sibling of the player.\n *\n * Otherwise it goes to the `PortalTargetContext` target (the room chrome's own\n * root when embedded), falling back to `document.body`.\n *\n * `z-[10000]` is the top of this app's ladder: the point of the feature is to\n * sit above everything.\n */\n\nexport interface ChatImageLightboxProps {\n src: string;\n /** The library title when the image has one; used as the caption and alt. */\n title?: string;\n onClose: () => void;\n}\n\nexport default function ChatImageLightbox({ src, title, onClose }: ChatImageLightboxProps) {\n // Escape closes, and the listener is on `document` so it works no matter\n // where focus went — a click on the backdrop does not focus anything.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n // Stop the key reaching anything behind it. Escape is also fullscreen's\n // own exit gesture, so without this, closing the lightbox in fullscreen\n // drops the viewer out of fullscreen at the same time.\n e.stopPropagation();\n onClose();\n }\n };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, [onClose]);\n\n // The video keeps playing behind the lightbox, deliberately — this is a\n // watch party, and pausing the room because one person looked at a meme\n // would be a synchronized-playback event for everybody.\n const scoped = usePortalTarget();\n const target =\n typeof document === 'undefined' ? null : (document.fullscreenElement ?? scoped ?? document.body);\n if (!target) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-black/85 p-6\"\n onClick={onClose}\n data-testid=\"chat-image-lightbox\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={title || 'Image'}\n >\n <img\n src={src}\n alt={title ?? ''}\n // Clicking the image itself must NOT close: people click to zoom in on\n // detail, and a click-through-to-close makes the image feel like a\n // trap door.\n onClick={(e) => e.stopPropagation()}\n className=\"max-h-full max-w-full rounded object-contain shadow-2xl\"\n data-testid=\"chat-image-lightbox-img\"\n />\n {title && (\n <p className=\"mt-3 max-w-full truncate text-center text-sm text-white/80\">{title}</p>\n )}\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close image\"\n className=\"absolute right-4 top-4 rounded px-3 py-1 text-2xl leading-none text-white/70 hover:text-white focus:outline-none focus:ring-1 focus:ring-white/50\"\n >\n &times;\n </button>\n </div>,\n target,\n );\n}\n","import React, { useState } from 'react';\nimport { type TChatImage } from '@watchparty/shared';\nimport { resolveChatImageUrl } from '../lib/chatMedia';\nimport { shouldOpenLightbox } from '../lib/chatMedia/imageClickIntent';\nimport ChatImageLightbox from './ChatImageLightbox';\n\n/**\n * Renders a chat message's image attachments.\n *\n * ONE component for both chat surfaces (`ChatMessage` in the sidebar and\n * `FloatingMessage` in the fullscreen overlay). Those two already re-implement\n * the avatar/name/text row separately, and that duplication is exactly how a\n * fix lands in one and not the other — attachments do not repeat it.\n *\n * Two rules live here and nowhere else:\n *\n * 1. **Compose the src from the KEY.** The message carries a content-addressed\n * key; `chatImageUrl` re-validates its shape and returns `null` for\n * anything that does not match, so a hostile key renders nothing instead of\n * reaching an `<img src>`. Never interpolate `image.key` directly.\n *\n * 2. **Reserve the layout box before the bytes arrive.** The chat log sits\n * beside a synchronized video player, where a reflow on every image load is\n * very visible. `aspect-ratio` from the sender-measured `w`/`h` holds the\n * space from first paint. A sender that failed to measure sends 0×0; we\n * then fall back to natural flow rather than a collapsed box.\n */\n\n/** Cap the on-screen size so one tall paste can't push the room out of view. */\nconst MAX_H_PX = 240;\nconst MAX_W_PX = 320;\n\nfunction Attachment({ image }: { image: TChatImage }) {\n const [expanded, setExpanded] = useState(false);\n // The CDN base comes from the chat-media layer's own configuration rather\n // than the app's config context, so this component carries no watchparty\n // dependency and moves to the shared package unchanged.\n const src = resolveChatImageUrl(image.key);\n if (!src) return null;\n\n const hasDims = image.w > 0 && image.h > 0;\n // Fit inside the cap while preserving aspect, so the reserved box is the\n // size the image will actually paint at — reserving the intrinsic size\n // would leave a gap that collapses on load, which is the reflow again.\n const scale = hasDims ? Math.min(1, MAX_W_PX / image.w, MAX_H_PX / image.h) : 1;\n\n return (\n <>\n {/* Still a real anchor, deliberately. Middle click, ctrl/cmd+click and the\n right-click menu are then browser behaviours nothing has to\n reimplement — only the plain left click is intercepted, and the href\n remains the fallback for every gesture we do not claim. */}\n <a\n href={src}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className=\"block max-w-full cursor-zoom-in\"\n data-testid=\"chat-image\"\n onClick={(e) => {\n if (!shouldOpenLightbox(e)) return;\n e.preventDefault();\n setExpanded(true);\n }}\n >\n <img\n src={src}\n /* A meme carries the library's title, and it is frequently the ONLY\n text describing the image — a meme-heavy log is unusable with a\n screen reader without it. A pasted screenshot has no title, and an\n invented one would be worse than none, so it stays decorative. */\n alt={image.title ?? ''}\n loading=\"lazy\"\n decoding=\"async\"\n width={hasDims ? Math.round(image.w * scale) : undefined}\n height={hasDims ? Math.round(image.h * scale) : undefined}\n className=\"rounded border border-border-light object-contain bg-bg-alt/30\"\n style={\n hasDims\n ? { aspectRatio: `${image.w} / ${image.h}`, maxWidth: '100%', maxHeight: MAX_H_PX }\n : { maxWidth: '100%', maxHeight: MAX_H_PX }\n }\n />\n </a>\n {expanded && (\n <ChatImageLightbox\n src={src}\n title={image.title}\n onClose={() => setExpanded(false)}\n />\n )}\n </>\n );\n}\n\nexport default function ChatImageAttachments({ images }: { images?: TChatImage[] }) {\n if (!images?.length) return null;\n return (\n <div className=\"flex flex-wrap gap-1 mt-1\" data-testid=\"chat-images\">\n {/* Index in the key: the same image pasted twice is a legitimate\n message and would otherwise collide on a key-only React key. */}\n {images.map((im, i) => <Attachment key={`${im.key}-${i}`} image={im} />)}\n </div>\n );\n}\n","/**\n * Is this chat message mine?\n *\n * By AUTHOR (the sender's participant handle) when both sides have one, by\n * clientId only as a fallback. `clientId` is the socket id: it is reassigned on\n * every reconnect, so comparing on it alone made a viewer's own history render\n * as someone else's after any reload. The handle is derived from the person's\n * account (or device) and survives that.\n *\n * The fallback covers a server or stored history that predates `author`. It is\n * deliberately NOT applied when the message has an author and we know ours:\n * a mismatch there is a real \"not mine\", not missing data.\n */\nexport function isOwnMessage(\n message: { clientId: string; author?: string },\n me: { clientId: string | null; author?: string },\n): boolean {\n if (message.author && me.author) return message.author === me.author;\n return !!me.clientId && message.clientId === me.clientId;\n}\n","import React from 'react';\nimport type { ChatMessage as ChatMessageType} from '../lib/store';\nimport { useAppStore } from '../lib/store';\nimport { usePersonColor } from '../lib/pictureColor';\nimport { pictureForAuthor } from '../lib/avatarPictures';\nimport AvatarImage from './AvatarImage';\nimport ChatImageAttachments from './ChatImageAttachments';\nimport { isOwnMessage } from '../lib/chatOwnership';\n\ninterface Props {\n message: ChatMessageType;\n}\n\nfunction formatTime(ts: number): string {\n const d = new Date(ts);\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n}\n\nconst ChatMessage = React.memo(function ChatMessage({ message }: Props) {\n const myClientId = useAppStore(s => s.me.clientId);\n const myAuthor = useAppStore(s => (s.me.clientId ? s.roster[s.me.clientId]?.author : undefined));\n const isOwn = isOwnMessage(message, { clientId: myClientId, author: myAuthor });\n const pictureUrl = useAppStore(s => pictureForAuthor(s.roster, message.author));\n const nameColor = usePersonColor(message.sprite, pictureUrl);\n \n // A recap is CONTENT, not chrome. Every other system message is a one-line\n // status (\"Ada joined\", \"Now playing: X\") and the minimal styling below is\n // right for those — but a ~450-character recap paragraph rendered at 11px,\n // muted, italic and centred reads as noise and gets skipped over, which is\n // the whole point of having asked for it.\n if (message.isSystem && message.systemType === 'recap') {\n return (\n <div className=\"px-2 py-1.5\">\n <div className=\"border-l-2 border-border-light pl-2.5 py-0.5\">\n <div className=\"text-[10px] uppercase tracking-wide text-text-muted mb-0.5\">\n Recap\n <span className=\"ml-1.5 normal-case tracking-normal opacity-60\">\n {formatTime(message.ts)}\n </span>\n </div>\n <p className=\"text-[13px] text-text-secondary leading-relaxed whitespace-pre-wrap break-words\">\n {message.text}\n </p>\n </div>\n </div>\n );\n }\n\n // System messages render differently - minimal\n if (message.isSystem) {\n return (\n <div className=\"px-2 py-0.5 text-[11px] text-text-muted italic text-center leading-tight\">\n {message.text}\n <span className=\"ml-1 text-[10px] opacity-50\">{formatTime(message.ts)}</span>\n </div>\n );\n }\n\n return (\n <div className={`flex items-start gap-2 px-2 py-1 hover:bg-bg-card/20 ${isOwn ? 'bg-bg-card/30' : ''}`}>\n {/* Avatar */}\n <AvatarImage\n sprite={message.sprite}\n url={pictureUrl}\n alt={message.name}\n className=\"w-8 h-8 rounded-full object-cover flex-shrink-0\"\n fallback={<span className=\"w-8 h-8 bg-bg-alt/50 rounded-full flex-shrink-0\" />}\n />\n\n {/* Message content - inline layout like Discord */}\n <div className=\"flex-1 min-w-0 -mt-0.5\">\n <span className=\"font-medium text-[13px]\" style={{ color: nameColor }}>\n {message.name}\n </span>\n <span className=\"text-[10px] text-text-muted opacity-60 ml-1.5\">\n {formatTime(message.ts)}\n </span>\n {/* An image-only message has empty text; don't render an empty <p>. */}\n {message.text && (\n <p className=\"text-[13px] text-text break-words leading-snug m-0\">{message.text}</p>\n )}\n <ChatImageAttachments images={message.images} />\n </div>\n </div>\n );\n});\n\nexport default ChatMessage;\n","import React, { useRef, useEffect, useCallback } from 'react';\nimport { useAppStore } from '../lib/store';\nimport ChatMessage from './ChatMessage';\n\n/**\n * The scrolling message list: date breaks, stick-to-bottom unless the reader\n * scrolled up, and the empty state.\n *\n * ONE implementation for both chat columns — the app's `ChatPanel` and the\n * embeddable `RoomChrome`. The render path already has a second copy\n * (`FloatingChat`) and CLAUDE.md records what that costs; a third would be\n * the same drift with one more place for it.\n */\nexport default function ChatLog() {\n const chat = useAppStore(s => s.chat);\n const containerRef = useRef<HTMLDivElement>(null);\n const userScrolledUpRef = useRef(false);\n\n const handleScroll = useCallback(() => {\n const container = containerRef.current;\n if (!container) return;\n const { scrollHeight, scrollTop, clientHeight } = container;\n const distanceFromBottom = scrollHeight - scrollTop - clientHeight;\n userScrolledUpRef.current = distanceFromBottom > 40;\n }, []);\n\n // Scroll the LIST, never `scrollIntoView`: that scrolls every scrollable\n // ancestor too, and inside a host page (`RoomChrome`) each new message would\n // yank the host's own scroll position to the chat.\n useEffect(() => {\n const container = containerRef.current;\n if (container && !userScrolledUpRef.current) {\n container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });\n }\n }, [chat.length]);\n\n return (\n <div\n ref={containerRef}\n onScroll={handleScroll}\n className=\"flex-1 overflow-y-auto overflow-x-hidden chat-messages\"\n >\n {chat.length === 0 ? (\n <div className=\"p-4 text-xs text-text-muted text-center\">\n No messages yet. Be the first to say something!\n </div>\n ) : (\n <>\n {chat.map((msg, i) => {\n const prev = i > 0 ? chat[i - 1] : null;\n const showDateBreak = prev && (msg.ts - prev.ts) > 24 * 60 * 60 * 1000;\n return (\n <React.Fragment key={msg.id}>\n {showDateBreak && (\n <div className=\"flex items-center gap-2 px-4 py-2 my-1\">\n <div className=\"flex-1 h-px bg-border\" />\n <span className=\"text-[10px] text-text-muted whitespace-nowrap\">\n {new Date(msg.ts).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}\n </span>\n <div className=\"flex-1 h-px bg-border\" />\n </div>\n )}\n <ChatMessage message={msg} />\n </React.Fragment>\n );\n })}\n </>\n )}\n </div>\n );\n}\n","/**\n * Slash-command registry and input rules for the chat box.\n *\n * Everything here is pure so the behaviour can be tested without a DOM:\n * `apps/ui/vitest.config.ts` runs in a `node` environment and only picks up\n * `.ts` tests, so a rule that lives inside the React component is a rule that\n * never gets tested. ChatInput/SlashCommandPicker consume these functions and\n * own nothing but rendering and focus.\n */\n\n/** How a command's result reaches people. */\nexport type SlashScope =\n /** Result is rendered only for the person who ran it. */\n | 'local'\n /** Result is broadcast to everyone in the room. */\n | 'room';\n\nexport interface SlashCommand {\n /** Command word WITHOUT the leading slash, lowercase. */\n name: string;\n /** One-line help text shown in the picker. */\n description: string;\n /** Shown in the picker as a hint, e.g. '/recap'. */\n usage: string;\n scope: SlashScope;\n /**\n * Command is only meaningful while media is loaded. The picker still lists\n * it (so it is discoverable), but greys it out and refuses to run it — a\n * silently-missing command is worse than a disabled one that explains itself.\n */\n requiresMedia?: boolean;\n}\n\nexport const SLASH_COMMANDS: SlashCommand[] = [\n {\n name: 'recap',\n description: 'Summarise the previous episode of what is loaded',\n usage: '/recap',\n scope: 'room',\n requiresMedia: true,\n },\n {\n name: 'tierlist',\n description: 'Open the tierlist',\n usage: '/tierlist',\n scope: 'local',\n },\n {\n name: 'help',\n description: 'List the available commands',\n usage: '/help',\n scope: 'local',\n },\n];\n\n/** Marker character. Kept as a constant so tests and UI cannot disagree. */\nexport const SLASH = '/';\n\n/**\n * The token the picker should filter on, or null when the picker must stay\n * closed.\n *\n * Open only while the caret is still inside the COMMAND WORD:\n * '' -> null (empty input)\n * 'hello' -> null (ordinary message)\n * 'a /recap' -> null (slash must be the first character; otherwise every\n * URL — \"https://x\" — and every \"and/or\" would pop\n * the picker mid-sentence)\n * '/' -> '' (show the whole list)\n * '/rec' -> 'rec'\n * '/recap ' -> null (a space closes the word: the user is typing\n * arguments now, and a picker covering the input at\n * that point is pure obstruction)\n *\n * Leading whitespace is NOT trimmed first — ' /recap' is a message that\n * happens to contain a slash, and treating it as a command would make the\n * behaviour depend on invisible characters.\n */\nexport function getPickerQuery(text: string): string | null {\n if (!text.startsWith(SLASH)) return null;\n const rest = text.slice(SLASH.length);\n if (/\\s/.test(rest)) return null;\n return rest.toLowerCase();\n}\n\n/**\n * Commands matching `query`, best first.\n *\n * Prefix matches outrank substring matches so typing 'he' puts '/help' at the\n * top rather than whatever merely contains 'he'. Ties break alphabetically so\n * the order is stable and does not depend on registry order.\n */\nexport function filterCommands(\n query: string,\n commands: SlashCommand[] = SLASH_COMMANDS,\n): SlashCommand[] {\n const q = query.toLowerCase();\n if (!q) return [...commands].sort((a, b) => a.name.localeCompare(b.name));\n const scored: Array<{ cmd: SlashCommand; rank: number }> = [];\n for (const cmd of commands) {\n const name = cmd.name.toLowerCase();\n if (name.startsWith(q)) scored.push({ cmd, rank: 0 });\n else if (name.includes(q)) scored.push({ cmd, rank: 1 });\n }\n return scored\n .sort((a, b) => a.rank - b.rank || a.cmd.name.localeCompare(b.cmd.name))\n .map((s) => s.cmd);\n}\n\n/**\n * Input text after completing to `cmd` (Tab, click, or Enter on a highlight).\n *\n * The trailing space matters twice over: it lets the user type arguments\n * immediately, and it makes `getPickerQuery` return null, which is what\n * actually dismisses the picker. Without it the picker stays open over a\n * completed command and Enter would re-complete instead of submitting.\n */\nexport function applyCompletion(cmd: SlashCommand): string {\n return `${SLASH}${cmd.name} `;\n}\n\n/** Wrap an index within [0, len). Returns 0 for an empty list. */\nexport function wrapIndex(index: number, len: number): number {\n if (len <= 0) return 0;\n return ((index % len) + len) % len;\n}\n\nexport type Submission =\n | { kind: 'command'; command: SlashCommand; args: string }\n | { kind: 'chat'; text: string };\n\n/**\n * Decide what pressing Enter means.\n *\n * An UNKNOWN slash word is deliberately sent as an ordinary message rather\n * than rejected. Refusing it would need a whole error-surface of its own, and\n * a typo'd '/recp' landing in chat is self-explanatory and harmless — whereas\n * swallowing input silently is the one outcome a chat box must never have.\n */\nexport function resolveSubmission(\n text: string,\n commands: SlashCommand[] = SLASH_COMMANDS,\n): Submission | null {\n const trimmed = text.trim();\n if (!trimmed) return null;\n if (!trimmed.startsWith(SLASH)) return { kind: 'chat', text: trimmed };\n\n const body = trimmed.slice(SLASH.length);\n const spaceAt = body.search(/\\s/);\n const word = (spaceAt === -1 ? body : body.slice(0, spaceAt)).toLowerCase();\n const args = spaceAt === -1 ? '' : body.slice(spaceAt).trim();\n\n const command = commands.find((c) => c.name.toLowerCase() === word);\n if (!command) return { kind: 'chat', text: trimmed };\n return { kind: 'command', command, args };\n}\n\n/**\n * Why a command cannot run right now, or null when it can.\n * Returned as a message so the caller can show it verbatim.\n */\nexport function commandBlockedReason(\n cmd: SlashCommand,\n ctx: { hasMedia: boolean },\n): string | null {\n if (cmd.requiresMedia && !ctx.hasMedia) {\n return `${SLASH}${cmd.name} needs an episode loaded.`;\n }\n return null;\n}\n","import type { SlashCommand } from '../lib/slashCommands';\n\ninterface Props {\n commands: SlashCommand[];\n activeIndex: number;\n /** Commands needing media are listed but disabled while nothing is loaded. */\n hasMedia: boolean;\n onPick: (cmd: SlashCommand) => void;\n /** Keeps the highlight in sync when the pointer moves over a row. */\n onHover: (index: number) => void;\n listboxId: string;\n}\n\n/**\n * The command list that pops up while the user is typing a slash command.\n *\n * Rendering only — every rule about what to show and in what order lives in\n * `lib/slashCommands.ts` so it can be tested without a DOM.\n *\n * Positioned `bottom-full` (above the input) because the chat box sits at the\n * bottom of the sidebar; a downward menu would open off-screen.\n */\nexport default function SlashCommandPicker({\n commands, activeIndex, hasMedia, onPick, onHover, listboxId,\n}: Props) {\n if (commands.length === 0) return null;\n\n return (\n <ul\n id={listboxId}\n role=\"listbox\"\n aria-label=\"Slash commands\"\n // `list-none m-0 p-0` is load-bearing, not tidiness: styles.css imports\n // Tailwind v4 UTILITIES ONLY, with no preflight/base, so the browser's\n // UA stylesheet still applies `padding-inline-start: 40px` and\n // `margin-block: 1em` to every <ul>. That indented all the rows by 40px.\n // The bullets were invisible (a `display: flex` list item drops its\n // marker box), so it read as a mysterious gap rather than a stray list.\n // `py-1`/`mb-1` follow the resets and win — longhand beats shorthand in\n // Tailwind's generated order.\n className=\"absolute bottom-full left-0 right-0 z-30 max-h-56 overflow-y-auto\n bg-bg-card border border-border-light rounded shadow-lg\n list-none m-0 p-0 py-1 mb-1\"\n >\n {commands.map((cmd, i) => {\n const blocked = Boolean(cmd.requiresMedia) && !hasMedia;\n const active = i === activeIndex;\n return (\n <li\n key={cmd.name}\n id={`${listboxId}-opt-${cmd.name}`}\n role=\"option\"\n aria-selected={active}\n aria-disabled={blocked}\n // onMouseDown, not onClick: the input's blur would otherwise fire\n // first and close the picker before the click ever lands.\n onMouseDown={(e) => { e.preventDefault(); onPick(cmd); }}\n onMouseEnter={() => onHover(i)}\n className={`px-3 py-1.5 cursor-pointer flex items-baseline gap-2 ${\n active ? 'bg-bg-alt' : ''\n } ${blocked ? 'opacity-50' : ''}`}\n >\n <span className=\"text-sm font-semibold text-text shrink-0\">\n {cmd.usage}\n </span>\n <span className=\"text-xs text-text-secondary truncate\">\n {blocked ? 'needs an episode loaded' : cmd.description}\n </span>\n </li>\n );\n })}\n </ul>\n );\n}\n","import React from 'react';\nimport type { PendingImage } from '../lib/chatMedia';\n\n/**\n * The row of not-yet-sent attachments above a chat input.\n *\n * Renders from the local object URL, so the image is visible the instant it is\n * pasted — the upload only happens on submit. Shared by both chat inputs for\n * the same reason `ChatImageAttachments` is: two copies drift.\n */\nexport default function ChatAttachmentStrip({\n pending,\n uploading,\n onRemove,\n}: {\n pending: readonly PendingImage[];\n uploading: boolean;\n onRemove: (id: string) => void;\n}) {\n if (!pending.length) return null;\n return (\n <div className=\"flex flex-wrap gap-1.5 pb-1.5\" data-testid=\"chat-attachment-strip\">\n {pending.map((p) => (\n <div key={p.id} className=\"relative\">\n <img\n src={p.previewUrl}\n alt=\"\"\n className={`h-14 w-14 rounded border border-border-light object-cover ${\n uploading ? 'opacity-50' : ''\n }`}\n />\n <button\n type=\"button\"\n onClick={() => onRemove(p.id)}\n disabled={uploading}\n aria-label=\"Remove attachment\"\n className=\"absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-bg-card border border-border-light text-[10px] text-text-secondary hover:bg-danger-bg hover:text-on-danger-bg disabled:opacity-50\"\n >\n ×\n </button>\n </div>\n ))}\n </div>\n );\n}\n","import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { ClipboardEvent as ReactClipboardEvent, DragEvent as ReactDragEvent } from 'react';\nimport { DEFAULT_CHAT_MAX_IMAGES, type TChatImage } from '@watchparty/shared';\nimport {\n getCdnClient,\n imageFilesFromClipboard,\n imageFilesFromDrop,\n measureImage,\n messageForUploadFailure,\n ownsObjectUrl,\n preflightMessage,\n resolveChatImageUrl,\n uploadAll,\n uploadErrorFields,\n type CatalogueItem,\n type PendingImage,\n} from '../lib/chatMedia';\nimport { MEME_CATALOGUE_PATH } from '../lib/chatMedia/react/MemePicker';\nimport { useAppStore } from '../lib/store';\nimport { warn } from '../lib/debug';\n\n/**\n * Paste/drop image attachments for a chat input.\n *\n * There are TWO chat inputs — the sidebar `ChatInput` and the inline one in\n * `ControlBar` (shown when the sidebar is collapsed and floating chat is on).\n * They already keep separate text/cooldown state, and attaching this behaviour\n * to only one of them is how the two silently diverge. Both consume this hook.\n *\n * Uploads happen on SUBMIT, not on paste. Pasting is cheap and often\n * reconsidered; uploading on paste would push bytes at a CDN with no rate\n * limiting for images the user then deletes. The local preview is an object\n * URL, so the user sees the image immediately either way.\n *\n * Meme picks land in the SAME pending list (`addMeme`). They are already on\n * the CDN, so they cost no upload on submit — but routing them through one\n * list is what makes the cap, the remove button, the disabled-while-uploading\n * state and the send path work identically for both without a second copy of\n * any of it.\n */\nexport function useChatImageAttachments() {\n const [pending, setPending] = useState<PendingImage[]>([]);\n const [uploading, setUploading] = useState(false);\n const pushToast = useAppStore((s) => s.pushToast);\n // Object URLs are revoked on removal and on unmount; without this every\n // pasted image leaks its blob for the lifetime of the tab.\n const liveUrls = useRef(new Set<string>());\n\n useEffect(() => () => {\n for (const url of liveUrls.current) URL.revokeObjectURL(url);\n liveUrls.current.clear();\n }, []);\n\n /**\n * Drop an attachment's preview.\n *\n * Guarded on `ownsObjectUrl` because a meme's `previewUrl` is an ordinary\n * CDN URL, not a blob. Revoking it is a no-op in every browser, but doing so\n * would mean this function had stopped tracking what it actually owns — and\n * the day something here starts depending on that set being accurate is the\n * day it breaks quietly.\n */\n const releasePreview = useCallback((p: PendingImage) => {\n if (!ownsObjectUrl(p)) return;\n URL.revokeObjectURL(p.previewUrl);\n liveUrls.current.delete(p.previewUrl);\n }, []);\n\n const addFiles = useCallback(async (files: File[]) => {\n if (!files.length) return;\n // Read the count from state at apply time, not from the closure, so two\n // fast pastes cannot both see the old length and blow past the cap.\n const accepted: PendingImage[] = [];\n for (const file of files) {\n const rejection = preflightMessage(file);\n if (rejection) {\n pushToast('error', rejection);\n continue;\n }\n const { w, h } = await measureImage(file);\n const previewUrl = URL.createObjectURL(file);\n liveUrls.current.add(previewUrl);\n accepted.push({\n kind: 'file',\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n file, previewUrl, w, h,\n });\n }\n if (!accepted.length) return;\n setPending((prev) => {\n const room = DEFAULT_CHAT_MAX_IMAGES - prev.length;\n if (room <= 0) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n for (const p of accepted) releasePreview(p);\n return prev;\n }\n if (accepted.length > room) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n for (const p of accepted.slice(room)) releasePreview(p);\n }\n return [...prev, ...accepted.slice(0, room)];\n });\n }, [pushToast, releasePreview]);\n\n /**\n * Attach a meme from the shared library.\n *\n * The pick is recorded HERE rather than on send, deliberately: that call is\n * what makes the library's default view popularity-ordered, but it must\n * never be able to delay or fail a message. Keeping it at pick time keeps it\n * out of the send path entirely, instead of relying on a caller remembering\n * not to await it.\n */\n const addMeme = useCallback((item: CatalogueItem) => {\n const previewUrl = resolveChatImageUrl(item.key);\n // `null` means the key failed the content-addressed shape check. A row the\n // renderer would refuse to display must not become an attachment either.\n if (!previewUrl) return;\n setPending((prev) => {\n if (prev.length >= DEFAULT_CHAT_MAX_IMAGES) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n return prev;\n }\n return [...prev, {\n kind: 'remote',\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n key: item.key,\n previewUrl,\n // The catalogue spells \"unknown\" as null; the wire schema spells it\n // as 0, and the renderer falls back to natural flow on that.\n w: item.width ?? 0,\n h: item.height ?? 0,\n title: item.title || undefined,\n }];\n });\n // Fire-and-forget by construction: `recordUse` returns void and swallows\n // everything, so it cannot be awaited and cannot be why a send fails.\n getCdnClient().catalogue(MEME_CATALOGUE_PATH).recordUse(item.key);\n }, [pushToast]);\n\n const remove = useCallback((id: string) => {\n setPending((prev) => {\n const hit = prev.find((p) => p.id === id);\n if (hit) releasePreview(hit);\n return prev.filter((p) => p.id !== id);\n });\n }, [releasePreview]);\n\n const clear = useCallback(() => {\n setPending((prev) => {\n for (const p of prev) releasePreview(p);\n return [];\n });\n }, [releasePreview]);\n\n /** Paste and drop are the same path — both carry a `DataTransfer`. */\n const onPaste = useCallback((e: ReactClipboardEvent) => {\n const files = imageFilesFromClipboard(e.clipboardData);\n if (!files.length) return; // ordinary text paste — leave it alone\n e.preventDefault();\n void addFiles(files);\n }, [addFiles]);\n\n const onDrop = useCallback((e: ReactDragEvent) => {\n const files = imageFilesFromDrop(e.dataTransfer);\n if (!files.length) return;\n e.preventDefault();\n void addFiles(files);\n }, [addFiles]);\n\n const onDragOver = useCallback((e: ReactDragEvent) => {\n if (e.dataTransfer?.types?.includes('Files')) e.preventDefault();\n }, []);\n\n /**\n * Upload everything and hand back the wire attachments.\n *\n * Returns `null` when an upload failed — the caller must then NOT send the\n * message and must NOT clear the input, so the user keeps their text and can\n * retry rather than losing it to a 401.\n */\n const uploadPending = useCallback(async (): Promise<TChatImage[] | null> => {\n if (!pending.length) return [];\n setUploading(true);\n try {\n return await uploadAll(pending);\n } catch (err: unknown) {\n warn('chat', 'Image upload failed', uploadErrorFields(err));\n // `CdnError.message` is developer-facing and not fit to show verbatim —\n // the reason is what maps to wording a user can act on.\n pushToast('error', messageForUploadFailure(err));\n return null;\n } finally {\n setUploading(false);\n }\n }, [pending, pushToast]);\n\n return {\n pending, uploading, addFiles, addMeme, remove, clear, uploadPending,\n onPaste, onDrop, onDragOver,\n };\n}\n","import React, { useState, useRef, useCallback, useEffect, useMemo, useId } from 'react';\nimport { DEFAULT_CHAT_MAX_LENGTH, DEFAULT_CHAT_COOLDOWN_MS } from '@watchparty/shared';\nimport { sendChat, emitRecapRequest } from '../lib/socket';\nimport { useAppStore } from '../lib/store';\nimport { warmTierlistPosters } from '../lib/tierlistApi';\nimport {\n SLASH_COMMANDS,\n getPickerQuery,\n filterCommands,\n applyCompletion,\n wrapIndex,\n resolveSubmission,\n commandBlockedReason,\n type SlashCommand,\n} from '../lib/slashCommands';\nimport { Icon } from '@wolffm/themes';\nimport SlashCommandPicker from './SlashCommandPicker';\nimport MemePicker from '../lib/chatMedia/react/MemePicker';\nimport ChatAttachmentStrip from './ChatAttachmentStrip';\nimport { useChatImageAttachments } from '../hooks/useChatImageAttachments';\n\nconst MAX_LENGTH = DEFAULT_CHAT_MAX_LENGTH;\nconst COOLDOWN_MS = DEFAULT_CHAT_COOLDOWN_MS;\n\ninterface ChatInputProps {\n /** Slash commands this input offers. Every command is a watchparty-product\n * action (`/recap` reads an episode's prebuilt recap, `/tierlist` opens\n * watchparty's global tierlist), so the embeddable\n * room chrome passes none and a leading `/` is just text. */\n commands?: SlashCommand[];\n}\n\nexport default function ChatInput({ commands = SLASH_COMMANDS }: ChatInputProps) {\n const [text, setText] = useState('');\n const [cooldown, setCooldown] = useState(false);\n // Selection is stored WITH the query it belongs to, so a narrowing query\n // resets the highlight by derivation instead of via an effect that would\n // setState during render and cascade (react-hooks/set-state-in-effect).\n const [selection, setSelection] = useState<{ q: string | null; i: number }>({ q: null, i: 0 });\n // Escape closes the picker without clearing the input. Sticky until the\n // command word changes again, so Escape is not undone by the next keystroke.\n const [dismissed, setDismissed] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n const listboxId = useId();\n const [memeOpen, setMemeOpen] = useState(false);\n // Sticky for the session. The meme library is a separate service from the\n // CDN, so it going down must cost the meme button and nothing else — paste\n // and drop keep working through the exact same composer.\n const [memeUnavailable, setMemeUnavailable] = useState(false);\n\n const attach = useChatImageAttachments();\n\n const mediaId = useAppStore((s) => s.snapshot.mediaId);\n const pushToast = useAppStore((s) => s.pushToast);\n const setTierlistOpen = useAppStore((s) => s.setTierlistOpen);\n const hasMedia = Boolean(mediaId);\n\n const query = getPickerQuery(text);\n const matches = useMemo(\n () => (query === null ? [] : filterCommands(query, commands)),\n [query, commands],\n );\n const pickerOpen = query !== null && !dismissed && matches.length > 0;\n const activeIndex = selection.q === query ? wrapIndex(selection.i, matches.length) : 0;\n const setActiveIndex = useCallback(\n (i: number) => setSelection({ q: query, i }),\n [query],\n );\n\n const runCommand = useCallback((cmd: SlashCommand, args: string) => {\n const blocked = commandBlockedReason(cmd, { hasMedia });\n if (blocked) {\n pushToast('info', blocked);\n return;\n }\n // Execution is intentionally a single seam: commands are dispatched by\n // name here rather than each carrying a closure, so the registry stays a\n // plain data table that tests can import without pulling in the socket.\n switch (cmd.name) {\n case 'help':\n pushToast('info', commands.map((c) => c.usage).join(' '));\n break;\n case 'recap':\n // Fire-and-forget: the server resolves the previous episode, reads the\n // prebuilt recap and broadcasts it to the room as a system message.\n // Anything that goes wrong arrives as a socket error with a recap/*\n // code, which the existing error toast already surfaces — so there is\n // deliberately no local \"loading\" or success state to get out of sync.\n emitRecapRequest();\n break;\n case 'tierlist':\n warmTierlistPosters();\n setTierlistOpen(true);\n break;\n default:\n pushToast('info', `${cmd.usage} is not implemented yet.`);\n }\n void args;\n }, [hasMedia, pushToast, commands, setTierlistOpen]);\n\n const complete = useCallback((cmd: SlashCommand) => {\n setText(applyCompletion(cmd));\n setDismissed(false);\n inputRef.current?.focus();\n }, []);\n\n const handleSubmit = useCallback(async (e: React.FormEvent) => {\n e.preventDefault();\n if (cooldown || attach.uploading) return;\n\n const resolved = resolveSubmission(text, commands);\n // `resolveSubmission` returns null for empty text — which is a legitimate\n // message when images are attached, and the common case for a paste.\n if (!resolved && !attach.pending.length) return;\n if (resolved?.kind === 'command' && attach.pending.length) {\n pushToast('info', 'Send images on their own, not with a command.');\n return;\n }\n\n if (resolved?.kind === 'command') {\n runCommand(resolved.command, resolved.args);\n setText('');\n // No cooldown for commands: the chat rate limit exists to stop message\n // spam, and a local command sends nothing to the room.\n if (!document.hidden && document.hasFocus()) inputRef.current?.focus();\n return;\n }\n\n // Upload attachments BEFORE clearing the input: a failed upload must\n // leave the user's text and images exactly where they were, so a 401 or a\n // flaky network costs a retry rather than the message.\n const images = await attach.uploadPending();\n if (images === null) return;\n\n sendChat(resolved?.text ?? '', images);\n setText('');\n attach.clear();\n setCooldown(true);\n setTimeout(() => setCooldown(false), COOLDOWN_MS);\n\n // Re-focus input only when watchparty actually has focus. If the user\n // hit Enter then alt-tabbed before the setTimeout fired, a late focus()\n // could yank attention back (the focus-trap guard pattern).\n if (!document.hidden && document.hasFocus()) {\n inputRef.current?.focus();\n }\n }, [text, cooldown, runCommand, attach, pushToast, commands]);\n\n const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Escape' && pickerOpen) {\n e.preventDefault();\n setDismissed(true);\n return;\n }\n if (!pickerOpen) return;\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n setActiveIndex(wrapIndex(activeIndex + 1, matches.length));\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n setActiveIndex(wrapIndex(activeIndex - 1, matches.length));\n } else if (e.key === 'Tab') {\n // Tab completes rather than moving focus — the picker is the focus\n // context while it is open.\n e.preventDefault();\n const cmd = matches[activeIndex];\n if (cmd) complete(cmd);\n } else if (e.key === 'Enter') {\n // Enter picks the highlight instead of submitting a half-typed word.\n // The form's onSubmit still runs on the NEXT Enter, once the picker has\n // closed (applyCompletion's trailing space is what closes it).\n e.preventDefault();\n const cmd = matches[activeIndex];\n if (cmd) complete(cmd);\n }\n }, [pickerOpen, matches, activeIndex, complete, setActiveIndex]);\n\n // Pop focus to the chat input from anywhere on the page via a custom event.\n // The Bomberman bundle fires this when the user presses Enter while its\n // canvas had keyboard focus (hadoku-bomberman `FOCUS_CHAT_EVENT`).\n useEffect(() => {\n const onFocusRequest = () => {\n if (document.hidden || !document.hasFocus()) return;\n inputRef.current?.focus();\n inputRef.current?.scrollIntoView({ block: 'nearest' });\n };\n window.addEventListener('watchparty:focus-chat-input', onFocusRequest);\n return () => window.removeEventListener('watchparty:focus-chat-input', onFocusRequest);\n }, []);\n\n const remaining = MAX_LENGTH - text.length;\n // The counter only renders near the limit; reserving its gutter always\n // stopped the text ~48px short of the field's right edge.\n const showCounter = remaining <= 100;\n const activeCmd = pickerOpen ? matches[activeIndex] : undefined;\n\n return (\n <form\n onSubmit={(e) => { void handleSubmit(e); }}\n onPaste={attach.onPaste}\n onDrop={attach.onDrop}\n onDragOver={attach.onDragOver}\n className=\"p-2 border-t border-border\"\n >\n <ChatAttachmentStrip pending={attach.pending} uploading={attach.uploading} onRemove={attach.remove} />\n <div className=\"relative\">\n {pickerOpen && (\n <SlashCommandPicker\n commands={matches}\n activeIndex={activeIndex}\n hasMedia={hasMedia}\n onPick={complete}\n onHover={setActiveIndex}\n listboxId={listboxId}\n />\n )}\n <MemePicker\n open={memeOpen}\n onClose={() => setMemeOpen(false)}\n onPick={attach.addMeme}\n onUnavailable={() => { setMemeUnavailable(true); setMemeOpen(false); }}\n />\n <div className=\"flex items-center gap-1.5\">\n <div className=\"relative flex-1\">\n <input\n ref={inputRef}\n type=\"text\"\n value={text}\n onChange={(e) => { setText(e.target.value); setDismissed(false); }}\n onKeyDown={handleKeyDown}\n maxLength={MAX_LENGTH}\n placeholder=\"Send a message\"\n role=\"combobox\"\n aria-expanded={pickerOpen}\n aria-controls={pickerOpen ? listboxId : undefined}\n aria-activedescendant={activeCmd ? `${listboxId}-opt-${activeCmd.name}` : undefined}\n aria-autocomplete=\"list\"\n className={`w-full bg-bg-card border border-border-light rounded px-3 py-1.5 text-sm focus:outline-none focus:border-text-muted ${showCounter ? 'pr-12' : ''}`}\n />\n {/* Character counter */}\n <span className={`absolute right-2 top-1/2 -translate-y-1/2 text-[10px] ${\n remaining <= 50\n ? remaining <= 0\n ? 'bg-danger-bg text-on-danger-bg px-1 rounded'\n : 'bg-primary-bg text-on-primary-bg px-1 rounded'\n : 'text-text-muted'\n }`}>\n {showCounter && remaining}\n </span>\n </div>\n {!memeUnavailable && (\n <button\n type=\"button\"\n onClick={() => setMemeOpen((v) => !v)}\n aria-label=\"Add a meme\"\n aria-expanded={memeOpen}\n title=\"Add a meme\"\n data-testid=\"meme-button\"\n className={`shrink-0 rounded border border-border-light px-2 py-1.5 text-sm hover:border-primary ${\n memeOpen ? 'text-text border-primary' : 'text-text-secondary'\n }`}\n >\n <Icon name=\"image\" />\n </button>\n )}\n </div>\n </div>\n </form>\n );\n}\n","import { useAppStore, type RosterUser } from '../lib/store';\nimport { usePersonColor } from '../lib/pictureColor';\nimport AvatarImage from './AvatarImage';\n\n/**\n * Simple \"who's online\" panel. Ready-check moved out to ClientStatusOverlay\n * (rendered inside the video container) so it survives chat-closed and\n * fullscreen modes — see apps/ui/src/components/ClientStatusOverlay.tsx.\n */\nexport default function Roster() {\n const roster = useAppStore(s => s.roster);\n const users = Object.values(roster);\n\n if (!users.length) {\n return <div className=\"text-xs text-text-muted\">No users online</div>;\n }\n\n return (\n <div className=\"flex flex-col gap-1\">\n {users.map(u => <RosterRow key={u.clientId} u={u} />)}\n </div>\n );\n}\n\nfunction RosterRow({ u }: { u: RosterUser }) {\n const color = usePersonColor(u.sprite, u.avatarUrl);\n return (\n <div className=\"flex items-center gap-2 py-0.5\">\n <AvatarImage\n sprite={u.sprite}\n url={u.avatarUrl}\n alt={u.name || 'Anon'}\n className=\"w-6 h-6 rounded-full object-cover flex-shrink-0\"\n fallback={<span className=\"w-6 h-6 bg-bg-alt rounded-full flex-shrink-0\" />}\n />\n <span className=\"text-xs font-medium truncate\" style={{ color }}>\n {u.name || 'Anon'}\n </span>\n {u.isLeader && (\n <span className=\"ml-auto px-1.5 py-0.5 rounded text-[9px] font-medium bg-success/20 text-success\">\n Leader\n </span>\n )}\n </div>\n );\n}\n","import { useEffect, useRef, useState } from 'react';\nimport { useAppStore } from '../lib/store';\nimport { debug } from '../lib/debug';\nimport Roster from './Roster';\n\n/**\n * The \"who's here\" toolbar button: an icon with the head count, opening the\n * roster as a popover. Shared by the app's `ChatPanel` and the embeddable\n * `RoomChrome`.\n */\nexport default function RosterButton() {\n const userCount = useAppStore(s => Object.keys(s.roster).length);\n const [open, setOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!open) return;\n function handleClickOutside(e: MouseEvent) {\n if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);\n }\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [open]);\n\n return (\n <div className=\"relative\" ref={ref}>\n <button\n onClick={() => {\n debug('chat', 'User list toggled', { open: !open, userCount });\n setOpen(!open);\n }}\n className=\"p-1.5 rounded text-text-secondary hover:text-white hover:bg-bg-alt transition-colors flex items-center gap-1 cursor-pointer\"\n title=\"Users\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z\" />\n </svg>\n <span className=\"text-xs\">{userCount}</span>\n </button>\n\n {/* Anchored right-0, NOT left-0. The chat column is a 320px `w-80` aside\n pinned to the right edge, and Room.tsx wraps the layout in\n `overflow-hidden` — so anything extending past the column's right\n edge is clipped away and simply never appears. A left-anchored card\n opened from this button (~168px into the toolbar for a leader, who\n also gets the Select Media and Games buttons) would end at 348-388px\n and be invisible, not merely cut off. Opening leftward can overhang\n the centre instead, which is fine: the aside is z-10 over it, so the\n card paints on top and stays inside the clipping wrapper. */}\n {open && (\n <div className=\"absolute top-full right-0 mt-1 bg-bg-card rounded-lg shadow-xl p-3 max-h-64 overflow-y-auto z-50 w-max min-w-[180px] max-w-[220px]\">\n <div className=\"text-[10px] uppercase tracking-wide text-text-muted mb-2\">\n {userCount} online\n </div>\n <Roster />\n </div>\n )}\n </div>\n );\n}\n","import { useEffect } from 'react';\nimport { useAppStore } from '../lib/store';\n\n/** `className` positions the stack. The app pins it to the viewport; the\n * embeddable room chrome keeps it inside its own column rather than over the\n * host's page. */\nexport default function Toasts({ className = 'fixed bottom-4 left-1/2 -translate-x-1/2' }: { className?: string }) {\n const toasts = useAppStore(s => s.toasts);\n const pop = useAppStore(s => s.popToast);\n useEffect(() => {\n const timers = toasts.map(t => setTimeout(() => pop(t.id), 4000));\n return () => { timers.forEach(clearTimeout); };\n }, [toasts, pop]);\n return (\n <div className={`${className} flex flex-col gap-2 z-50`}>\n {toasts.map(t => (\n <div key={t.id} className={`px-3 py-2 rounded text-sm shadow font-medium border ${t.kind==='error' ? 'bg-danger-bg text-on-danger-bg border-danger' : 'bg-bg-card border-border-light text-text'}`}>{t.msg}</div>\n ))}\n </div>\n );\n}\n","import { THEMES, type Theme } from '@wolffm/themes';\n\nconst isTheme = (t: string | null | undefined): t is Theme =>\n typeof t === 'string' && (THEMES as readonly string[]).includes(t);\n\n/**\n * Which `@wolffm/themes` theme the chrome paints, highest precedence first:\n *\n * 1. the host's explicit `theme` prop;\n * 2. the AMBIENT theme — what the host's own `<HadokuThemeRoot>` provides. This\n * is what keeps chrome and host agreeing when the host is not dark;\n * 3. the viewer's saved hadoku theme (our own store), a fallback for a host\n * with no provider;\n * 4. `dark`.\n *\n * An ambient or saved value outside `THEMES` falls through rather than being\n * painted: `data-theme=\"<unknown>\"` matches no stylesheet and renders unthemed.\n * (`HadokuThemeValue.theme` is typed `string`, so the type does not rule it out.)\n */\nexport function resolveChromeTheme(\n explicit: Theme | undefined,\n ambient: string | null | undefined,\n saved: string | null | undefined,\n): Theme {\n if (explicit) return explicit;\n if (isTheme(ambient)) return ambient;\n if (isTheme(saved)) return saved;\n return 'dark';\n}\n","/**\n * Report an element's width now and whenever it changes; returns the cleanup.\n *\n * `ResizeObserver` is NOT assumed. jsdom and happy-dom do not implement it, so\n * an unguarded `new ResizeObserver` in a mount effect throws an UNCAUGHT\n * `ReferenceError` from React's commit phase — vitest reports that as an\n * unhandled error and fails the consumer's whole run, not one test. That shipped\n * in 0.40.5 and turned hadoku-cardtable's CI red on every push (#148), with\n * nothing in cardtable changed. SSR and older embedded webviews lack it too.\n *\n * Without an observer the chrome still gets one correct measurement at mount;\n * it simply does not follow later resizes, which is the right degradation for an\n * environment that cannot report them. The first measurement is taken\n * explicitly rather than relying on the observer's initial callback, so both\n * paths start from the same number.\n */\nexport function observeWidth(\n el: Pick<HTMLElement, 'clientWidth'>,\n onWidth: (width: number) => void,\n): () => void {\n onWidth(el.clientWidth);\n if (typeof ResizeObserver === 'undefined') return () => {};\n const ro = new ResizeObserver(() => onWidth(el.clientWidth));\n ro.observe(el as Element);\n return () => ro.disconnect();\n}\n","import { useAppStore } from '../lib/store';\n\n/**\n * Which name the chrome joins with: the host's `name` prop, trimmed, if\n * non-empty — applied as a SESSION identity, never saved — else auto identity\n * (`ensureAutoIdentity`).\n *\n * This is only what the client OFFERS. For an account the server ignores it\n * and uses the key-registry name, which presence then hands back\n * (`registry_name`), so a host that passes its own registry name and one that\n * passes nothing land on the same name. There is no stored watchparty name to\n * weigh against it any more: watchparty's own `name` pref is what made\n * cardtable and watchparty disagree (2026-10-08).\n */\nexport type JoinNameDecision =\n | { source: 'host'; name: string }\n | { source: 'auto' };\n\nexport function resolveJoinName(hostName: string | null | undefined): JoinNameDecision {\n const host = hostName?.trim() ?? '';\n return host ? { source: 'host', name: host } : { source: 'auto' };\n}\n\n/** Put a resolved join name into the store. A host-supplied name is a SESSION\n * identity: it must not follow the viewer into every other room and host. */\nexport function applyHostName(decision: JoinNameDecision): void {\n if (decision.source !== 'host') return;\n const { setSessionIdentity, sprite } = useAppStore.getState();\n setSessionIdentity(decision.name, sprite);\n}\n","\ninterface IconProps {\n className?: string;\n}\n\nexport function StarIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} fill=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\" />\n </svg>\n );\n}\n\nexport function PlayIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n );\n}\n\nexport function PauseIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M6 19h4V5H6v14zm8-14v14h4V5h-4z\" />\n </svg>\n );\n}\n\nexport function VolumeMutedIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2\" />\n </svg>\n );\n}\n\nexport function VolumeLowIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.536 8.464a5 5 0 010 7.072\" />\n </svg>\n );\n}\n\nexport function VolumeHighIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728\" />\n </svg>\n );\n}\n\nexport function MonitorIcon({ className = 'w-4 h-4' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\" />\n </svg>\n );\n}\n\nexport function FullscreenIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\" />\n </svg>\n );\n}\n\nexport function ExitFullscreenIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 9V4H4m0 0l5 5M9 15v5H4m0 0l5-5M15 9V4h5m0 0l-5 5M15 15v5h5m0 0l-5-5\" />\n </svg>\n );\n}\n\nexport function ChevronLeftIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 19l-7-7 7-7\" />\n </svg>\n );\n}\n\nexport function ChevronRightIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n </svg>\n );\n}\n\nexport function SidebarCloseIcon({ className = 'w-4 h-4' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 4v16\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12l-5 4v-8l5 4z\" />\n </svg>\n );\n}\n\nexport function SidebarOpenIcon({ className = 'w-4 h-4' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M20 4v16\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 12l5 4v-8l-5 4z\" />\n </svg>\n );\n}\n\n/** A cross — stops the running game (RoomCornerControls). */\nexport function CloseIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n );\n}\n\nexport function EjectIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M12 5l8 11H4z\" />\n <rect x=\"4\" y=\"17\" width=\"16\" height=\"3\" rx=\"0.5\" />\n </svg>\n );\n}\n\n\n/** Film strip — the media catalog. Same glyph the chat toolbar's\n * \"Select Media\" button uses, so both entry points read as one action. */\nexport function FilmIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 4h16a1 1 0 011 1v14a1 1 0 01-1 1H4a1 1 0 01-1-1V5a1 1 0 011-1z\" />\n </svg>\n );\n}\n","import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';\nimport type { CSSProperties, PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';\nimport { useHadokuThemeOptional, type Theme } from '@wolffm/themes';\nimport { ConfigContext, DEFAULT_CONFIG, setRuntimeConfig, type WatchpartyConfig } from '../lib/config';\nimport { PortalTargetContext } from '../lib/portalTarget';\nimport { setAuth } from '../lib/auth';\nimport { useAppStore } from '../lib/store';\nimport { connectAndJoin, leaveRoom, sendAvatarChange } from '../lib/socket';\nimport { loadDisplayNames } from '../lib/characters';\nimport { loadPreferences, syncFromServerIfFirstLoad } from '../lib/preferences';\nimport { fetchRequestedSprite } from '../lib/avatar';\nimport { useAvatarChanges } from '../hooks/useAvatarChanges';\nimport { primeDeviceId } from '../lib/deviceId';\nimport { exposeDebugGlobals } from '../lib/debugGlobals';\nimport { debug, warn, errorFields } from '../lib/debug';\nimport ChatLog from '../components/ChatLog';\nimport ChatInput from '../components/ChatInput';\nimport RosterButton from '../components/RosterButton';\nimport Toasts from '../components/Toasts';\nimport { resolveChromeTheme } from './hostTheme';\nimport { observeWidth } from './observeWidth';\nimport { resolveJoinName, applyHostName } from './hostIdentity';\nimport { SidebarCloseIcon, SidebarOpenIcon } from '../components/controls/Icons';\nimport {\n clampChatWidth, widthFromDrag, widthFromKey, chatLayoutState, type ChatLayoutState,\n CHAT_WIDTH_MIN_PX, CHAT_WIDTH_MAX_PX,\n} from './chatLayout';\n\nexport interface RoomChromeProps {\n /** The watchparty room to join. Keys spelled `<app-id>-<[a-z0-9-]{1,40}>`\n * need no provisioning: the room is created on first join by a friend-tier\n * user, is never listed, and is collected once empty.\n *\n * `<app-id>` must be one watchparty ADMITS — the namespace is an enumerated\n * list (`EPHEMERAL_ROOM_NAMESPACES`), not a shape, so that a mistyped or\n * retired key still fails as `auth/invalid_key` rather than minting a silent\n * empty room. Admitting a new app is one line in hadoku_site's pm2 wrapper\n * and a restart; an unadmitted key is refused and the chrome shows the\n * error, while the host's content (a sibling, not a child) keeps rendering.\n * Do NOT borrow another app's id to get around it: the room id IS the key,\n * so two apps spelling `cardtable-lobby` land in one room and read each\n * other's chat.\n *\n * **Live unprovisioned rooms share ONE cap across every app**\n * (`MAX_EPHEMERAL_ROOMS`, 800), so an app that mints rooms in a loop can\n * refuse its neighbours' joins with `room/full`. Here rather than only in\n * watchparty's ops docs because it is a shared fate an app author should\n * meet while wiring, not during someone else's incident. The server logs\n * `room.full` with a per-namespace breakdown, so the cause is one grep. */\n roomKey: string;\n /** Watchparty's server, e.g. \"https://hadoku.me/watchparty\". Every HTTP call\n * and the socket derive from it. */\n serverOrigin: string;\n /** The same identity `mount()` takes. `userId` must be the viewer's REGISTRY\n * userId (what `@wolffm/prefs-client` whoami returns): the chrome keys the\n * viewer's saved preferences on it, since it has no session to resolve\n * the account from. */\n userType?: 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';\n userId?: string | null;\n /** A `@wolffm/themes` theme name for the chrome. Wins over everything.\n * Without it the chrome follows the host's ambient `<HadokuThemeRoot>`\n * (and re-renders when the host changes theme), then the viewer's saved\n * hadoku theme, then `dark`. Applied to the chrome's own root, never to the\n * host document. Mount a `<HadokuThemeRoot>` above the chrome or pass this:\n * with neither, dev builds warn once. */\n theme?: Theme;\n /** A name to OFFER at join, for a viewer with no key-registry name. A\n * signed-in viewer is always shown under their registry name — the server\n * resolves it from whoami and ignores this — so a host need not pass it.\n * Otherwise: this > an auto-assigned character name, as a SESSION identity\n * never saved to the viewer's preferences. */\n name?: string;\n /** Renders a Leave button that leaves the room and then calls this. Without\n * it there is no button: unmounting the component is how a host leaves. */\n onLeave?: () => void;\n /** Publish watchparty's `window.__*` debug handles (`__SOCKET__`,\n * `__ZUSTAND_STORE__`, …). Off by default; nothing is written to `window`. */\n debug?: boolean;\n /** Called with the chat column's state on mount and whenever the viewer\n * collapses, reopens or resizes it — including on every frame of a drag.\n * `width` is the column's rendered width in CSS px (the narrow rail's while\n * collapsed), i.e. how much of the frame the host's content does NOT get.\n * Only for a host that must re-measure its own layout; collapsing and\n * resizing work without it, and are remembered per viewer. */\n onLayoutChange?: (state: ChatLayoutState) => void;\n /** `false` keeps the chat column open: no Hide chat button, and a viewer's\n * saved collapse is not applied — though it stays saved, so a screen that\n * allows collapsing still opens the way they left it. For a host screen\n * where the chat IS the point (a lobby). Resizing still works. Default true. */\n collapsible?: boolean;\n /** The host's content, rendered where the app renders its player. */\n children: React.ReactNode;\n}\n\n/** How long the join waits for display names, cross-device prefs and the\n * requested character before going ahead with what it has — the same cap\n * `Room.tsx` uses, for the same reason: the first presence broadcast should\n * carry the chosen identity, but a broken prefs server must never block\n * joining. */\nconst BOOT_WAIT_TIMEOUT_MS = 1500;\n\nlet warnedNoThemeSource = false;\n\n/**\n * Watchparty's room — chat, roster, presence and the viewer's own identity —\n * around a host's content, as a component.\n *\n * It is `mount()`'s room with the product removed and the page left alone:\n * no router, no player, no drawers or games, nothing written to `<html>`,\n * `<head>` or `window`, and no navigation. It connects to watchparty's\n * server, not the host's; a host's own socket stays its own.\n *\n * Unmounting leaves the room and closes the socket. The store and socket are\n * module singletons, so ONE chrome per page — a second instance would share,\n * and then tear down, the first one's connection.\n */\nexport function RoomChrome({\n roomKey, serverOrigin, userType, userId, theme, name: hostName, onLeave, onLayoutChange, collapsible = true, debug: debugHandles, children,\n}: RoomChromeProps) {\n const [root, setRoot] = useState<HTMLElement | null>(null);\n const { setFrame, ...layout } = useChatColumnLayout(onLayoutChange, collapsible);\n // Read at join time only: changing the name later must not leave and rejoin.\n const readHostName = useEffectEvent(() => hostName);\n // Identity first, during render: the saved theme is read on this very\n // render, and before `setAuth` the prefs key is unknowable\n // (getPrefsCacheUserId), so the read would come back empty. setAuth only\n // sets module state, so a StrictMode double render is harmless.\n const savedTheme = useMemo(() => {\n setAuth({ userType, userId: userId ?? null });\n return loadPreferences().theme;\n }, [userType, userId]);\n const ambient = useHadokuThemeOptional();\n const ambientTheme = ambient?.theme;\n const resolvedTheme = useMemo(\n () => resolveChromeTheme(theme, ambientTheme, savedTheme),\n [theme, ambientTheme, savedTheme],\n );\n const hasAmbient = ambient !== null;\n useEffect(() => {\n if (import.meta.env.DEV && !warnedNoThemeSource && !theme && !hasAmbient) {\n warnedNoThemeSource = true;\n warn('room-chrome', 'No <HadokuThemeRoot> above RoomChrome and no `theme` prop: the chrome falls back to its own saved theme or dark, which may not match the host.');\n }\n }, [theme, hasAmbient]);\n const config = useMemo<WatchpartyConfig>(\n () => ({ ...DEFAULT_CONFIG, serverOrigin, defaultRoomKey: roomKey }),\n [serverOrigin, roomKey],\n );\n\n useEffect(() => {\n if (debugHandles) exposeDebugGlobals();\n }, [debugHandles]);\n\n // A character picked in the fleet settings popout reaches the room live.\n useAvatarChanges();\n\n // One effect owns the whole connection, so its cleanup is the single place\n // the room is left. Everything non-component code reads (runtime config,\n // auth) is set here, before the socket exists.\n useEffect(() => {\n setRuntimeConfig({ serverOrigin, defaultRoomKey: roomKey });\n // The host resolved the account (whoami) and setAuth took it during\n // render; re-read the prefs under it before joining.\n useAppStore.getState().rehydrateFromPrefs();\n primeDeviceId();\n\n let cancelled = false;\n const namesLoaded = loadDisplayNames();\n void (async () => {\n try {\n await Promise.race([\n Promise.all([\n namesLoaded,\n syncFromServerIfFirstLoad().then((merged) => {\n if (merged) useAppStore.getState().rehydrateFromPrefs();\n }),\n // The character chosen in the settings popout (lib/avatar.ts).\n fetchRequestedSprite().then(sendAvatarChange),\n ]),\n new Promise<void>(r => setTimeout(r, BOOT_WAIT_TIMEOUT_MS)),\n ]);\n } catch (e: unknown) {\n warn('room-chrome', 'pre-join wait threw, proceeding anyway', errorFields(e));\n }\n // StrictMode runs this effect twice; the first pass is cancelled before\n // its wait resolves and must not join.\n if (cancelled) return;\n const decision = resolveJoinName(readHostName());\n useAppStore.getState().ensureAutoIdentity();\n applyHostName(decision);\n const st = useAppStore.getState();\n debug('room-chrome', 'Joining room', { roomKey, name: st.name, sprite: st.sprite });\n connectAndJoin({ room_key: roomKey, name: st.name || undefined, sprite: st.sprite || undefined });\n })();\n\n return () => {\n cancelled = true;\n leaveRoom();\n };\n }, [roomKey, serverOrigin, userType, userId]);\n\n // The host's content is a SIBLING of the chrome column, not a child: the\n // column's stylesheet is scoped to `.wp-room-chrome`, and keeping the host\n // out of that subtree also keeps out what scoping cannot stop — inherited\n // font, colour and `color-scheme`.\n return (\n <ConfigContext.Provider value={config}>\n <PortalTargetContext.Provider value={root}>\n <div ref={setFrame} className=\"wp-room-chrome-frame\">\n <div className=\"wp-room-chrome-frame__content\">{children}</div>\n <aside\n ref={setRoot}\n className=\"wp-room-chrome\"\n data-theme={resolvedTheme}\n data-collapsed={layout.collapsed ? '1' : '0'}\n style={{ '--wp-room-chrome-width': `${layout.state.width}px` } as CSSProperties}\n >\n {layout.collapsed ? (\n <>\n <button\n onClick={layout.toggleCollapsed}\n className=\"wp-room-chrome__icon-btn\"\n title=\"Show chat\"\n aria-label=\"Show chat\"\n aria-expanded={false}\n >\n <SidebarOpenIcon />\n </button>\n {/* Beside the rail, over the host's edge: a refusal (\"Sign in\n to join this room.\") must still be seen with chat hidden. */}\n <Toasts className=\"absolute bottom-2 right-full mr-2 w-72\" />\n </>\n ) : (\n <>\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label=\"Resize chat\"\n aria-valuemin={CHAT_WIDTH_MIN_PX}\n aria-valuemax={CHAT_WIDTH_MAX_PX}\n aria-valuenow={layout.state.width}\n tabIndex={0}\n className=\"wp-room-chrome__resize\"\n data-dragging={layout.dragging ? '1' : '0'}\n onPointerDown={layout.onPointerDown}\n onPointerMove={layout.onPointerMove}\n onPointerUp={layout.onPointerEnd}\n onPointerCancel={layout.onPointerEnd}\n onKeyDown={layout.onKeyDown}\n />\n <div className=\"flex items-center gap-1 px-2 py-1.5 border-b border-border\">\n <RosterButton />\n <div className=\"flex-1\" />\n {onLeave && (\n <button\n onClick={() => { leaveRoom(); onLeave(); }}\n className=\"wp-room-chrome__leave\"\n title=\"Leave room\"\n aria-label=\"Leave room\"\n >\n Leave\n </button>\n )}\n {collapsible && (\n <button\n onClick={layout.toggleCollapsed}\n className=\"wp-room-chrome__icon-btn\"\n title=\"Hide chat\"\n aria-label=\"Hide chat\"\n aria-expanded\n >\n <SidebarCloseIcon />\n </button>\n )}\n </div>\n <ChatLog />\n <ChatInput commands={[]} />\n <Toasts className=\"absolute bottom-14 left-2 right-2\" />\n </>\n )}\n </aside>\n </div>\n </PortalTargetContext.Provider>\n </ConfigContext.Provider>\n );\n}\n\n/**\n * The chat column's collapse and width. Both live in the store, saved per\n * viewer (see chatLayout.ts for why under their own keys); a drag in progress\n * is local state and is saved once, on release, rather than once per frame.\n *\n * Nothing here listens on `window` or `document` — the chrome's contract with\n * its host forbids it (e2e/room-chrome.spec.ts counts them). The drag uses\n * pointer capture on the handle, so moves and the release reach the handle\n * even when the pointer leaves it, and the frame is measured with a\n * ResizeObserver so a narrowing host still keeps `HOST_MIN_PX`.\n */\nfunction useChatColumnLayout(onLayoutChange: RoomChromeProps['onLayoutChange'], collapsible: boolean) {\n const collapsed = useAppStore(s => s.chromeChatCollapsed) && collapsible;\n const savedWidth = useAppStore(s => s.chromeChatWidth);\n const toggleCollapsed = useAppStore(s => s.toggleChromeChatCollapsed);\n const setSavedWidth = useAppStore(s => s.setChromeChatWidth);\n const [frame, setFrame] = useState<HTMLDivElement | null>(null);\n const [frameWidth, setFrameWidth] = useState<number | undefined>(undefined);\n // The drag in progress, held as state rather than a ref: it is read while\n // rendering (`width` follows it), and the release saves `current` once.\n const [drag, setDrag] = useState<{ pointerId: number; startX: number; startWidth: number; current: number } | null>(null);\n\n useEffect(() => {\n if (!frame) return;\n return observeWidth(frame, setFrameWidth);\n }, [frame]);\n\n const width = clampChatWidth(drag?.current ?? savedWidth, frameWidth);\n const state = useMemo(() => chatLayoutState(collapsed, width), [collapsed, width]);\n\n // An effect event, so a host passing a fresh arrow on every render is not\n // told about a layout that did not change.\n const reportLayout = useEffectEvent((s: ChatLayoutState) => onLayoutChange?.(s));\n useEffect(() => { reportLayout(state); }, [state]);\n\n const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (e.button !== 0) return;\n e.preventDefault();\n e.currentTarget.setPointerCapture(e.pointerId);\n setDrag({ pointerId: e.pointerId, startX: e.clientX, startWidth: width, current: width });\n };\n const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag || drag.pointerId !== e.pointerId) return;\n setDrag({ ...drag, current: widthFromDrag(drag.startWidth, drag.startX, e.clientX, frameWidth) });\n };\n const onPointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag || drag.pointerId !== e.pointerId) return;\n const final = widthFromDrag(drag.startWidth, drag.startX, e.clientX, frameWidth);\n setDrag(null);\n if (final !== savedWidth) setSavedWidth(final);\n };\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const next = widthFromKey(width, e.key, frameWidth);\n if (next === null) return;\n e.preventDefault();\n if (next !== savedWidth) setSavedWidth(next);\n };\n\n return {\n setFrame, collapsed, state, dragging: drag !== null,\n toggleCollapsed, onPointerDown, onPointerMove, onPointerEnd, onKeyDown,\n };\n}\n"],"names":["DEFAULT_CDN_BASE","CHAT_IMAGE_KEY_RE","isChatImageKey","value","DEFAULT_HTTP_PORT","DEFAULT_UI_PORT","DEFAULT_WS_PATH","DEFAULT_CHAT_MAX_LENGTH","DEFAULT_CHAT_COOLDOWN_MS","DEFAULT_CHAT_MAX_IMAGES","_env","Tier","z","MAX_IMAGE_DIMENSION","MAX_IMAGE_TITLE_LENGTH","CORE_EVENTS","ControlKind","EmbedContext","ChatImage","d","SystemMessageType","HealthReport","ClientHealth","BufferClient","VoteValue","PlaylistVideo","PlaylistType","PlaylistConfig","PlaylistVoteProgressItem","PLAYLIST_EVENTS","TierlistItem","TierlistBoard","TierlistPlacedOut","TIERLIST_EVENTS","READY_EVENTS","GAME_EVENTS","CHARACTER_SETS","ENTRIES","s","CHARACTER_ORDER","c","CHARACTER_COLORS","CHARACTER_ART_REV","portraitPath","id","rev","isCharacterId","sprite","DEFAULT_CLIENT_TIME_PING_INTERVAL_MS","DEFAULT_FRAGMENT_DELIVERY_FLAG","FRAGMENT_DELIVERY_MODES","isFragmentDeliveryMode","v","normalizeFragmentDeliveryFlag","raw","o","mode","n","percent","hashClientId","clientId","h","i","resolveDeliveryArm","flag","override","f","SubmissionKind","SubmissionState","SUBMISSION_TRANSITIONS","Submission","SUBMISSION_SOURCE_MAX_CHARS","SUBMISSION_TITLE_MAX_CHARS","SUBMISSION_SERIES_MAX_CHARS","GAME_ID_RE","PROBE_ORIGIN","isSameOriginPath","SameOriginPath","GameEntryBase","GameEntrySchema","games","g","ShowSource","ShowResult","VideoResult","cached","cdnBase","setChatMediaConfig","config","getChatMediaCdnBase","getCdnClient","base","createCdnClient","resolveChatImageUrl","key","ownsObjectUrl","p","ChatImageUploadError","message","uploadErrorMessage","reason","messageForUploadFailure","err","CdnError","preflightMessage","file","type","kindOfType","MAX_BYTES","imageFilesFromClipboard","dt","filesFromClipboard","imageFilesFromDrop","filesFromDrop","measureImage","dims","measureImageBytes","uploadChatImage","opts","result","uploadAll","pending","out","uploadErrorFields","PREVIEW_PX","PREVIEW_GAP","PREVIEW_CHROME_PX","PREVIEW_MAX_IMG_PX","placePreview","tile","panel","viewport","left","boxHeight","top","MEME_CATALOGUE_PATH","HOVER_INTENT_MS","MemePickerPanel","onClose","onPick","onUnavailable","query","setQuery","useState","panelRef","useRef","inputRef","client","items","loading","error","hasMore","loadMore","useCatalogueSearch","rows","useMemo","seen","item","preview","setPreview","hoverTimer","cancelHover","useCallback","hidePreview","canHover","showPreview","immediate","place","useEffect","onKey","e","onDown","pick","isEmpty","jsxs","jsx","src","MemePicker","open","rest","DEFAULT_CONFIG","runtimeConfig","setRuntimeConfig","filtered","_","getRuntimeConfig","ConfigContext","createContext","getServerOrigin","getRuntimeServerOrigin","PortalTargetContext","usePortalTarget","useContext","STORAGE_KEYS","owner","playlistId","mediaId","storage","readStored","writeStored","storedKeys","keys","k","removeStored","readStoredFlag","registry","exposed","registerDebugGlobal","name","publish","exposeDebugGlobals","logHistory","MAX_HISTORY","logQueue","logFlushTimeout","LOG_FLUSH_DELAY","LOG_FLUSH_DELAY_FAST","currentRoomContext","setLogRoomContext","ctx","isReportAllEnabled","isDebugEnabled","stored","getAllowedCategories","categories","formatTime","addToHistory","entry","styles","safeStringify","data","_log","level","consoleFn","alwaysReport","alwaysShow","category","queueLogForServer","allowed","time","suffix","debug","warn","getLogHistory","clearLogHistory","enableDebug","disableDebug","MAX_DATA_SIZE","MAX_BATCH_SIZE","truncateData","json","truncated","vStr","flushLogsToServer","batch","origin","loc","url","prepared","chunk","chunkSize","entrySize","errorFields","delay","_state","getPrefsCacheUserId","setAuth","input","getUserId","voteOwnerScope","colorForSprite","spriteUrlFor","displayNames","loadPromise","loadDisplayNames","res","displayNameFor","isCharacterName","lower","dn","WatchpartyPrefsSchema","watchpartyPrefs","createPrefsClient","THEME_KEYS","sdkCacheKey","appId","uid","identityPendingUnresolved","readSdkCache","stripRetiredNameFromCaches","rewritten","sdkRow","legacyRow","LEGACY_PREFIX","parsed","blob","_retired","next","latestThemeBlob","latestAppBlob","legacyMergedFor","loadPreferences","cacheTheme","cacheApp","themeBlob","appBlob","legacy","readLegacy","merged","anonWritesThisLoad","isAnonymousNow","savePref","themePrefs","syncInFlight","subscribedOnce","syncFromServerIfFirstLoad","writeThrough","rehydrate","themeFromServer","appFromServer","useAppStore","store","themeKeyPost","appKeyPost","sdkThemePost","sdkAppPost","migrateLegacyIfNeeded","legacyKey","deleteLegacy","sdkHasTheme","sdkHasApp","themePatch","appPatch","themeKeys","appKeys","writeFailureWarned","env","CHAT_WIDTH_DEFAULT_PX","CHAT_WIDTH_MIN_PX","CHAT_WIDTH_MAX_PX","HOST_MIN_PX","COLLAPSED_RAIL_PX","KEYBOARD_STEP_PX","clampChatWidth","px","frameWidth","max","widthFromDrag","startWidth","startX","currentX","widthFromKey","width","chatLayoutState","collapsed","savedPrefs","create","set","get","usedSprites","r","snap","rosterUpdates","leaderId","newPlayheadMs","u","copy","m","messages","newVal","delta","rtt","serverTime","clientSendTs","rtts","offsetMs","clientMid","kind","msg","t","state","volume","muted","lang","enabled","style","snapshot","status","progress","results","saving","adoptDraft","prev","itemId","tier","index","row","without","at","gameId","mount","probed","prefs","tab","resolveMediaAsset","relPath","resolveCardImage","thumbnail","path","refreshTierlist","warmed","warmTierlistPosters","img","retryTakenSprite","st","used","free","keepName","isStaleSpriteRefusal","myClientId","seat","AVATAR_EVENT","requestedSpriteOf","settings","fetchRequestedSprite","baseUrl","createAvatarClient","AvatarError","adoptRequestedSprite","listenForAvatarChanges","onChange","target","handler","detail","MAX_FRAME_DEPTH","describeEmbedding","win","depth","w","FRAGMENT_DELIVERY_FLAG","readDeliveryOverride","q","serverFlag","resolvedArm","DELIVERY_CLIENT_ID_KEY","deliveryClientId","existing","fresh","resolveSessionArm","currentDeliveryArm","storedDeviceId","DEVICE_ID_KEY","primeDeviceId","getDeviceId","cachedDeviceId","clearStoredVotes","playlist","removed","video","PONG_DEAD_AFTER_MS","SLEEP_GAP_MS","createLiveness","notePong","l","now","tick","connected","slept","pongAge","socket","timePingInflight","timePingInterval","timePingTimeout","lastJoinPayload","TAB_ID","makeTabId","liveness","lastDisconnect","pendingDisconnectCause","reconnectCount","joinedIdentity","identityEmitInHand","spriteHeldByOwnOtherConnection","hasConnectedOnce","lastReadyMediaId","connectAndJoin","getSocket","payload","sanitizeJoin","bytes","b","socketOrigin","io","livenessTick","initiateTimePing","sid","rejoinPayload","readyMediaId","resolved","onSnapshot","wireCoreHandlers","isServerPlaying","normalizeChatMessage","seqFallback","im","wireSync","stAfter","known","idResult","resolveIdentityReassert","idPayload","newMediaId","ack","readyCount","total","ready","wirePresence","ev","roster","myId","mine","serverName","serverSprite","hasCustomName","nameToUse","stFinal","st3","wireChat","chatMsg","wirePlaylist","wireTierlist","wireReadyCheck","x","wireGame","wireHealth","code","retry","sendChat","text","images","sendAvatarChange","before","after","_lastReasserted","myEntry","wantsName","wantsSprite","cleanupTimers","clearJoinState","leaveRoom","emitRecapRequest","useAvatarChanges","MIN_LIGHTNESS","MAX_LIGHTNESS","SAMPLE_SIZE","MIN_ALPHA","averageColor","pixels","weight","a","clampLightness","rgbToHsl","clamped","r2","g2","b2","hslToRgb","hex","min","channel","colors","listeners","notify","readPicture","settle","color","canvas","subscribe","listener","personColor","read","usePersonColor","useSyncExternalStore","pictureForAuthor","author","AvatarImage","alt","className","fallback","failedUrl","setFailedUrl","portrait","Fragment","shouldOpenLightbox","ChatImageLightbox","title","scoped","createPortal","MAX_H_PX","MAX_W_PX","Attachment","image","expanded","setExpanded","hasDims","scale","ChatImageAttachments","isOwnMessage","me","ts","ChatMessage","React","myAuthor","isOwn","pictureUrl","nameColor","ChatLog","chat","containerRef","userScrolledUpRef","handleScroll","container","scrollHeight","scrollTop","clientHeight","distanceFromBottom","showDateBreak","SLASH_COMMANDS","SLASH","getPickerQuery","filterCommands","commands","scored","cmd","applyCompletion","wrapIndex","len","resolveSubmission","trimmed","body","spaceAt","word","args","command","commandBlockedReason","SlashCommandPicker","activeIndex","hasMedia","onHover","listboxId","blocked","active","ChatAttachmentStrip","uploading","onRemove","useChatImageAttachments","setPending","setUploading","pushToast","liveUrls","releasePreview","addFiles","files","accepted","rejection","previewUrl","room","addMeme","remove","hit","clear","onPaste","onDrop","onDragOver","uploadPending","MAX_LENGTH","COOLDOWN_MS","ChatInput","setText","cooldown","setCooldown","selection","setSelection","dismissed","setDismissed","useId","memeOpen","setMemeOpen","memeUnavailable","setMemeUnavailable","attach","setTierlistOpen","matches","pickerOpen","setActiveIndex","runCommand","complete","handleSubmit","handleKeyDown","onFocusRequest","remaining","showCounter","activeCmd","Icon","Roster","users","RosterRow","RosterButton","userCount","setOpen","ref","handleClickOutside","Toasts","toasts","pop","timers","isTheme","THEMES","resolveChromeTheme","explicit","ambient","saved","observeWidth","el","onWidth","ro","resolveJoinName","hostName","host","applyHostName","decision","setSessionIdentity","SidebarCloseIcon","SidebarOpenIcon","BOOT_WAIT_TIMEOUT_MS","RoomChrome","roomKey","serverOrigin","userType","userId","theme","onLeave","onLayoutChange","collapsible","debugHandles","children","root","setRoot","setFrame","layout","useChatColumnLayout","readHostName","useEffectEvent","savedTheme","useHadokuThemeOptional","ambientTheme","resolvedTheme","cancelled","namesLoaded","savedWidth","toggleCollapsed","setSavedWidth","frame","setFrameWidth","drag","setDrag","reportLayout","final"],"mappings":"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"}
1
+ {"version":3,"file":"room-chrome.js","sources":["../../../packages/shared/dist/chatImages.js","../../../packages/shared/dist/defaults.js","../../../packages/shared/dist/tierlist.js","../../../packages/shared/dist/protocol.js","../../../packages/shared/dist/characters.js","../../../packages/shared/dist/playback.js","../../../packages/shared/dist/fragmentDeliveryFlag.js","../../../packages/shared/dist/submissions.js","../../../packages/shared/dist/gameCatalog.js","../../../packages/shared/dist/mediaSearch.js","../src/lib/chatMedia/config.ts","../src/lib/chatMedia/upload.ts","../src/lib/chatMedia/previewPlacement.ts","../src/lib/chatMedia/react/MemePicker.tsx","../src/lib/config.ts","../src/lib/portalTarget.ts","../src/lib/storageKeys.ts","../src/lib/browserStorage.ts","../src/lib/debugGlobals.ts","../src/lib/debug.ts","../src/lib/auth.ts","../src/lib/characters.ts","../src/prefs/watchpartyPrefs.ts","../src/lib/preferences.ts","../src/roomChrome/chatLayout.ts","../src/lib/store.ts","../src/lib/mediaUrls.ts","../src/lib/tierlistApi.ts","../src/lib/spriteRetry.ts","../src/lib/avatar.ts","../src/lib/embedContext.ts","../src/lib/fragmentDelivery.ts","../src/lib/deviceId.ts","../src/lib/playlistVotes.ts","../src/lib/connectionLiveness.ts","../src/lib/socket.ts","../src/hooks/useAvatarChanges.ts","../src/lib/pictureColor.ts","../src/lib/avatarPictures.ts","../src/components/AvatarImage.tsx","../src/lib/chatMedia/imageClickIntent.ts","../src/components/ChatImageLightbox.tsx","../src/components/ChatImageAttachments.tsx","../src/lib/chatOwnership.ts","../src/components/ChatMessage.tsx","../src/components/ChatLog.tsx","../src/lib/slashCommands.ts","../src/components/SlashCommandPicker.tsx","../src/components/ChatAttachmentStrip.tsx","../src/hooks/useChatImageAttachments.ts","../src/components/ChatInput.tsx","../src/components/Roster.tsx","../src/components/RosterButton.tsx","../src/components/Toasts.tsx","../src/roomChrome/hostTheme.ts","../src/roomChrome/observeWidth.ts","../src/roomChrome/hostIdentity.ts","../src/components/controls/Icons.tsx","../src/roomChrome/RoomChrome.tsx"],"sourcesContent":["/**\n * Chat image attachments — the WIRE contract.\n *\n * Transport lives in `@wolffm/cdn-client` (upload, `urlFor`, catalogue search,\n * the accepted-type and size limits it parity-tests against the `cdn-api`\n * worker). What remains here is the part that package deliberately does not\n * own: **the shape watchparty puts on the wire and validates on both sides.**\n *\n * This module must stay free of `@wolffm/cdn-client`, and that is structural\n * rather than stylistic — `@watchparty/shared` is imported by the SERVER, and\n * the key predicate below is what `ChatSend`/`ChatBroadcast` validate with. A\n * browser-oriented client package has no business in the server's bundle.\n *\n * **A chat message persists the KEY, never an absolute URL.** `cdn.hadoku.me`\n * is same-site with the app, which was a deliberate trade-off, and the\n * isolation upgrade (a separate registrable domain) is still on the table.\n * Storing keys makes that move a config change; storing absolute URLs would\n * make it a migration over all chat history — the most expensive place in this\n * system to be wrong. Every app that hotlinked Tenor GIFs lost its entire chat\n * back-scroll on 2026-06-30 for exactly this reason.\n */\n/**\n * Public read base for CDN objects.\n *\n * A literal rather than an env lookup on purpose: this value is needed in the\n * BROWSER bundle, where `process.env` does not exist — the `_env` guard used\n * by `PRODUCTION_UI_URL` in `defaults.ts` would resolve to the placeholder in\n * exactly the context that has to work. It is also not a secret in any sense:\n * it appears in the `src` of every image the app renders, and `hadoku.me`'s\n * CSP names it explicitly in `img-src`. Overridable per-deployment via the\n * `cdnBase` mount prop / `VITE_CDN_BASE`, and handed to `createCdnClient`.\n */\nexport const DEFAULT_CDN_BASE = 'https://cdn.hadoku.me';\n/**\n * `<64 lowercase hex chars><ext>`. Anchored, no capture of user-controlled text.\n *\n * **Deliberately stricter than `@wolffm/cdn-client`'s `isKey`,** which accepts\n * any `[a-z0-9]{1,8}` extension. This is an ALLOWLIST, so it also refuses\n * `.svg` — a scriptable document, not an image, and one served same-site with\n * the app could set a `Domain=.hadoku.me` cookie. The CDN refuses SVG by\n * sniffing bytes; this refuses it on the wire as well, so a key that somehow\n * existed still could not be rendered.\n *\n * `jpe?g` because the worker maps `image/jpeg` to `.jpg` but the older `.jpeg`\n * spelling is accepted defensively.\n */\n/**\n * **The key shape IS the trust boundary**, and it is validated in THREE places\n * on purpose — a key arrives from another client, so it is data, not input:\n *\n * 1. `ChatSend`/`ChatBroadcast` Zod (`protocol.ts`) — the server refuses to\n * persist or broadcast a bad key.\n * 2. `normalizeChatMessage` (`ui/lib/socket.ts`) — inbound messages are\n * re-checked at the client boundary; bad entries are dropped, not rendered.\n * 3. `chatImageUrl()` returns null — last line: render nothing rather than a\n * guessed URL.\n *\n * `uploadChatImage` also validates the key the SERVICE returned, because a\n * malformed one would be persisted into chat history permanently.\n *\n * A key is `<sha256-hex><ext>`: it cannot contain a scheme, host, traversal or\n * quote, which is the only reason it is safe to interpolate into an `<img src>`.\n * The extension list is an ALLOWLIST and is deliberately stricter than\n * `cdn-client.isKey` (which accepts any `[a-z0-9]{1,8}`) so that it also refuses\n * `.svg` — a scriptable document served same-site with the app, which could set\n * a `Domain=.hadoku.me` cookie. Do NOT add a client-side SVG conversion;\n * hadoku_site has a test that fails on purpose if the accepted set widens.\n */\nexport const CHAT_IMAGE_KEY_RE = /^[0-9a-f]{64}\\.(?:png|jpe?g|gif|webp|avif)$/;\n/**\n * True when `value` is a well-formed CDN object key.\n *\n * Applied in three places, all deliberate — a key arrives over the wire from\n * another client, so it is data, not a value we produced:\n * - the `ChatSend`/`ChatBroadcast` Zod schemas, so the server refuses to\n * persist or broadcast a bad one;\n * - `normalizeChatMessage` at the client boundary, so a bad entry is dropped\n * rather than rendered;\n * - `resolveChatImageUrl`, which returns null rather than a guessed URL —\n * `cdn.urlFor` composes unconditionally and is not a validator.\n */\nexport function isChatImageKey(value) {\n return typeof value === 'string' && CHAT_IMAGE_KEY_RE.test(value);\n}\n","/**\n * Centralized default values shared across server, UI, E2E, and scripts.\n *\n * This is the single source of truth for all configuration defaults.\n * Consumers should import from here instead of hardcoding values.\n */\n// ─── Ports ──────────────────────────────────────────────────────────────\n/** Default server HTTP port (matches hadoku_site PM2 ecosystem — CF tunnel routes watchparty.hadoku.me → :8080). */\nexport const DEFAULT_HTTP_PORT = 8080;\n/** Default Vite dev server port */\nexport const DEFAULT_UI_PORT = 5173;\n/** E2E test UI port (avoids colliding with dev) */\nexport const DEFAULT_E2E_UI_PORT = 5175;\n/** E2E test server port (avoids colliding with prod/dev) */\nexport const DEFAULT_E2E_SERVER_PORT = 8082;\n/** Common dev proxy port (e.g. hadoku-site reverse proxy) */\nexport const DEFAULT_DEV_PROXY_PORT = 8080;\n// ─── Server defaults ────────────────────────────────────────────────────\nexport const DEFAULT_MEDIA_ROOT = './media';\nexport const DEFAULT_CATALOG_TTL = 30;\nexport const DEFAULT_MAX_WS_PAYLOAD_BYTES = 4096;\nexport const DEFAULT_WS_BACKPRESSURE_THRESHOLD_BYTES = 262_144;\nexport const DEFAULT_LOG_DIR = './logs';\nexport const DEFAULT_LOG_RETENTION_DAYS = 14;\n/// Local SQLite store for per-friend-key match stats + lifetime aggregates.\n/// `stats.sqlite` will sit inside this dir. Gitignored; created at startup.\nexport const DEFAULT_STATS_DB_DIR = './data';\n// Drift thresholds\nexport const DEFAULT_DRIFT_SOFT_MS = 80;\n// Presence timing\nexport const DEFAULT_PRESENCE_TTL_MS = 15_000;\n// Limits\nexport const DEFAULT_MAX_ROOMS = 50;\n/** Cap on live EPHEMERAL rooms (`<app>-*`, see apps/server/src/rooms/ephemeralRooms.ts).\n * Separate from MAX_ROOMS because those rooms are minted by joining, one per\n * draft or match, and must neither crowd out the curated rooms nor be crowded\n * out by them. Still bounded: a friend-tier key is the only gate, and an\n * unbounded namespace would be a room-per-request memory leak behind it.\n *\n * 800 is MEASURED, not guessed — `scripts/stress/ramp.mjs`, 2026-09-19, on the\n * box that runs production. The 200 it replaces was a guess, and it was low:\n * 1600 rooms / 6400 sockets ran at 10% of one core with zero dropped joins,\n * so the room axis never reached a knee at all. 800 is that verified-clean\n * figure halved.\n *\n * What DOES have a knee is message throughput, and it binds regardless of how\n * the rooms are arranged: healthy to 4800 chat/s (86% of one core, p99 10ms),\n * knee at 7200/s (p99 1.3s, event-loop lag p99 61ms), collapse at 9600/s.\n * At 800 rooms every room would have to sustain 6 messages/second to reach\n * that, so the cap sits ~6x under the real constraint.\n *\n * Memory per room is bounded by DEFAULT_CHAT_MAX_MESSAGES (200) rather than by\n * room lifetime, so 800 rooms is tens of MB — the cap is an abuse bound, not a\n * capacity one. Node's event loop is single-threaded, so the ceiling to watch\n * is 100% of ONE core, not of the box.\n *\n * SHARED across every namespace in EPHEMERAL_ROOM_NAMESPACES, so a second app\n * draws on the same 800 — which the measurement above says is fine, since the\n * room axis never reached a knee at twice this. If that stops being true the\n * fix is a per-namespace cap, not a bigger shared one. */\nexport const DEFAULT_MAX_EPHEMERAL_ROOMS = 800;\n/** Child apps admitted to the unprovisioned room namespace: a key spelled\n * `<namespace>-<sessionId>` is joinable without POST /api/rooms.\n *\n * A LIST rather than a shape on purpose — see the header of\n * apps/server/src/rooms/ephemeralRooms.ts for why an enumerable namespace is\n * what keeps a mistyped or deleted curated key failing loudly instead of\n * silently minting an empty room. Overridden wholesale by\n * EPHEMERAL_ROOM_NAMESPACES; adding an app is a line in hadoku_site's pm2\n * wrapper, not a watchparty deploy. */\nexport const DEFAULT_EPHEMERAL_ROOM_NAMESPACES = ['cardtable'];\n// HTTP rate limiting (token buckets; see apps/server/src/middleware/rateLimit.ts)\n//\n// Sized from measurement, not guesswork: a fragment is 2002 ms / ~1.9 MiB, so a\n// steady viewer makes 1.0 req/s; a join or a seek is a ~30-request burst; five\n// concurrent rooms is ~5 req/s steady, and five rooms joining at once is ~150\n// requests in a few seconds. The per-user ceiling is deliberately far above\n// that: five concurrent room streams from ONE user must never 429.\n/** Per-user bucket size: absorbs ~20 join/seek bursts back to back. */\nexport const DEFAULT_RATE_LIMIT_USER_CAPACITY = 600;\n/** Per-user refill, tokens per second (1800/min — 30x one viewer's steady rate). */\nexport const DEFAULT_RATE_LIMIT_USER_REFILL_PER_S = 30;\n/** Global bucket size. Pathology catch only: an order of magnitude over any real party. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_CAPACITY = 2000;\n/** Global refill, tokens per second. The largest realistic party is ~8 req/s. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_REFILL_PER_S = 200;\n/** Retry-After (seconds) floor on a global 503, so a stampede does not retry instantly. */\nexport const DEFAULT_RATE_LIMIT_GLOBAL_RETRY_AFTER_S = 2;\n/** Most in-flight (non-terminal) submissions one user may hold, regardless of bytes. */\nexport const DEFAULT_SUBMISSION_PENDING_COUNT_CAP = 25;\n// Media serving\nexport const DEFAULT_MAX_CONCURRENT_MEDIA = 12;\nexport const DEFAULT_FRAGMENT_CACHE_MB = 64;\nexport const DEFAULT_QUEUE_TIMEOUT_MS = 45_000;\n/** Cache-Control max-age (seconds) for media BYTES under /media.\n * Deliberately minutes, not hours: cross-viewer fragment reuse happens within\n * seconds, while a longer TTL would widen the window in which a `--force`\n * re-ingest leaves stale bytes cached against fresh byte offsets. Manifests\n * (*.json) are always `no-cache` regardless of this value. */\nexport const DEFAULT_MEDIA_CACHE_MAX_AGE_S = 300;\nexport const DEFAULT_MAX_CACHEABLE_BYTES = 2_097_152; // 2MB\n// ─── WebSocket ─────────────────────────────────────────────────────────\n/** Socket.io path used by both server and UI */\nexport const DEFAULT_WS_PATH = '/watchparty/ws';\n/** Socket.io ping timeout (ms) for client keepalive */\nexport const DEFAULT_SOCKET_PING_TIMEOUT_MS = 300_000;\n// ─── Rate limiting ─────────────────────────────────────────────────────\n/** Control events: capacity per window */\nexport const DEFAULT_CONTROL_RATE_CAPACITY = 10;\n/** Control events: window in seconds */\nexport const DEFAULT_CONTROL_RATE_WINDOW_S = 5;\n/** Seek events: capacity per window */\nexport const DEFAULT_SEEK_RATE_CAPACITY = 5;\n/** Seek events: window in seconds */\nexport const DEFAULT_SEEK_RATE_WINDOW_S = 1;\n/** Chat messages: capacity per window */\nexport const DEFAULT_CHAT_RATE_CAPACITY = 3;\n/** Chat messages: window in seconds */\nexport const DEFAULT_CHAT_RATE_WINDOW_S = 1;\n/** Identity updates: capacity per window. A single join legitimately costs up\n * to 2 (name + sprite), so 2 left zero headroom — a reconnect + one sprite\n * retry instantly tripped the limit (cedar-ridge \"Coz\" incident, 2026-06-04).\n * 5 gives a join + a few corrections room to breathe. */\nexport const DEFAULT_IDENTITY_RATE_CAPACITY = 5;\n/** Identity updates: refill interval in ms */\nexport const DEFAULT_IDENTITY_RATE_WINDOW_MS = 10_000;\n// ─── Chat ──────────────────────────────────────────────────────────────\n/** Max chat message length in characters */\nexport const DEFAULT_CHAT_MAX_LENGTH = 500;\n/** Local chat send cooldown in ms */\nexport const DEFAULT_CHAT_COOLDOWN_MS = 1_000;\n/** Max chat messages retained per room (server-side Redis cap) */\nexport const DEFAULT_CHAT_MAX_MESSAGES = 200;\n/** Max chat messages retained in the UI store for display */\nexport const DEFAULT_CHAT_UI_BUFFER = 500;\n/** Number of chat messages sent to a client on join */\nexport const DEFAULT_CHAT_HISTORY_JOIN_LIMIT = 100;\n/** Default page size for chat history pagination */\nexport const DEFAULT_CHAT_PAGE_LIMIT = 50;\n/** Maximum page size cap for chat history pagination */\nexport const DEFAULT_CHAT_PAGE_LIMIT_MAX = 100;\n/** Max image attachments on a single chat message. Small on purpose: the\n * attachments ride the websocket as keys, and the chat log sits beside a\n * synchronized video player where a tall message pushes the room out of view. */\nexport const DEFAULT_CHAT_MAX_IMAGES = 4;\n// ─── GC / background ──────────────────────────────────────────────────\n/** Room idle threshold before GC (10 minutes) */\nexport const DEFAULT_GC_ROOM_IDLE_MS = 10 * 60 * 1_000;\n/** Empty room grace period before GC destroy (2 minutes) */\nexport const DEFAULT_GC_ROOM_EMPTY_GRACE_MS = 2 * 60 * 1_000;\n/** GC sweep loop interval (10 seconds) */\nexport const DEFAULT_GC_INTERVAL_MS = 10_000;\n/** Rate limiter bucket prune age (1 hour) */\nexport const DEFAULT_RATE_LIMITER_PRUNE_AGE_MS = 3_600_000;\n/** Log cleanup loop interval (1 hour) */\nexport const DEFAULT_LOG_CLEANUP_INTERVAL_MS = 3_600_000;\n// ─── Health ────────────────────────────────────────────────────────────\n/** Interval between health snapshot broadcasts to the leader */\nexport const DEFAULT_HEALTH_BROADCAST_INTERVAL_MS = 3_000;\n// ─── Application heartbeat ─────────────────────────────────────────────\n/** App-level heartbeat: disconnect sockets that stop sending time_ping for this long */\nexport const DEFAULT_APP_HEARTBEAT_TIMEOUT_MS = 60_000;\n// ─── Media scheduler tuning ────────────────────────────────────────────\n/** Warn-and-act on a media Range stream that has written no bytes for this long.\n * Lower → noisier but catches stalled streams faster; raise on flaky upstreams. */\nexport const DEFAULT_STUCK_STREAM_THRESHOLD_MS = 15_000;\n/** Kill a media Range stream that has been stuck (no bytes written) this long.\n * Without this, a stuck stream holds its concurrency slot until the OS times\n * out — under sustained load the pool drains and every fresh fetch queues for\n * 45s → 503 cascade. Reproduced in iter3 (2026-05-25): audio_jpn_X.m4a hit\n * 40s+ TTFB at origin, all 4 viewers cascaded into HTTP 500. Threshold must\n * be > STUCK_STREAM_THRESHOLD_MS so warns precede the kill. */\nexport const DEFAULT_STUCK_STREAM_ABORT_MS = 30_000;\n/** A media client (by ip) is considered \"currently active\" if it has dispatched\n * a request within this window. Used by the priority-queue fairness logic. */\nexport const DEFAULT_MEDIA_CLIENT_ACTIVE_WINDOW_MS = 30_000;\n/** Rolling window for per-client throughput/queue stats summaries. */\nexport const DEFAULT_MEDIA_CLIENT_STATS_WINDOW_MS = 60_000;\n/** Buffer-ahead-ms below which a viewer's request is flagged as \"emergency\"\n * and dispatched into the reserve slot ahead of regular waiters. */\nexport const DEFAULT_EMERGENCY_BUFFER_MS = 3_000;\n/** How often the media-scheduler emits its periodic summary log when there is\n * active traffic. Skipped entirely when the system is idle. */\nexport const DEFAULT_MEDIA_SCHEDULER_SUMMARY_INTERVAL_MS = 30_000;\n/** Grace window after a socket disconnect before we broadcast a \"leave\" chat\n * system message. Lets transient reconnects (CF tunnel hiccups, refresh,\n * cellular↔wifi handoff on phones) avoid noisy join/leave churn. 60s\n * covers cellular↔wifi handoff stabilization — was 15s before Bug 2 in\n * the 2026-05-20 cedar-ridge retrospective showed phones with measured\n * reconnect gaps up to 60s producing spam. */\nexport const DEFAULT_CHAT_RECONNECT_GRACE_MS = 60_000;\n/** Periodic heartbeat byte interval (ms) for `streamE2eProcess` — keeps\n * Cloudflare-Free from idling out long-silent ingest/test streams. */\nexport const DEFAULT_STREAM_KEEPALIVE_HEARTBEAT_MS = 20_000;\n// ─── Client log endpoint ───────────────────────────────────────────────\n/** Max log entries per client-log POST request */\nexport const DEFAULT_CLIENT_LOG_MAX_ENTRIES = 50;\n// ─── Production URLs ───────────────────────────────────────────────────\n// Set via PRODUCTION_UI_URL / PRODUCTION_SERVER_URL env vars (e.g. in CI or .env).\n// Defaults to example.com so the public repo never leaks a real domain.\n// Guard: `process` is unavailable when this module is bundled for the browser.\nconst _env = typeof process !== 'undefined' && process.env ? process.env : {};\nexport const PRODUCTION_UI_URL = _env.PRODUCTION_UI_URL || 'https://example.com/watchparty/';\nexport const PRODUCTION_SERVER_URL = _env.PRODUCTION_SERVER_URL || 'https://watchparty.example.com';\n// ─── Stress test defaults ──────────────────────────────────────────────\nexport const DEFAULT_STRESS_VIEWERS = 10;\nexport const DEFAULT_STRESS_DURATION_SECS = 120;\nexport const DEFAULT_STRESS_MONITOR_INTERVAL_SECS = 10;\n// ─── Pino log levels ───────────────────────────────────────────────────\nexport const PINO_LEVEL_ERROR = 50;\nexport const PINO_LEVEL_WARN = 40;\nexport const PINO_LEVEL_INFO = 30;\nexport const PINO_LEVEL_DEBUG = 20;\n","/**\n * Tierlist scoring — turning N private S-to-F boards into one room board.\n *\n * This module is PURE and shared by client and server on purpose. The board\n * every viewer sees must be byte-identical to the one the leader sees and to\n * the one written into the stats DB; a hand-ported second copy of these rules\n * would drift silently and nobody would notice, because a plausible-but-wrong\n * tierlist looks exactly like a correct one.\n *\n * ── What is stored vs what is derived ──────────────────────────────────────\n * ONLY the raw letters are ever persisted (`Record<mediaId, TTier>` per voter).\n * Everything below runs at render time. That means the curve can be retuned\n * later without touching a single stored ballot, and the UI can always show\n * the raw picks next to the derived tier so the board stays auditable against\n * what people actually chose. Do NOT persist the output of `placeTierlist`\n * as if it were input.\n *\n * ── Why the raw average is not enough ──────────────────────────────────────\n * Averaging shrinks variance by ~sqrt(nVoters). Even when every individual\n * uses the full S-to-F range, the room means bunch toward the middle, and\n * rounding them lands most clips in B and C — a flat, unreadable board. That\n * compression is an ARTIFACT of averaging, not a finding about the clips, so\n * expanding it back is not fabricating signal.\n *\n * ── The rule: rescale the spread, never move the centre ────────────────────\n * A clip starts at the plain rounded average of its letters. When the board is\n * compressed (but not unanimous) each clip is nudged toward a more spread\n * board, and NEVER by more than `NUDGE_MAX_SHIFT` tiers.\n *\n * That clamp is load-bearing and structural, not a tuning knob. Because the\n * nudge is anchored on each clip's OWN average, an all-S board has every clip\n * anchored at S and the nudge can only ever step one tier — so a room that\n * genuinely loved everything stays top-heavy and CANNOT be turned into a bell\n * curve. Do not replace the clamp with a pure quantile allocation: quantile\n * moves the centre too, which is exactly the failure this design exists to\n * avoid.\n *\n * ── Why the gate has two sides (both verified, do not remove either) ───────\n * Nudging unconditionally was tried and is wrong at BOTH ends:\n * - Below `NUDGE_MIN_SD`: a unanimous board (everyone said B to everything,\n * sd = 0) got shifted a whole tier to A. Moving a perfect consensus is\n * indefensible.\n * - At or above `NUDGE_MAX_SD`: an already well-spread board\n * (S1 A2 B3 C3 D2 F2) was rearranged into a worse one (S2 A4 B1 C4 D1 F1).\n * There was nothing to fix.\n * So the nudge runs only in the band between them.\n *\n * ── Voter centring only bites on PARTIAL ballots ───────────────────────────\n * Subtracting each voter's own mean is provably a no-op when everyone rated\n * every clip — measured difference exactly 0.0000 — because it shifts every\n * clip by the same constant and cancels. It matters only when voters rated\n * DIFFERENT subsets: a late joiner, or someone who left before the end. That\n * is the common case in a watchparty, which is why it is here. The mean must\n * be taken over the clips THAT VOTER rated, never over all clips; taking it\n * over all clips silently reduces it to the no-op case.\n */\nimport { z } from 'zod';\nexport const Tier = z.enum(['S', 'A', 'B', 'C', 'D', 'F']);\n/** Display order, best first. Also the row order of the board. */\nexport const TIER_ORDER = ['S', 'A', 'B', 'C', 'D', 'F'];\n/** Numeric level per tier. Higher is better; F = 1 so that no level is 0\n * (a 0 would make \"unrated\" and \"rated F\" indistinguishable in a sum). */\nexport const TIER_LEVEL = { S: 6, A: 5, B: 4, C: 3, D: 2, F: 1 };\nconst MIN_LEVEL = 1;\nconst MAX_LEVEL = 6;\n/** Nudge gate, lower bound. Below this the room has effectively agreed and the\n * board is left exactly as the averages placed it. */\nexport const NUDGE_MIN_SD = 0.35;\n/** Nudge gate, upper bound. At or above this the board already reads well and\n * is left alone. */\nexport const NUDGE_MAX_SD = 1.0;\n/** Hard cap on how far the nudge may move a clip from its own average, in\n * tiers. This is what makes a genuine lean impossible to flatten. */\nexport const NUDGE_MAX_SHIFT = 1;\n/** One step of within-tier order, in score units.\n *\n * Deliberately two orders of magnitude below a tier (1.0): a clip's position\n * in its row refines the ORDER of that row and must never change which row it\n * is in. See `subRankOffset` for why this is applied AFTER placement rather\n * than folded into the score the tier is derived from. */\nexport const SUB_TIER_STEP = 0.01;\n/** Ceiling on the total sub-rank offset. Keeps the integer part of a displayed\n * score equal to its tier level even for an absurdly long row, which is the\n * property that makes the number readable at a glance (an S clip reads 6.0x,\n * never 7.0). */\nexport const SUB_TIER_MAX = 0.49;\n/** Target share per tier, S..F, used only to rank-order a compressed board.\n * Deliberately mild — a strong shape would fight the clamp and never win. */\nexport const TARGET_SHAPE = [0.08, 0.17, 0.25, 0.25, 0.17, 0.08];\n/** Flatten a ballot to `mediaId -> tier`, discarding order. */\nexport function ballotPicks(ballot) {\n const out = {};\n for (const tier of TIER_ORDER) {\n for (const mediaId of ballot.board[tier] ?? [])\n out[mediaId] = tier;\n }\n return out;\n}\n/** Clamp to the tier scale and convert a numeric level to a letter. */\nexport function levelToTier(level) {\n const rounded = Math.round(level);\n const clamped = Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, rounded));\n // TIER_ORDER is best-first (S=6 at index 0), so index = MAX - level.\n return TIER_ORDER[MAX_LEVEL - clamped];\n}\n/** Population standard deviation. Population, not sample: these are all the\n * clips in the playlist, not a draw from a larger pool. */\nfunction stdev(values) {\n if (values.length === 0)\n return 0;\n const mean = values.reduce((a, b) => a + b, 0) / values.length;\n const variance = values.reduce((a, v) => a + (v - mean) * (v - mean), 0) / values.length;\n return Math.sqrt(variance);\n}\n/**\n * Rank each clip onto a tier by `TARGET_SHAPE`.\n *\n * Clips with an identical score MUST come out on the same tier — otherwise the\n * arbitrary order of two equal clips decides which row they land in, and two\n * clients sorting equal scores differently would render different boards. Ties\n * are therefore repaired to the tier their group's MIDPOINT position falls in.\n */\nfunction rankTiers(scores) {\n const n = scores.size;\n const cuts = [];\n let run = 0;\n for (let i = 0; i < TARGET_SHAPE.length - 1; i++) {\n run += TARGET_SHAPE[i] * n;\n cuts.push(run);\n }\n const tierAt = (position) => {\n for (let j = 0; j < cuts.length; j++) {\n if (position < cuts[j])\n return MAX_LEVEL - j;\n }\n return MIN_LEVEL;\n };\n // Order by score desc, mediaId asc — the same total order used for display,\n // so ranking and rendering can never disagree.\n const order = [...scores.keys()].sort((a, b) => {\n const d = scores.get(b) - scores.get(a);\n return d !== 0 ? d : a.localeCompare(b);\n });\n const position = new Map();\n order.forEach((id, i) => position.set(id, i));\n const out = new Map();\n for (const id of order)\n out.set(id, tierAt(position.get(id)));\n // Tie repair: every clip sharing a score takes the group's midpoint tier.\n // That ties stay TOGETHER is the invariant (tested). Midpoint specifically —\n // rather than the group's best or worst position — is a judgment call: it\n // keeps a tied pair straddling a cut from being pulled wholly into the more\n // generous row. Change it if you prefer, but keep them on one row.\n const groups = new Map();\n for (const [id, score] of scores) {\n const key = Math.round(score * 1e6) / 1e6;\n const g = groups.get(key);\n if (g)\n g.push(id);\n else\n groups.set(key, [id]);\n }\n for (const group of groups.values()) {\n if (group.length < 2)\n continue;\n const mid = group.reduce((a, id) => a + position.get(id), 0) / group.length;\n const tier = tierAt(mid);\n for (const id of group)\n out.set(id, tier);\n }\n return out;\n}\n/**\n * Collapse a set of ballots into one board.\n *\n * `mediaIds` is the playlist's full clip list and fixes the universe — a clip\n * nobody rated comes back in `unrated` rather than being given an invented\n * score.\n */\nexport function placeTierlist(mediaIds, ballots) {\n // Flatten each ballot once: tier, plus the clip's rank from the BOTTOM of\n // its row and that row's size. Rank-from-bottom so the top of a row scores\n // highest, matching how a tierlist is read.\n const picksOf = new Map();\n const rankOf = new Map();\n for (const ballot of ballots) {\n picksOf.set(ballot.voterId, ballotPicks(ballot));\n const ranks = new Map();\n for (const tier of TIER_ORDER) {\n const row = ballot.board[tier] ?? [];\n row.forEach((mediaId, i) => ranks.set(mediaId, row.length - 1 - i));\n }\n rankOf.set(ballot.voterId, ranks);\n }\n // ── Per-voter mean, over the clips THAT VOTER rated ────────────────────\n const voterMean = new Map();\n let grandTotal = 0;\n let grandCount = 0;\n for (const ballot of ballots) {\n const picks = picksOf.get(ballot.voterId);\n const levels = mediaIds\n .filter(id => picks[id] !== undefined)\n .map(id => TIER_LEVEL[picks[id]]);\n if (levels.length === 0)\n continue;\n const sum = levels.reduce((a, b) => a + b, 0);\n voterMean.set(ballot.voterId, sum / levels.length);\n grandTotal += sum;\n grandCount += levels.length;\n }\n // Re-anchor the centred deviations on the grand mean so scores stay on the\n // 1..6 tier scale instead of hovering around zero.\n const grandMean = grandCount > 0 ? grandTotal / grandCount : 0;\n // ── Voter-centred score per clip ───────────────────────────────────────\n const scores = new Map();\n const votesByClip = new Map();\n /** Did every rater of this clip give it the same letter? */\n const isUnanimous = new Map();\n const unrated = [];\n const subRank = new Map();\n for (const mediaId of mediaIds) {\n const raw = {};\n let deviationSum = 0;\n let rankSum = 0;\n let raters = 0;\n for (const ballot of ballots) {\n const pick = picksOf.get(ballot.voterId)[mediaId];\n if (pick === undefined)\n continue;\n const mean = voterMean.get(ballot.voterId);\n if (mean === undefined)\n continue;\n raw[ballot.voterId] = pick;\n deviationSum += TIER_LEVEL[pick] - mean;\n rankSum += rankOf.get(ballot.voterId)?.get(mediaId) ?? 0;\n raters++;\n }\n votesByClip.set(mediaId, raw);\n if (raters === 0) {\n unrated.push(mediaId);\n continue;\n }\n scores.set(mediaId, deviationSum / raters + grandMean);\n isUnanimous.set(mediaId, new Set(Object.values(raw)).size === 1);\n // Mean rank-from-bottom across the voters who placed it, scaled into score\n // units and capped. With a single voter this is exactly the ladder the\n // feature was asked for: five clips in one row score X.04 down to X.00.\n subRank.set(mediaId, Math.min(SUB_TIER_MAX, SUB_TIER_STEP * (rankSum / raters)));\n }\n if (scores.size === 0) {\n return { placed: [], unrated, spread: 0, nudged: false };\n }\n // ── Absolute placement — the honest baseline ───────────────────────────\n const absolute = new Map();\n for (const [id, score] of scores) {\n absolute.set(id, Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, Math.round(score))));\n }\n // ── Gated, clamped nudge ───────────────────────────────────────────────\n const spread = stdev([...scores.values()]);\n const nudged = spread >= NUDGE_MIN_SD && spread < NUDGE_MAX_SD;\n const finalLevel = new Map(absolute);\n if (nudged) {\n const ranked = rankTiers(scores);\n for (const [id, abs] of absolute) {\n // NEVER overrule unanimity. The nudge exists to undo the variance that\n // AVERAGING removes; a clip its raters all put in one tier had nothing\n // averaged away, so there is nothing to restore and moving it just\n // contradicts them.\n //\n // This is not a corner case — it is every single-voter board, where each\n // clip is trivially unanimous. Without the guard a lone voter's board\n // came back shifted a whole tier DOWN: two shows placed in S were\n // revealed in A (observed on production, rainbow-roost). `absolute` was\n // right the whole time; the nudge was the only thing moving them.\n if (isUnanimous.get(id))\n continue;\n const want = ranked.get(id);\n finalLevel.set(id, Math.max(abs - NUDGE_MAX_SHIFT, Math.min(abs + NUDGE_MAX_SHIFT, want)));\n }\n }\n // ── Deterministic output order ─────────────────────────────────────────\n // Tier desc, then score desc, then mediaId asc. The mediaId tiebreak is what\n // guarantees every client renders the same board for equal scores.\n const placed = [...scores.keys()]\n .map(mediaId => {\n const tier = levelToTier(finalLevel.get(mediaId));\n const offset = subRank.get(mediaId) ?? 0;\n return {\n mediaId,\n tier,\n absoluteTier: levelToTier(absolute.get(mediaId)),\n // Tier level + position in the row. NOT the placement score — see the\n // field comment for why folding them together breaks placement.\n score: TIER_LEVEL[tier] + offset,\n placementScore: scores.get(mediaId),\n subRankOffset: offset,\n votes: votesByClip.get(mediaId) ?? {},\n };\n })\n .sort((a, b) => {\n const byTier = TIER_LEVEL[b.tier] - TIER_LEVEL[a.tier];\n if (byTier !== 0)\n return byTier;\n // Within a row: sub-rank first (that IS the row order people set), then\n // the consensus that put them in the row, then mediaId so two clients\n // sorting equal boards can never disagree.\n const byScore = b.score - a.score;\n if (byScore !== 0)\n return byScore;\n const byPlacement = b.placementScore - a.placementScore;\n if (byPlacement !== 0)\n return byPlacement;\n return a.mediaId.localeCompare(b.mediaId);\n });\n return { placed, unrated, spread, nudged };\n}\n/**\n * Map a legacy `VoteValue` onto a tier.\n *\n * The vote playlist's four values are a coarse tierlist already, so an existing\n * vote board can be read as one. `mid` deliberately maps to C rather than B:\n * the vote scale is not symmetric (one positive extreme, one step down, a\n * neutral, one negative) and C is the neutral row of a six-tier board.\n */\nexport const VOTE_TO_TIER = {\n super_upvote: 'S',\n upvote: 'A',\n mid: 'C',\n downvote: 'F',\n};\n","import { z } from 'zod';\nimport { CHAT_IMAGE_KEY_RE } from './chatImages.js';\nimport { DEFAULT_CHAT_MAX_LENGTH, DEFAULT_CHAT_MAX_IMAGES } from './defaults.js';\n/** Upper bound on a reported image dimension. Guards against a client sending\n * an absurd number that would drive a huge CSS aspect-ratio box. */\nconst MAX_IMAGE_DIMENSION = 20_000;\n/** Upper bound on an image's `alt` text. The meme library's longest title sits\n * around 110 chars; this leaves room without letting one attachment carry\n * more prose than the message itself. */\nconst MAX_IMAGE_TITLE_LENGTH = 300;\n// Identity-update Zod schema — used by the server's identity_update WS handler\n// to validate inbound payloads (presence.ts). Kept here next to the other\n// inbound schemas like JoinIn / ChatSend.\nexport const IdentityUpdateSchema = z.object({\n name: z.string().min(1).max(32).optional(),\n sprite: z.string().min(1).max(32).optional(),\n /** \"My avatar settings just changed — re-read my picture\" (the fleet\n * settings popout's `hadoku:avatar` event). Carries no picture: the server\n * resolves it from prefs-api by registry userId, never from the client. */\n avatar: z.literal(true).optional(),\n});\n// Wire event names — the single source of truth.\n// Use these instead of string literals when subscribing/emitting. New events MUST be added here\n// so server + client + tests can't drift. (Family-specific events live in PLAYLIST_EVENTS / READY_EVENTS below.)\nexport const CORE_EVENTS = {\n JOIN: 'join',\n SNAPSHOT: 'snapshot',\n CONTROL: 'control',\n CONTROL_ACK: 'control_ack',\n CONTROL_BROADCAST: 'control_broadcast',\n CHAT_SEND: 'chat_send',\n CHAT_BROADCAST: 'chat_broadcast',\n CHAT_HISTORY: 'chat_history',\n PRESENCE: 'presence',\n PRESENCE_PING: 'presence_ping',\n IDENTITY_UPDATE: 'identity_update',\n TIME_PING: 'time_ping',\n TIME_PONG: 'time_pong',\n HEALTH_REPORT: 'health_report',\n HEALTH_SNAPSHOT: 'health_snapshot',\n BUFFER_STATUS: 'buffer_status',\n CLIENT_READY: 'client_ready',\n // 'ready_state' here = playback-readiness gate (distinct from READY_EVENTS.STATE = \"ready check\" UX feature)\n READY_STATE: 'ready_state',\n ERROR: 'error',\n};\n// Control kinds per protocol.md\nexport const ControlKind = z.enum(['play', 'pause', 'seek', 'load', 'home']);\n// Optional identity fields (name + sprite) allow client roster population immediately after join.\n// These are advisory and not authenticated; server will simply echo through presence lists.\n// NOTE: room_key now determines identity (viewer vs leader) via server-side lookup.\n// leader_key and override fields removed - identity is embedded in the room_key itself.\n// How the client is framed. `client_url` is read from the app's own window and\n// is identical for a direct load and for the nested-iframe entry from the\n// hadoku.me home page, so nothing in the logs could distinguish the two entry\n// paths (2026-08-04). Optional + backward compatible: older clients omit it.\nexport const EmbedContext = z.object({\n framed: z.boolean(),\n depth: z.number().int().nonnegative().max(10)\n});\nexport const JoinIn = z.object({\n room_key: z.string(),\n name: z.string().min(1).max(32).optional(),\n sprite: z.string().min(1).max(32).optional(),\n embed: EmbedContext.optional(),\n /** Which media-delivery path this client resolved to ('range' | 'fragment').\n * Optional so older clients still validate. Reported so a party's logs can be split by\n * arm — without it an A/B rollout is unmeasurable, exactly as entry path was before\n * `embed` existed. */\n delivery_arm: z.enum(['range', 'fragment']).optional(),\n /** Browser-persisted device id (`hadoku_device_id`, owned by\n * @wolffm/prefs-client). Lets the server derive a `dev:`-tier\n * ParticipantId for a client with no friend key, so an anonymous viewer\n * keeps their identity across a reload instead of becoming a new person.\n *\n * Optional because it genuinely is: a first-ever visit, or a browser with\n * storage blocked, sends nothing and gets an `eph:` identity. Also keeps\n * older clients validating.\n *\n * CLIENT-ASSERTED — nothing proves it, so it must never gate a capability.\n * See `isAuthenticatedTier` in @wolffm/rooms-protocol. */\n device_id: z.string().min(1).max(128).optional(),\n /** Random per PAGE LOAD, carried on every rejoin from that page. A join whose\n * tab id matches a live connection of the same participant is that tab\n * reconnecting, so the server retires the old connection at once instead of\n * showing it as a second row until the heartbeat reaps it (mistral-bluff\n * 2026-10-20). Never broadcast: it is what lets a rejoin evict a connection.\n * CLIENT-ASSERTED, so it never gates a capability. */\n tab_id: z.string().min(8).max(64).optional(),\n /** Present on a rejoin only: why this page's previous connection ended, as the\n * BROWSER saw it, and for how long it was gone. The server cannot know either\n * — a connection cut upstream never reaches it as a close. */\n reconnect: z.object({\n count: z.number().int().nonnegative(),\n reason: z.string().max(64),\n offline_ms: z.number().int().nonnegative(),\n }).optional(),\n});\nexport const Snapshot = z.object({\n // The room's id — the same for every participant, unlike the key each one\n // joined with (a leader key is a credential). What a game bundle is told\n // its room is (GameBundleSlot).\n room_id: z.string(),\n play_state: z.enum(['idle', 'playing', 'paused', 'ended']),\n playhead_ms: z.number().int().nonnegative(),\n leader_id: z.string().nullable(),\n media_id: z.string().nullable(),\n server_seq: z.number().int().nonnegative(),\n server_ts: z.number().int().nonnegative()\n});\nexport const ControlIn = z.object({\n client_seq: z.number().int().positive(),\n kind: ControlKind,\n to_ms: z.number().int().nonnegative().optional(),\n media_id: z.string().optional()\n // leader_id removed - not needed since set_leader control was removed\n});\n/**\n * A CDN image attachment on a chat message.\n *\n * `key` is validated against the content-addressed shape rather than accepted\n * as free text: it ends up in an `<img src>` on every client in the room, so\n * the schema is the trust boundary. `w`/`h` are the intrinsic pixel dimensions,\n * measured client-side before upload (the CDN deliberately does not return\n * them) so the chat log can reserve layout space and not reflow when the image\n * loads — which matters more here than in most chat apps, because the log sits\n * beside a synchronized video player where reflow is very visible.\n */\nexport const ChatImage = z.object({\n key: z.string().regex(CHAT_IMAGE_KEY_RE, 'not a CDN image key'),\n /**\n * Intrinsic pixels, or **0 meaning \"unknown\"**.\n *\n * `nonnegative`, not `positive`: `measureImage()` falls back to 0x0 when a\n * file cannot be decoded (a corrupt paste, or a format `createImageBitmap`\n * and the `<img>` fallback both refuse), and the renderer has always handled\n * that — `hasDims = w > 0 && h > 0` drops it to natural flow instead of\n * reserving a box. The schema disagreed, so such a message failed validation\n * and was DROPPED by the server: the paste vanished and the user's typed\n * text went with it, with nothing said. Widening is backward compatible —\n * every message already in history carries positive dimensions.\n */\n w: z.number().int().nonnegative().max(MAX_IMAGE_DIMENSION),\n h: z.number().int().nonnegative().max(MAX_IMAGE_DIMENSION),\n /**\n * Human-readable description, rendered as the `alt` text.\n *\n * Present for a meme picked from the shared library (where it is frequently\n * the ONLY text describing the image, so a meme-heavy log is unusable with a\n * screen reader without it) and absent for a pasted screenshot, which has no\n * title to carry. Optional so that older clients — and every message already\n * in chat history — still validate.\n *\n * It is untrusted text from another client and is rendered as an attribute\n * value only, never as markup. Bounded so one message cannot carry a\n * paragraph; the library's own titles run well under this.\n */\n title: z.string().max(MAX_IMAGE_TITLE_LENGTH).optional(),\n});\n/**\n * Inbound chat. `text` may be empty ONLY when the message carries at least one\n * image — the refine keeps \"no empty messages\" true while allowing an\n * image-only paste, which is the common case.\n */\nexport const ChatSend = z.object({\n text: z.string().max(DEFAULT_CHAT_MAX_LENGTH),\n style: z.string().max(64).optional(),\n images: z.array(ChatImage).max(DEFAULT_CHAT_MAX_IMAGES).optional(),\n}).refine((d) => d.text.length > 0 || (d.images?.length ?? 0) > 0, { message: 'message must carry text or at least one image' });\n// System message types for chat\nexport const SystemMessageType = z.enum(['join', 'leave', 'media_change', 'identity_change', 'recap']);\n// Chat broadcast payload (outbound from server)\nexport const ChatBroadcast = z.object({\n id: z.string(),\n client_id: z.string(),\n // The sender's participant handle — stable across their reconnects, unlike\n // `client_id`. Optional: system messages and pre-existing history have none.\n author: z.string().optional(),\n name: z.string(),\n sprite: z.string().nullable(),\n text: z.string(),\n ts: z.number(),\n // Optional so a client on an older bundle still validates a message that\n // has none, and so system messages (which never carry images) stay unchanged.\n images: z.array(ChatImage).max(DEFAULT_CHAT_MAX_IMAGES).optional(),\n isSystem: z.boolean().optional(),\n systemType: SystemMessageType.optional()\n});\n// Health reporting (client → server)\nexport const HealthReport = z.object({\n avg_download_rate_bps: z.number().nonnegative(),\n buffered_ahead_ms: z.number().nonnegative(),\n rtt_ms: z.number().nonnegative(),\n playhead_ms: z.number().nonnegative(),\n is_buffering: z.boolean(),\n fragments_loaded: z.number().int().nonnegative(),\n fragments_pending: z.number().int().nonnegative(),\n fragments_error: z.number().int().nonnegative(),\n video_ready_state: z.number().int().optional(),\n /** Buffered-ahead of the WORST AUDIO track, ms. `buffered_ahead_ms` above is\n * already the min across video and audio; this is reported separately so the\n * health card (and any later analysis) can say WHICH track is starving.\n * -1 means \"no audio FragmentManager\" (media still loading, or audio not yet\n * attached) and must never be treated as a zero-length buffer. Defaults to\n * -1 so older clients, which never send it, are read as \"unknown\". */\n audio_buffered_ahead_ms: z.number().default(-1),\n // Nominal media duration (ms) = last fragment's end. Lets the leader's health\n // card distinguish \"near end-of-stream, little buffer ahead is expected\" from\n // a real low-buffer stall. Defaults to 0 so older clients still validate.\n duration_ms: z.number().nonnegative().default(0),\n /** Media delivery path this client is actually using. Reported HERE and not only at\n * join because the join-time value is provisional — /api/flags lands after the socket\n * connects, so the arm is not final until FragmentManager first reads it. Optional so\n * older clients still validate. */\n delivery_arm: z.enum(['range', 'fragment']).optional(),\n /** Page state, so a stall can be read against what the viewer could see.\n * Without it \"he says he saw nothing\" and \"his player froze for 55s\" could\n * not be reconciled (mistral-bluff 2026-10-20). All optional: older\n * clients omit them, and `net_*` exist only where the browser exposes\n * navigator.connection (Chromium). */\n page_visible: z.boolean().optional(),\n page_focused: z.boolean().optional(),\n muted: z.boolean().optional(),\n online: z.boolean().optional(),\n net_type: z.string().max(16).optional(),\n net_rtt_ms: z.number().nonnegative().optional(),\n net_downlink_mbps: z.number().nonnegative().optional(),\n /** ms since this connection last received a time_pong — a half-open\n * socket shows here as a growing number while everything else looks fine. */\n pong_age_ms: z.number().int().nonnegative().optional(),\n reported_at: z.number().int().nonnegative()\n});\n// Health snapshot (server → leader): per-client health with identity\nexport const ClientHealth = HealthReport.extend({\n client_id: z.string(),\n name: z.string(),\n sprite_id: z.string().nullable(),\n /** Set when this connected client's last report is older than the freshness\n * window: how old it is, ms. The card then shows the client as silent\n * rather than dropping it — a viewer whose reports cannot get through is the\n * one the leader most needs to see. */\n stale_ms: z.number().int().nonnegative().optional(),\n});\nexport const HealthSnapshot = z.object({\n clients: z.array(ClientHealth),\n server_ts: z.number().int().nonnegative()\n});\n// Buffer status (server → all clients in room): lightweight per-client buffer data\nexport const BufferClient = z.object({\n client_id: z.string(),\n sprite_id: z.string().nullable(),\n name: z.string(),\n buffered_ahead_ms: z.number().nonnegative(),\n // playhead + duration let the viewer buffer ring apply the same near-end\n // exemption the leader's health card uses: near end-of-stream there is\n // legitimately little buffer ahead, so a low buffered_ahead_ms there must NOT\n // render \"critical\" red. Default 0 so older clients still validate.\n playhead_ms: z.number().nonnegative().default(0),\n duration_ms: z.number().nonnegative().default(0),\n /** The video element's readyState, forwarded so the viewer buffer ring can\n * apply the same \"plenty buffered but the element cannot advance\" rule the\n * leader's card uses (see components/bufferHealth.ts). -1 = unknown/older\n * client, which must never trip the rule. */\n video_ready_state: z.number().int().default(-1),\n});\nexport const BufferStatus = z.object({\n clients: z.array(BufferClient),\n server_ts: z.number().int().nonnegative(),\n});\n// Client readiness signal (client → server)\nexport const ClientReady = z.object({\n media_id: z.string(),\n});\n// ---------------------------------------------------------------------------\n// Playlist protocol\n// ---------------------------------------------------------------------------\nimport { Tier } from './tierlist.js';\nexport const VoteValue = z.enum(['super_upvote', 'upvote', 'mid', 'downvote']);\nexport const PlaylistVideo = z.object({\n mediaId: z.string(),\n displayName: z.string(),\n // Enrichment fields — present for flat-playlist ingest output, absent for\n // subfolder-layout playlist clips. All optional so old playlist.json stays valid.\n description: z.string().optional(),\n thumbnail: z.string().optional(), // media-relative path, e.g. output/fmp4/.../poster.jpg\n durationMs: z.number().optional(),\n uploadDate: z.string().optional(), // YouTube upload date 'YYYY-MM-DD'; drives playlist order\n});\n// Playlist type — `vote` is the rate-and-reveal playlist (per-video votes,\n// reveal popup); `basic` is a plain sequential watch-through with no voting.\n// Defaults to `vote` so a type-less playlist.json keeps its original behavior.\n//\n// There is deliberately no `tierlist` type: the tierlist is a GLOBAL board, not\n// a playback mode, and coupling it to playlist loading was a mistake that made\n// it vanish on every media change.\nexport const PlaylistType = z.enum(['basic', 'vote']);\nexport const PlaylistConfig = z.object({\n id: z.string(),\n name: z.string(),\n type: PlaylistType.default('vote'),\n videos: z.array(PlaylistVideo),\n cover: z.string().optional(), // media-relative path to the playlist cover image\n});\n// Client -> Server\nexport const PlaylistLoadIn = z.object({ playlistId: z.string() });\nexport const PlaylistSelectIn = z.object({ index: z.number().int().min(0) });\nexport const PlaylistVoteUpdateIn = z.object({\n playlistId: z.string(),\n mediaId: z.string(),\n vote: VoteValue,\n});\n/** Leader removes one voter from the revealed results. `voterKey` is a key of\n * `PlaylistResultsOut.votes` — the reveal-time id, which survives the voter\n * leaving (removal is mostly used on people who already left). */\nexport const PlaylistVoteRemoveIn = z.object({ voterKey: z.string().min(1).max(128) });\n/** Server -> a removed voter: drop your stored ballot. Carries the media ids\n * because on REJOIN it arrives before playlist_state (deliberately — that\n * state is what triggers the client's re-send), so the client cannot look the\n * playlist up yet. */\nexport const PlaylistVotesClearedOut = z.object({ playlistId: z.string(), mediaIds: z.array(z.string()) });\n// Server -> Client\nexport const PlaylistStateOut = z.object({\n playlist: PlaylistConfig.nullable(),\n activeIndex: z.number().int().nullable(),\n myVotes: z.record(z.string(), VoteValue), // mediaId -> VoteValue\n});\nexport const PlaylistVoteProgressItem = z.object({\n clientId: z.string(),\n name: z.string(),\n votedCount: z.number().int().nonnegative(),\n totalVideos: z.number().int().nonnegative(),\n});\nexport const PlaylistVoteProgressOut = z.object({\n progress: z.array(PlaylistVoteProgressItem),\n});\nexport const PlaylistResultsOut = z.object({\n playlist: PlaylistConfig,\n votes: z.record(z.string(), z.record(z.string(), VoteValue)), // votes[clientId][mediaId]\n names: z.record(z.string(), z.string()), // clientId -> display name\n /** clientId -> sprite id, frozen at reveal, so a voter who has since LEFT\n * (and so is gone from the live roster) still renders. Optional: older\n * servers omit it and clients fall back to the roster. */\n sprites: z.record(z.string(), z.string()).optional(),\n /** clientId -> avatar picture URL, frozen at reveal like `sprites`. Only\n * people who have a picture; everyone else shows their character. */\n pictures: z.record(z.string(), z.string()).optional(),\n /** True when this re-sends results after a voter was removed. A client whose\n * reveal is closed ignores it, rather than having the popup reappear. */\n update: z.boolean().optional(),\n});\nexport const PLAYLIST_EVENTS = {\n LOAD: 'playlist_load',\n SELECT: 'playlist_select',\n VOTE_UPDATE: 'playlist_vote_update',\n REVEAL_REQUEST: 'playlist_reveal_request',\n STATE: 'playlist_state',\n VOTE_PROGRESS: 'playlist_vote_progress',\n RESULTS: 'playlist_results',\n /** Leader -> server: drop a voter from the revealed results. */\n VOTE_REMOVE: 'playlist_vote_remove',\n /** Server -> the removed voter: forget the ballot you hold in localStorage. */\n VOTES_CLEARED: 'playlist_votes_cleared',\n};\n// ---------------------------------------------------------------------------\n// Tierlist protocol — ONE GLOBAL BOARD\n// ---------------------------------------------------------------------------\n//\n// Not room state, and not a playlist. The tierlist is one board the whole site\n// shares: openable from anywhere, persisted in SQLite, and outliving every\n// room. An earlier version hung it off a loaded playlist, which meant it\n// vanished when the room changed media and two rooms could not see the same\n// votes.\n//\n// `voterId` on this wire is an OPAQUE token, never the friend key — that IS the\n// X-User-Key credential, and a board every viewer receives cannot carry it.\n/** One rankable entry. `id` doubles as the media path, so card art resolves by\n * the usual `<id>/poster.jpg` convention. */\nexport const TierlistItem = z.object({\n id: z.string(),\n displayName: z.string(),\n thumbnail: z.string().optional(),\n});\n/** `config/tierlist.json` — which entries are eligible this season. */\nexport const TierlistConfig = z.object({\n items: z.array(TierlistItem),\n});\n/** A board: the ordered item ids in each tier. Order within a row is part of\n * the ballot (it feeds the sub-rank), not presentation. */\nexport const TierlistBoard = z.partialRecord(Tier, z.array(z.string()));\n/** Client -> Server. Saving REPLACES the caller's whole board. */\nexport const TierlistSaveIn = z.object({ board: TierlistBoard });\n/** One entry on the shared board. Mirrors `PlacedClip` in tierlist.ts. */\nexport const TierlistPlacedOut = z.object({\n mediaId: z.string(),\n tier: Tier,\n absoluteTier: Tier,\n /** Tier level + within-row position. Integer part is the tier. */\n score: z.number(),\n /** The consensus mean that decided the tier. Never the displayed number. */\n placementScore: z.number(),\n subRankOffset: z.number(),\n votes: z.record(z.string(), Tier), // opaque voterId -> the letter they gave\n});\n/** The whole board as one client should see it: the shared result, plus that\n * caller's own arrangement. */\nexport const TierlistSnapshotOut = z.object({\n items: z.array(TierlistItem),\n placed: z.array(TierlistPlacedOut),\n unrated: z.array(z.string()),\n spread: z.number(),\n nudged: z.boolean(),\n names: z.record(z.string(), z.string()),\n sprites: z.record(z.string(), z.string()),\n /** Voter id -> avatar picture URL, for voters who have one. Optional so a\n * browser on a newer bundle still reads an older server's board. */\n pictures: z.record(z.string(), z.string()).optional(),\n voterCount: z.number().int().nonnegative(),\n myBoard: TierlistBoard,\n});\nexport const TIERLIST_EVENTS = {\n /** Broadcast to EVERY connected socket when anyone saves. Carries no board —\n * it is a \"re-fetch\" ping, because the shared result is identical for all\n * viewers but `myBoard` is not, and one payload cannot serve both. */\n UPDATED: 'tierlist_updated',\n};\n// Ready check — leader-triggered \"are you all ready to keep going?\" prompt.\n// Server tracks per-client ready state while a check is active. Cleared\n// automatically when the leader unpauses (control/play). No payload on\n// start/stop — sender role authorizes the action.\nexport const ReadyCheckStartIn = z.object({});\nexport const ReadyToggleIn = z.object({ ready: z.boolean() });\nexport const ReadyStateOut = z.object({\n active: z.boolean(),\n ready: z.array(z.string()), // clientIds who have flagged ready\n});\nexport const READY_EVENTS = {\n START: 'ready_check_start',\n TOGGLE: 'ready_check_toggle',\n STATE: 'ready_check_state', // distinct from buffer-readiness 'ready_state'\n};\n// ---------------------------------------------------------------------------\n// The room's game: a SELECTION. Every game is a bundle with its own server\n// (GAMES `module` entries), so the room only records which one is open.\n// ---------------------------------------------------------------------------\nexport const GAME_EVENTS = {\n SELECT: 'game_select', // leader -> server (open a game for the room; null closes it)\n ACTIVE: 'game_active', // server -> room (the open game id; null = none)\n SYNC: 'game_sync', // client -> server (which game is open? on mount/reconnect)\n};\n// `gameId: null` turns the active game off for the whole room.\nexport const GameSelectIn = z.object({\n gameId: z.string().min(1).max(32).nullable(),\n});\n// Broadcast when the open game changes, or sent to one socket on game_sync.\nexport const GameActiveOut = z.object({\n gameId: z.string().nullable(),\n});\n","// The playable characters: one table, read by the client, the server and —\n// through a contract test — the Bomberman game's Rust (`game/src/assets.rs`).\n//\n// Grouped by the show they come from. Each set is one page of the character\n// picker, so a set holds at most CHARACTER_PAGE_SIZE characters. `color` is the\n// character's chat/name colour, taken from their hair. Display names are NOT\n// here: they live in the deployment's git-ignored media/sprites/names.json.\n//\n// `rev` is the portrait's revision: bump it when the art in media/sprites/ is\n// redrawn in place. Sprite PNGs are served immutable for a year, so without a\n// new URL a browser that has seen the old image keeps showing it.\nexport const CHARACTER_SETS = [\n {\n id: 'fantasy',\n characters: [\n { id: 'sage', color: '#7f7c84' },\n { id: 'herald', color: '#bddaf9' },\n { id: 'cleric', color: '#78855e' },\n { id: 'knight', color: '#cfccc0' },\n { id: 'mage', color: '#794983' },\n { id: 'brawler', color: '#af4a33' },\n { id: 'wanderer', color: '#936f42' },\n { id: 'rogue', color: '#667240' },\n ],\n },\n {\n id: 'cyber',\n characters: [\n { id: 'gunner', color: '#b6a472' },\n { id: 'chief', color: '#a47e4d' },\n { id: 'walker', color: '#e0443a', rev: 2 },\n { id: 'operative', color: '#347cae' },\n ],\n },\n {\n id: 'stream',\n characters: [\n { id: 'idol', color: '#eb82bf' },\n { id: 'recluse', color: '#6d6880' },\n { id: 'phantom', color: '#a4a3ae' },\n { id: 'harlequin', color: '#e5d5c5' },\n { id: 'doll', color: '#a02633', rev: 2 },\n { id: 'imp', color: '#b398cb' },\n ],\n },\n {\n id: 'arcane',\n characters: [\n { id: 'apprentice', color: '#c6c15b' },\n { id: 'prodigy', color: '#5b4a6a' },\n { id: 'dreamer', color: '#97d0e4' },\n { id: 'sunny', color: '#ee989b' },\n { id: 'mentor', color: '#e1e0dc' },\n ],\n },\n];\nexport const CHARACTER_PAGE_SIZE = 8;\nconst ENTRIES = CHARACTER_SETS.flatMap((s) => s.characters);\n// Canonical character (sprite) order — every set, in set order. Used everywhere\n// a UI roster or server sprite-pool needs a deterministic iteration order, so\n// auto-assignment hands out the first set before any later one.\nexport const CHARACTER_ORDER = ENTRIES.map((c) => c.id);\nexport const CHARACTER_COLORS = Object.fromEntries(ENTRIES.map((c) => [c.id, c.color]));\n/** Portrait revision per character; 1 for art that has never been redrawn. */\nexport const CHARACTER_ART_REV = Object.fromEntries(ENTRIES.map((c) => [c.id, 'rev' in c ? c.rev : 1]));\n/**\n * A character's portrait, as a path under the watchparty server's root. A\n * redrawn portrait carries `?v=<rev>` so the year-long immutable cache cannot\n * keep serving the old art; untouched ones keep their bare URL and stay cached.\n * The UI prefixes its server origin; GET /api/characters hands it to everyone\n * else, so the rule is spelled once.\n */\nexport function portraitPath(id) {\n const rev = CHARACTER_ART_REV[id];\n return `/media/sprites/square_${id}.png${rev > 1 ? `?v=${rev}` : ''}`;\n}\n/** Narrow an arbitrary sprite string (it arrives off the wire) to a CharacterId. */\nexport function isCharacterId(sprite) {\n return !!sprite && CHARACTER_ORDER.includes(sprite);\n}\n","/**\n * Playback tuning constants shared across client, server, simulation, and tests.\n *\n * All buffer/fetch behavior should reference these values so there is exactly\n * one place to tweak when optimising for different network conditions.\n */\n// ─── Client fetch behavior ──────────────────────────────────────────────────\n/** Max parallel fragment fetches per viewer */\nexport const MAX_CONCURRENT_JOBS = 4;\n/** How far ahead of playhead (ms) to prefetch fragments */\nexport const BUFFER_AHEAD_MS = 30_000;\n/** Buffer required (ms) before playback starts or resumes after a stall */\nexport const INITIAL_BUFFER_MS = 5_000;\n// ─── Health status thresholds ────────────────────────────────────────────────\n/** Buffer below this (ms) → \"Critical\" status */\nexport const CRITICAL_BUFFER_MS = 2_000;\n/** Buffer below this (ms) → \"Low Buffer\" / warning status */\nexport const LOW_BUFFER_MS = 5_000;\n// ─── Stall + sync thresholds ────────────────────────────────────────────────\n/** Leader stall longer than this (ms) → leader auto-emits a pause control so viewers\n * don't drift out ahead of the still-buffering leader. See useStallRecovery. */\nexport const LEADER_STALL_PAUSE_THRESHOLD_MS = 2_000;\n/** Same gap-pattern must persist this long (ms) before logging an unfillable-gap warn,\n * to filter out transient append-queue churn. See fragmentManager._maybeWarnStableGaps. */\nexport const STABLE_GAP_DWELL_MS = 5_000;\n// ─── Prefetch + buffer-gate tuning ──────────────────────────────────────────\n/** Background prefetch tick interval (ms) for usePrefetch + audio mirror. */\nexport const DEFAULT_PREFETCH_INTERVAL_MS = 2_000;\n/** Maximum wall-clock time (ms) waiting for INITIAL_BUFFER_MS of buffer before\n * releasing initializePlayback. After this, playback starts with whatever's buffered. */\nexport const DEFAULT_BUFFER_GATE_TIMEOUT_MS = 10_000;\n/** Number of front-of-stream fragments included in the INIT-DIGEST telemetry event. */\nexport const INIT_DIGEST_TRACK_COUNT = 10;\n// ─── Fragment fetch retry/error behavior ────────────────────────────────────\n/** When fetchInit() fails, retry after this delay (ms). */\nexport const INIT_RETRY_DELAY_MS = 2_000;\n/** Consecutive fetchInit() failures before giving up on the current MediaSource\n * and signalling a fast recovery (reject append/buffer waiters → MediaPlayer\n * recreates the MediaSource) instead of looping forever every\n * INIT_RETRY_DELAY_MS. Without this cap a persistent init failure froze\n * playback for ~65s: the retry loop logged silently while initializePlayback\n * blocked on two 30s waitForAppend timeouts before recreating (cedar-ridge\n * 2026-06-25, DemonLord s01e04). At 3 × 2s this recovers in ~6s. */\nexport const MAX_INIT_FETCH_RETRIES = 3;\n/** After endOfStream() is called but a SourceBuffer is still updating, retry on\n * this interval (ms). See FragmentManager.tryEndOfStream. */\nexport const END_OF_STREAM_RETRY_INTERVAL_MS = 50;\n/** A fragment that has exhausted MAX_FETCH_ERROR_CYCLES rounds of retries is\n * declared permanently unservable. Reset attempts beyond this are skipped. */\nexport const MAX_FETCH_ERROR_CYCLES = 3;\n/** Fragment error states older than this (ms) are reset to pending so retries\n * can resume (provided cycle budget remains). See prefetchAround. */\nexport const ERROR_RESET_AGE_MS = 30_000;\n/** How long processAppendQueue may hold the SAME queue head behind a\n * lower-indexed pending fetch before declaring that fetch dead and breaking\n * the hold (ms).\n *\n * The sequential-append guard has no timeout of its own: it holds the whole\n * append queue while ANY pending fetch has a lower index than the head. That\n * is correct for a fetch that will land, and a permanent wedge for one that\n * will not. A fragment whose append is reported discarded goes back to\n * 'pending' and is re-fetched, re-taking the lower index — so a fragment that\n * keeps getting discarded holds every later append forever.\n *\n * That is the audio-side freeze from cedar-ridge 2026-08-25: audio appends\n * #556–562 were each reported discarded, the audio queue backed up to 15\n * items, and `waitForAppend` timed out on #569 after 30s while the playhead\n * sat pinned at the audio buffer's leading edge.\n *\n * 20s is deliberately longer than a slow fragment fetch under load (the\n * adaptive fetch timeout runs 30–120s, but a fetch that is actually\n * progressing re-enters the queue well inside this window) and well short of\n * the 30s `waitForAppend` timeout it exists to pre-empt. */\nexport const APPEND_HOLD_MAX_MS = 20_000;\n/** Buffer-ahead-ms header value sent on background pre-buffer fetches so the\n * server's priority queue treats them as lowest-priority and yields to live viewers. */\nexport const BACKGROUND_FETCH_PRIORITY_MS = 99_999;\n// ─── Time sync ──────────────────────────────────────────────────────────────\n/** Interval (ms) the client emits `time_ping` to derive its drift offset. */\nexport const DEFAULT_CLIENT_TIME_PING_INTERVAL_MS = 5_000;\n// ─── Output filenames ──────────────────────────────────────────────────────\n/** Filename used by ingest for the fragmented MP4 muxed output. Consumed by the\n * client to convert a media URL into the corresponding audio-base URL. */\nexport const FRAGMENT_MP4_FILENAME = 'output_frag.mp4';\n/** Subdirectory of the media output where per-language audio fragments live. */\nexport const AUDIO_DIR = 'audio';\n","/**\n * Fragment-delivery rollout flag — shared by server and client so both agree on what a given\n * setting means.\n *\n * Three modes:\n * off — everyone uses the Range path (the shipping default)\n * on — everyone uses the edge-cacheable fragment route, falling back to Range on failure\n * ab — a deterministic `percent` slice uses the fragment route; the rest stay on Range\n *\n * `ab` exists so the two delivery paths can be compared inside ONE party, on the same media, at\n * the same time. That matters because the numbers this feature is meant to move (TTFB, cache\n * hit rate, stalls) swing wildly with time of day, which media is loaded, and who is watching —\n * comparing two separate runs would mostly measure those.\n *\n * Assignment is a pure hash of the client id, so a viewer keeps its arm across reconnects\n * within a session and cannot flap between paths mid-episode.\n */\nexport const DEFAULT_FRAGMENT_DELIVERY_FLAG = { mode: 'off', percent: 0 };\nexport const FRAGMENT_DELIVERY_MODES = ['off', 'on', 'ab'];\nexport function isFragmentDeliveryMode(v) {\n return typeof v === 'string' && FRAGMENT_DELIVERY_MODES.includes(v);\n}\n/** Clamp/normalise anything (env string, JSON body, stored value) into a valid flag. */\nexport function normalizeFragmentDeliveryFlag(raw) {\n const o = (raw && typeof raw === 'object' ? raw : {});\n const mode = isFragmentDeliveryMode(o.mode) ? o.mode : DEFAULT_FRAGMENT_DELIVERY_FLAG.mode;\n const n = Number(o.percent);\n const percent = Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : 0;\n return { mode, percent };\n}\n/**\n * FNV-1a over the client id. Small, dependency-free, and — the actual requirement — identical\n * on both sides, so the arm the server reports in telemetry is the arm the client used.\n */\nfunction hashClientId(clientId) {\n let h = 0x811c9dc5;\n for (let i = 0; i < clientId.length; i++) {\n h ^= clientId.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h >>> 0;\n}\n/**\n * Which delivery path should this client use?\n *\n * `override` is the local escape hatch (`?fragmentDelivery=0|1`) and wins over the server flag\n * in BOTH directions — so a viewer hitting a bad fragment-route session can force themselves\n * back onto Range without waiting for an operator, and an operator can test the route on a\n * single client while the room-wide flag is off.\n */\nexport function resolveDeliveryArm(flag, clientId, override) {\n if (override === true)\n return 'fragment';\n if (override === false)\n return 'range';\n const f = normalizeFragmentDeliveryFlag(flag);\n if (f.mode === 'on')\n return 'fragment';\n if (f.mode === 'off')\n return 'range';\n // 'ab'. percent 0 => nobody, percent 100 => everybody; both must hold exactly, so compare\n // against `percent` with a strict `<` on a 0-99 bucket.\n if (f.percent <= 0)\n return 'range';\n if (f.percent >= 100)\n return 'fragment';\n if (!clientId)\n return 'range'; // no stable identity => never in the experiment arm\n return hashClientId(clientId) % 100 < f.percent ? 'fragment' : 'range';\n}\n","/**\n * User submissions — \"a user asked for this media\".\n *\n * The wire contract and the state machine, shared by client and server so the\n * two cannot hold different ideas about which moves are legal. Nothing here\n * fetches, uploads or ingests: wave 1 stores a request and its adjudication,\n * and the later waves attach the machinery that walks it forward.\n *\n * ── `ownerUserId` is a userId, NEVER a credential ──────────────────────────\n * Every row keys on the stable userId whoami returns, not on the raw\n * `X-User-Key`. Issue #144 is exactly the mistake of keying five stats tables\n * on the credential: a key rotation then orphans rows that still hold a\n * secret, and two aliases of one rotated user never collapse. Hashing the\n * credential does not fix it either — a key-derived id is still key-derived.\n * This store is being written after that lesson; do not reproduce it.\n *\n * ── The transition table is DATA, on purpose ───────────────────────────────\n * `SUBMISSION_TRANSITIONS` is the single rule every later wave consults. A\n * fetcher, an uploader, a moderation UI and an operator script will all move\n * submissions between states; if each one hand-writes \"is this move allowed\"\n * they will disagree, and the disagreement shows up as a submission wedged in\n * a state nothing advances. `canTransition` is the only predicate.\n */\nimport { z } from 'zod';\n/** How the bytes are expected to arrive. */\nexport const SubmissionKind = z.enum(['url', 'magnet', 'upload']);\n/**\n * Lifecycle. The happy path is\n * `pending → approved → fetching → ingesting → ready`; `rejected` and\n * `failed` are the two ways out of it.\n *\n * Moderation is approve-first (operator, 2026-09-24): nothing reaches the\n * catalog without passing through `approved`, so `pending` is not a formality\n * that a later wave may skip.\n */\nexport const SubmissionState = z.enum([\n 'pending', 'approved', 'fetching', 'ingesting', 'ready', 'rejected', 'failed',\n]);\n/**\n * Every legal move, keyed by the state you are leaving.\n *\n * Deliberate calls, so a later wave does not read an omission as an oversight:\n *\n * - **`ready` and `rejected` are terminal.** A request that has landed in the\n * catalog, or that an operator has refused, is finished. Wanting it again is\n * a new submission — which keeps the audit trail honest about who asked for\n * what, and when.\n * - **Nothing returns to `pending`.** `pending` means \"never adjudicated\". A\n * submission cannot become un-adjudicated, so a retry re-enters at\n * `approved` (the decision still stands), never at the top.\n * - **`fetching` and `ingesting` cannot be rejected.** Killing work in flight\n * needs cancellation semantics — a way to stop the yt-dlp child and clean up\n * a part-written file — and none exists yet. Adding `fetching → rejected`\n * without that leaves an orphaned process writing into a directory nobody\n * owns. Add the edge together with the killer that makes it true.\n * - **`failed` is recoverable, once approved.** A fetch that died on a network\n * blip should not cost the user their place in the queue.\n */\nexport const SUBMISSION_TRANSITIONS = {\n pending: ['approved', 'rejected'],\n approved: ['fetching', 'rejected', 'failed'],\n fetching: ['ingesting', 'failed'],\n ingesting: ['ready', 'failed'],\n ready: [],\n rejected: [],\n failed: ['approved'],\n};\n/** States nothing can leave. Derived, so it cannot drift from the table. */\nexport const SUBMISSION_TERMINAL_STATES = Object.keys(SUBMISSION_TRANSITIONS)\n .filter(s => SUBMISSION_TRANSITIONS[s].length === 0);\n/**\n * The one predicate. Total over `unknown` rather than over the enum, because\n * both ends read states back out of SQLite and off the wire, where a value\n * that is not in the enum at all is a reachable outcome — and must be `false`\n * rather than a throw or a crash inside a transaction.\n *\n * A self-transition is NOT legal. `from === to` is a write that changes\n * nothing; treating it as a move would let a caller \"advance\" a submission\n * and log a state change that never happened.\n */\nexport function canTransition(from, to) {\n const legal = SUBMISSION_TRANSITIONS[from];\n if (!legal)\n return false;\n return legal.includes(to);\n}\n/** One submission, as it is stored and as it goes on the wire. */\nexport const Submission = z.object({\n id: z.string().min(1),\n /** Stable userId from whoami. Never the raw `X-User-Key`. */\n ownerUserId: z.string().min(1),\n kind: SubmissionKind,\n /** The magnet URI, the https URL, or the filetransfer binding id. */\n source: z.string().min(1),\n title: z.string().optional(),\n /**\n * The anime series folder (PascalCase, e.g. `SparksOfTomorrow`) a `magnet`\n * submission belongs to — wave 4 (2026-10-01: \"magnets are in scope, and\n * user content is NOT restricted to the playlist tree\"). `undefined` for\n * `url`/`upload`, which always land in the playlist tree regardless of\n * title. The scraper derives its own destination from this same string, so\n * it is stored verbatim rather than normalized — a normalization that\n * disagreed with the scraper's own folder-name assumption would silently\n * misroute the copy.\n */\n series: z.string().optional(),\n state: SubmissionState,\n createdAt: z.number().int().nonnegative(),\n updatedAt: z.number().int().nonnegative(),\n /** Why it failed, or why an operator rejected it. */\n error: z.string().optional(),\n /** Size once known. 0 means \"known to be empty\"; absent means \"not yet known\" —\n * the same distinction `chatImages` draws for image dimensions. */\n bytes: z.number().int().nonnegative().optional(),\n});\n/** Longest `source` accepted at the boundary. A magnet with a long tracker\n * list is legitimately over 1KB; nothing real approaches this. */\nexport const SUBMISSION_SOURCE_MAX_CHARS = 4096;\n/** Longest caller-supplied title. */\nexport const SUBMISSION_TITLE_MAX_CHARS = 300;\n/** Longest caller-supplied series name. Comfortably over the longest real\n * series folder name in the library; bounded for the same reason title is —\n * it ends up as a filesystem path segment. */\nexport const SUBMISSION_SERIES_MAX_CHARS = 100;\n/** `POST /api/submissions` request body. */\nexport const SubmissionCreateIn = z.object({\n kind: SubmissionKind,\n source: z.string().min(1).max(SUBMISSION_SOURCE_MAX_CHARS),\n title: z.string().min(1).max(SUBMISSION_TITLE_MAX_CHARS).optional(),\n /** Required for `kind: 'magnet'` by `checkSource`; ignored for `url`/`upload`. */\n series: z.string().min(1).max(SUBMISSION_SERIES_MAX_CHARS).optional(),\n});\n/** `POST /api/submissions/:id/reject` request body — the reason is optional,\n * and lands in the submission's `error` so the requester can be told why. */\nexport const SubmissionRejectIn = z.object({\n reason: z.string().min(1).max(SUBMISSION_TITLE_MAX_CHARS).optional(),\n});\n/** List response shape, for `GET /api/submissions` and `.../mine`. */\nexport const SubmissionListOut = z.object({\n submissions: z.array(Submission),\n});\n// ── Caps (operator, 2026-09-24) ────────────────────────────────────────────\n// They live in shared rather than in the server's config so the submit UI can\n// show the same number the server enforces, instead of a second copy that\n// drifts.\n//\n// `DEFAULT_SUBMISSION_PENDING_BYTES_CAP` IS enforced — at submit, by\n// `POST /api/submissions`, which answers 429 with {@link SubmissionQuotaOut}.\n// The other two are still defaults only: the upload cap lands with the upload\n// path that can weigh a file, and retention with the prune that reads it.\n/** Total bytes one user may have submitted-but-not-yet-ready. 20 GB. */\nexport const DEFAULT_SUBMISSION_PENDING_BYTES_CAP = 20 * 1024 * 1024 * 1024;\n/** Largest single upload. 4 GB. */\nexport const DEFAULT_SUBMISSION_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;\n/** Days a fetched source is kept after a successful ingest, unless pinned. */\nexport const DEFAULT_SUBMISSION_SOURCE_RETENTION_DAYS = 14;\n/**\n * `429` body when a submit would exceed the per-user pending cap.\n *\n * **The numbers are in the body on purpose.** A bare 429 tells a friend \"no\"\n * and nothing else, so the only recovery is to guess which of their own\n * requests to abandon. With both figures the UI can say \"you are holding\n * 21.4 GB of a 20 GB allowance\" against the same values the server enforced,\n * rather than a second copy that drifts.\n *\n * ── What an unknown size contributes: nothing ──────────────────────────────\n * A `url` or `magnet` row has `bytes` absent until its fetch lands — the size\n * is genuinely unknown, and the schema says so (absent ≠ 0). The cap therefore\n * binds on MEASURED bytes. The alternative is to charge an unknown row some\n * assumed size, which is a number nobody measured: too small and the cap does\n * not bind, too large and a friend's second request is refused on the strength\n * of a guess about their first. The honest consequence is that many\n * not-yet-fetched requests pass the check and the cap starts biting as soon as\n * real sizes land — a *count* cap is the tool for the other half, and it\n * belongs with the fetch path that can measure.\n */\nexport const SubmissionQuotaOut = z.object({\n error: z.literal('submissions/quota_exceeded'),\n /** The caller's measured bytes in non-terminal submissions, right now. */\n pendingBytes: z.number().int().nonnegative(),\n /** The cap that was applied, from the server's config. */\n capBytes: z.number().int().nonnegative(),\n});\n","import { z } from 'zod';\n/**\n * The room's game list, as DATA.\n *\n * The leader's Play tab used to be a hardcoded array in `GamePicker.tsx`, so\n * every new game was a watchparty code change. Now the server reads the list\n * from the `GAMES` env var (JSON), serves it at `GET /api/games`, and the UI\n * renders and mounts whatever it says. The shape is `GameEntry` from\n * `@wolffm/rooms-protocol` — this file is only its runtime validation, and\n * `apps/ui/src/lib/gameCatalog.ts` checks the two agree at compile time.\n *\n * Validated at BOTH ends: the server so a bad env value is reported where the\n * operator set it, the browser because a module URL is about to be imported.\n */\n/** Same charset as a room-key segment; `game_select` carries it on the wire. */\nconst GAME_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;\n/** Any origin works: the check is that resolving against it cannot leave it. */\nconst PROBE_ORIGIN = 'https://game-catalog.invalid';\n/**\n * A root-relative path on the page's own origin, and nothing else.\n *\n * Absolute URLs are refused outright, not just cross-origin ones: the catalog\n * does not know the page's origin, so `https://hadoku.me/x` cannot be told\n * apart from `https://evil.example/x` here. `//host/x` (protocol-relative) and\n * `/\\host/x` (browsers read the backslash as a slash) both leave the origin\n * despite the leading `/`, which is why this resolves the path rather than\n * just checking its first character.\n */\nexport function isSameOriginPath(value) {\n if (!value.startsWith('/') || value.startsWith('//'))\n return false;\n if (/[\\\\\\s]/.test(value) || [...value].some((c) => c.charCodeAt(0) < 0x20 || c.charCodeAt(0) === 0x7f))\n return false;\n try {\n return new URL(value, PROBE_ORIGIN).origin === PROBE_ORIGIN;\n }\n catch {\n return false;\n }\n}\nconst SameOriginPath = z.string().min(2).max(512).refine(isSameOriginPath, {\n message: 'must be a root-relative path on the page origin (e.g. /cardtable/embed/x.js)',\n});\nconst GameEntryBase = {\n id: z.string().regex(GAME_ID_RE),\n name: z.string().min(1).max(64),\n cover: SameOriginPath.optional(),\n};\nexport const GameEntrySchema = z.object({ ...GameEntryBase, kind: z.literal('module'), url: SameOriginPath }).strict();\nexport const GameCatalogSchema = z.array(GameEntrySchema).max(32).refine((games) => new Set(games.map((g) => g.id)).size === games.length, { message: 'game ids must be unique' });\n/** What a deployment gets without `GAMES`: Bomberman, served by hadoku-bomberman\n * under its own edge mount. */\nexport const DEFAULT_GAME_CATALOG = [\n { id: 'bomberman', name: 'Bomberman', kind: 'module', url: '/bomberman/embed/bomberman-embed.js' },\n];\n/**\n * Parse the `GAMES` env value. Unset keeps the default list; set REPLACES it,\n * and `[]` turns games off. A malformed ENTRY is dropped, not coerced, and a\n * duplicate id keeps its first entry — the same fail-closed rule as\n * `EPHEMERAL_ROOM_NAMESPACES`. A value that is not a JSON array at all falls\n * back to the default rather than taking every game away over a typo.\n */\nexport function parseGameCatalog(raw) {\n if (raw === undefined)\n return { games: [...DEFAULT_GAME_CATALOG], problems: [] };\n let value;\n try {\n value = JSON.parse(raw);\n }\n catch {\n return { games: [...DEFAULT_GAME_CATALOG], problems: ['GAMES is not valid JSON; using the default list'] };\n }\n if (!Array.isArray(value)) {\n return { games: [...DEFAULT_GAME_CATALOG], problems: ['GAMES is not a JSON array; using the default list'] };\n }\n const games = [];\n const problems = [];\n for (const [i, item] of value.entries()) {\n const parsed = GameEntrySchema.safeParse(item);\n if (!parsed.success) {\n problems.push(`GAMES[${i}] dropped: ${parsed.error.issues.map((x) => `${x.path.join('.') || '(entry)'} ${x.message}`).join('; ')}`);\n continue;\n }\n if (games.some((g) => g.id === parsed.data.id)) {\n problems.push(`GAMES[${i}] dropped: duplicate id ${parsed.data.id}`);\n continue;\n }\n if (games.length >= 32) {\n problems.push(`GAMES[${i}] dropped: more than 32 games`);\n continue;\n }\n games.push(parsed.data);\n }\n return { games, problems };\n}\n","/**\n * Search in the Add media drawer — shows (AniList, TMDB) and YouTube videos.\n *\n * The wire contract only. The server owns every rule that decides what a\n * result MEANS — which names a release might carry, which folder a show lands\n * in, whether it is already on the scraper's watch list — so the client\n * renders what it is handed and sends back an id, never a name or a pattern.\n */\nimport { z } from 'zod';\nexport const ShowSource = z.enum(['anilist', 'tmdb']);\nexport const ShowResult = z.object({\n source: ShowSource,\n /** The provider's own id, as a string (AniList and TMDB both use integers). */\n id: z.string(),\n title: z.string(),\n year: z.number().int().nullable(),\n /** AniList `format` (TV, ONA, MOVIE…) or `TV` for TMDB. */\n format: z.string().nullable(),\n episodes: z.number().int().nullable(),\n /** Same-origin proxied poster (`/api/search/thumb?u=…`), or null. */\n poster: z.string().nullable(),\n /** The folder this show would land in — `SousouNoFrieren`. */\n series: z.string(),\n /** On the scraper's watch list already (by series). */\n onWatchlist: z.boolean(),\n /**\n * Not addable: a movie, or a title with no usable Latin-script name. The\n * scraper matches releases by `SxxExx`, so a film can never arrive through it.\n */\n addable: z.boolean(),\n});\nexport const ShowSearchOut = z.object({\n results: z.array(ShowResult),\n /** Providers that answered. TMDB is absent when no token is configured. */\n sources: z.array(ShowSource),\n});\nexport const VideoResult = z.object({\n id: z.string(),\n url: z.string(),\n title: z.string(),\n channel: z.string().nullable(),\n durationS: z.number().nullable(),\n thumb: z.string().nullable(),\n});\nexport const VideoSearchOut = z.object({ results: z.array(VideoResult) });\nexport const WatchlistAddIn = z.object({\n source: ShowSource,\n id: z.string().regex(/^\\d{1,12}$/),\n});\nexport const WatchlistAddOut = z.object({ series: z.string(), title: z.string() });\n/** Shortest query worth sending to a provider. */\nexport const SEARCH_MIN_QUERY = 2;\nexport const SEARCH_MAX_QUERY = 100;\n","/**\n * The app's handle on `@wolffm/cdn-client`.\n *\n * Transport — upload, URL composition, catalogue search — now lives in that\n * shared package (`hadoku_site/packages/cdn-client`), which every ecosystem app\n * integrates with. What stays here is the watchparty-specific SHIM: the\n * composer's attachment model, the wire schema, and the picker's markup. That\n * split is deliberate — `/tube/` comments want the same transport and nothing\n * like the same layout.\n *\n * The client is memoised per `cdnBase`, and `setRuntimeConfig` can legitimately\n * change `cdnBase` at mount time — so a once-only singleton would pin the wrong\n * origin, and a fresh object per call would throw away a shared instance that\n * non-React callers use too.\n *\n * It is NOT memoised to protect `useCatalogueSearch` any more. It used to be:\n * that hook keyed its catalogue on the client's IDENTITY, so a fresh object per\n * render re-ran the effect, which set state, which re-rendered — a permanent\n * loop, not the \"re-fetch per keystroke\" this comment used to claim. Measured\n * upstream at ~2 catalogue requests a second for as long as the picker stayed\n * mounted, with the component otherwise idle.\n *\n * cdn-client 1.0.3 fixes it at the source — the hooks key on the client's\n * CONFIGURATION and read it through a ref — so an unstable client is now merely\n * wasteful allocation rather than a request storm. The `^1.0.3` floor below is\n * what makes that a guarantee instead of a coincidence.\n */\nimport { createCdnClient, type CdnClient } from '@wolffm/cdn-client';\nimport { DEFAULT_CDN_BASE, isChatImageKey } from '@watchparty/shared';\n\nlet cached: { base: string; client: CdnClient } | null = null;\nlet cdnBase: string = DEFAULT_CDN_BASE;\n\nexport interface ChatMediaConfig {\n /** Public read base for CDN objects, e.g. `https://cdn.hadoku.me`. */\n cdnBase: string;\n}\n\n/** Point chat media at a CDN. Called from `setRuntimeConfig` at app boot. */\nexport function setChatMediaConfig(config: Partial<ChatMediaConfig>): void {\n if (config.cdnBase) cdnBase = config.cdnBase;\n}\n\n/** The configured base, trailing slash stripped. */\nexport function getChatMediaCdnBase(): string {\n return cdnBase.replace(/\\/+$/, '');\n}\n\n/** The shared client, stable for as long as `cdnBase` is. */\nexport function getCdnClient(): CdnClient {\n const base = getChatMediaCdnBase();\n if (!cached || cached.base !== base) {\n cached = { base, client: createCdnClient({ cdnBase: base }) };\n }\n return cached.client;\n}\n\n/**\n * Compose the render URL for a stored key, or `null` if the key is not one.\n *\n * **The null case is load-bearing and is NOT what `client.urlFor` does.**\n * `urlFor` concatenates unconditionally — it is a convenience that makes\n * storing keys the easy path, not a validator. A key reaches this app over the\n * wire from another client, so it is data: interpolating an unchecked one\n * would put attacker-controlled text into an `<img src>`.\n *\n * The check uses watchparty's `isChatImageKey`, not `client.isKey`, and the\n * difference matters. The package accepts any `[a-z0-9]{1,8}` extension;\n * `CHAT_IMAGE_KEY_RE` is an ALLOWLIST (png/jpg/jpeg/gif/webp/avif), so it also\n * refuses `.svg` — a scriptable document served same-site with the app. It is\n * the same predicate the Zod wire schema enforces, so render and protocol\n * cannot disagree.\n */\nexport function resolveChatImageUrl(key: string): string | null {\n if (!isChatImageKey(key)) return null;\n const base = getChatMediaCdnBase();\n if (!base) return null;\n return getCdnClient().urlFor(key);\n}\n","/**\n * The composer's attachment model, on top of `@wolffm/cdn-client`.\n *\n * The package owns TRANSPORT: `upload()`, `measureImage()`,\n * `filesFromClipboard()`/`filesFromDrop()`, and the size/type constants\n * mirrored from the worker. What stays here is what is watchparty's:\n *\n * - the pending-attachment model, which is a UNION because a meme pick is\n * already on the CDN and a pasted file is not;\n * - the user-facing wording for each failure;\n * - the guard on the key the CDN hands back.\n *\n * Everything is a pure function or one `await`, deliberately: the apps/ui\n * vitest project runs in the `node` environment and collects only `.ts`, so a\n * rule that lives in a component cannot be tested at all.\n */\nimport {\n CdnError,\n MAX_BYTES,\n filesFromClipboard,\n filesFromDrop,\n kindOfType,\n measureImage as measureImageBytes,\n type CdnErrorReason,\n} from '@wolffm/cdn-client';\nimport { isChatImageKey } from '@watchparty/shared';\nimport { getCdnClient } from './config';\n\n/**\n * An attachment sitting on the composer, not yet sent.\n *\n * Two kinds, and the distinction is load-bearing on three separate paths:\n *\n * - `file` — pasted or dropped. Needs uploading on submit, and `previewUrl`\n * is an object URL that MUST be revoked when it goes away.\n * - `remote` — picked from the meme library. Already content-addressed on the\n * CDN, so submitting it costs no network at all, and `previewUrl` is an\n * ordinary CDN URL that must NOT be revoked (harmless, but it would mean\n * the revoke path had stopped tracking what it owns).\n *\n * A discriminated union rather than optional fields, so neither path can\n * silently read the other's shape — `file` being `undefined` at the one point\n * that uploads is exactly the bug this prevents.\n */\ninterface PendingBase {\n /** Stable id for React keys and for removing one specific attachment. */\n id: string;\n /** What the composer renders right now — object URL or CDN URL by kind. */\n previewUrl: string;\n /** Intrinsic pixels, or 0 when unknown (renderers fall back to natural flow). */\n w: number;\n h: number;\n}\n\nexport interface PendingFileImage extends PendingBase {\n kind: 'file';\n file: File;\n}\n\nexport interface PendingRemoteImage extends PendingBase {\n kind: 'remote';\n /** Already on the CDN — this goes straight onto the message. */\n key: string;\n /** Library title, carried through to the rendered `alt`. */\n title?: string;\n}\n\nexport type PendingImage = PendingFileImage | PendingRemoteImage;\n\n/** True when this attachment owns an object URL that has to be revoked. */\nexport function ownsObjectUrl(p: PendingImage): boolean {\n return p.kind === 'file';\n}\n\n/**\n * A finished attachment, ready to go on the message.\n *\n * Structurally the wire `ChatImage`, kept as its own type because the package\n * emits keys and dimensions, not watchparty's protocol.\n */\nexport interface ChatMediaAttachment {\n key: string;\n w: number;\n h: number;\n title?: string;\n}\n\n/** Raised when the CDN accepted an upload but returned something unusable. */\nexport class ChatImageUploadError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChatImageUploadError';\n }\n}\n\n/**\n * User-facing wording for a `CdnError`.\n *\n * Branching on `reason` rather than a status is what the package's error type\n * is for. Two of these must stay distinct: `unauthenticated` is fixable by the\n * user and `forbidden` is not, so collapsing them shows a sign-in prompt that\n * changes nothing.\n */\nexport function uploadErrorMessage(reason: CdnErrorReason): string {\n switch (reason) {\n case 'unauthenticated': return 'Sign in to paste images';\n case 'forbidden': return \"Your account can't upload images\";\n case 'too-large': return 'Image too large (max 10 MB)';\n case 'unsupported-type': return \"That image type isn't supported\";\n case 'invalid': return \"That image couldn't be read\";\n case 'network': return 'Check your connection and try again';\n case 'unavailable': return 'Image upload is having trouble — try again';\n default: return 'Image upload failed';\n }\n}\n\n/** Turn any thrown value into something worth showing a user. */\nexport function messageForUploadFailure(err: unknown): string {\n if (err instanceof CdnError) return uploadErrorMessage(err.reason);\n if (err instanceof ChatImageUploadError) return err.message;\n return 'Image upload failed';\n}\n\n/**\n * Reject a file at PASTE time, before it ever reaches the composer.\n *\n * `client.upload()` pre-flights the same limits, but it does so at SEND — and\n * telling someone their 12 MB screenshot is too large only after they have\n * typed a message and hit Enter is the experience this avoids. The constants\n * come from the package, which parity-tests them against the worker's own\n * values, so this cannot drift into promising an upload the server refuses.\n *\n * A file with NO type passes: some clipboard and drag sources supply nothing,\n * and refusing those would drop valid images the server would have accepted.\n * The server sniffs the bytes and is the actual gate either way.\n *\n * IMAGE-only, deliberately: cdn-client 2.x also accepts audio, but chat renders\n * every upload in an <img>. 2.0 removed ACCEPTED_TYPES/MAX_UPLOAD_BYTES rather\n * than widen them for exactly this reason; `kindOfType` also resolves the\n * browser's alternate spellings of a type.\n */\nexport function preflightMessage(file: { size: number; type: string; name?: string }): string | null {\n const type = file.type.toLowerCase().split(';')[0].trim();\n if (type && kindOfType(type) !== 'image') {\n return uploadErrorMessage('unsupported-type');\n }\n if (file.size > MAX_BYTES.image) return uploadErrorMessage('too-large');\n if (file.size === 0) return uploadErrorMessage('invalid');\n return null;\n}\n\n/**\n * Pull image files out of a paste or a drop.\n *\n * Both delegate to the package, which reads `clipboardData.files` first and\n * falls back to walking `items` — not equivalent, since some browsers populate\n * only `items` for an image copied out of another page.\n */\nexport function imageFilesFromClipboard(dt: DataTransfer | null | undefined): File[] {\n return filesFromClipboard(dt);\n}\n\nexport function imageFilesFromDrop(dt: DataTransfer | null | undefined): File[] {\n return filesFromDrop(dt);\n}\n\n/**\n * Measure a file's intrinsic dimensions.\n *\n * The package returns `null` when the environment has no decoder; the wire\n * schema spells \"unknown\" as **0**, and the renderer falls back to natural\n * flow on it. Translating here keeps that single spelling everywhere.\n */\nexport async function measureImage(file: Blob): Promise<{ w: number; h: number }> {\n const dims = await measureImageBytes(file);\n return { w: dims?.width ?? 0, h: dims?.height ?? 0 };\n}\n\n/**\n * Upload one file and return the key to put on the message.\n *\n * The key is re-validated even though the CDN produced it. A malformed one\n * would be persisted into chat history permanently, and `urlFor` will happily\n * compose a URL for anything — so this is the last point at which a bad key\n * can be refused rather than stored.\n */\nexport async function uploadChatImage(\n file: File,\n opts: { signal?: AbortSignal } = {},\n): Promise<{ key: string; deduplicated: boolean }> {\n const result = await getCdnClient().upload(file, { signal: opts.signal });\n if (!isChatImageKey(result.key)) {\n throw new ChatImageUploadError('Image upload returned an unusable key');\n }\n return { key: result.key, deduplicated: result.deduplicated === true };\n}\n\n/**\n * Turn every pending attachment into a wire attachment, preserving order.\n * Throws on the first failure.\n *\n * A `remote` attachment (a meme pick) is passed straight through: its bytes\n * are already on the CDN under that key, so re-uploading them would spend\n * bandwidth to arrive at the identical content-addressed key. That is what\n * makes picking a meme feel instant while pasting a screenshot does not.\n */\nexport async function uploadAll(\n pending: readonly PendingImage[],\n opts: { signal?: AbortSignal } = {},\n): Promise<ChatMediaAttachment[]> {\n const out: ChatMediaAttachment[] = [];\n for (const p of pending) {\n if (p.kind === 'remote') {\n out.push({ key: p.key, w: p.w, h: p.h, ...(p.title ? { title: p.title } : {}) });\n continue;\n }\n const { key } = await uploadChatImage(p.file, opts);\n out.push({ key, w: p.w, h: p.h });\n }\n return out;\n}\n\n/** Log helper so callers keep one shape for upload failures. */\nexport function uploadErrorFields(err: unknown): Record<string, unknown> {\n return {\n error: err instanceof Error ? err.message : String(err),\n name: err instanceof Error ? err.name : typeof err,\n reason: err instanceof CdnError ? err.reason : undefined,\n status: err instanceof CdnError ? err.status : undefined,\n };\n}\n","/**\n * Where the meme picker's hover preview goes.\n *\n * Ported from dataplatform's `/tube/` comment picker\n * (`src/components/MediaPicker.tsx: placePreview`), which solved this first.\n * Keeping the geometry identical is deliberate — the two pickers read the same\n * catalogue and should not feel like different products — so if you change the\n * rules here, change them there too.\n *\n * The preview sits BESIDE the panel rather than over it, so the grid stays\n * readable and you can keep scanning while one item is expanded. Left is\n * preferred because the panel is right-anchored above the composer, so that is\n * where the room usually is; it flips right when it will not fit, and is\n * clamped into the viewport either way so a tile near an edge still gets a\n * whole preview rather than a sliced one.\n */\n\n/** Side of the enlarged preview box, in px. */\nexport const PREVIEW_PX = 240;\n\n/** Breathing room from the panel and the viewport edges. */\nexport const PREVIEW_GAP = 8;\n\n/**\n * Vertical slack for the caption row beneath the image.\n *\n * The box HUGS its image (`height: auto`), so its real height varies with the\n * meme's aspect. Placement clamps against the TALLEST box it could be, which\n * means a shorter one is simply clamped more conservatively — never pushed\n * off-screen. Getting this backwards (clamping against the actual height)\n * would let a tall image overflow the bottom edge.\n */\nexport const PREVIEW_CHROME_PX = 28;\n\nexport interface Rect {\n left: number;\n top: number;\n right: number;\n width: number;\n height: number;\n}\n\nexport interface Viewport {\n width: number;\n height: number;\n}\n\n/** Max height the image itself may paint at (mirrors the CSS `max-height`). */\nexport const PREVIEW_MAX_IMG_PX = 240;\n\nexport function placePreview(\n tile: Rect,\n panel: Rect,\n viewport: Viewport\n): { left: number; top: number } {\n let left = panel.left - PREVIEW_PX - PREVIEW_GAP;\n // No room on the left — put it on the panel's other side.\n if (left < PREVIEW_GAP) left = panel.right + PREVIEW_GAP;\n // Clamp AFTER the flip: on a narrow viewport neither side fits cleanly, and\n // a preview half off-screen is worse than one overlapping the panel.\n left = Math.min(left, viewport.width - PREVIEW_PX - PREVIEW_GAP);\n left = Math.max(PREVIEW_GAP, left);\n\n // Vertically centred on the tile, so the eye does not have to travel.\n const boxHeight = PREVIEW_MAX_IMG_PX + PREVIEW_CHROME_PX;\n let top = tile.top + tile.height / 2 - boxHeight / 2;\n top = Math.min(top, viewport.height - boxHeight - PREVIEW_GAP);\n top = Math.max(PREVIEW_GAP, top);\n\n return { left, top };\n}\n","import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useCatalogueSearch } from '@wolffm/cdn-client/react';\nimport type { CatalogueItem } from '@wolffm/cdn-client';\nimport { getCdnClient, resolveChatImageUrl } from '../config';\nimport {\n PREVIEW_PX,\n PREVIEW_MAX_IMG_PX,\n placePreview,\n} from '../previewPlacement';\n\n/**\n * The shared meme library, as a picker.\n *\n * This is the half that deliberately did NOT move into `@wolffm/cdn-client`:\n * the package ships headless hooks and no markup, because watchparty's chat\n * picker and a `/tube/` comment box want the same transport and nothing like\n * the same layout. `useCatalogueSearch` owns everything that is hard and\n * invisible when wrong — the 250ms debounce, cursor paging, and aborting a\n * superseded request so a slow \"ca\" cannot land after a fast \"cat\" — and this\n * file owns the grid.\n *\n * Styling is theme tokens (`bg-bg-card`, `text-text-secondary`) from\n * `@wolffm/themes`, which are ecosystem-wide rather than watchparty-local.\n */\n\n/**\n * Facts about the corpus that change how you write against it:\n *\n * **There are no GIFs.** dataplatform sniffed it by magic bytes: 690 JPEG, 189\n * PNG, 105 WebP, 266 MP4, and ZERO GIFs — short video was transcoded to animated\n * WebP. Call them **memes**, never GIFs. There is also no third-party provider\n * and no attribution owed; the library is ours.\n *\n * **Do NOT branch on `contentType`.** Animated and still items are mixed with no\n * flag, and the encoder is moving WebP -> animated AVIF. Treat any `image/*` as\n * renderable — there is no `<video>` path to build here.\n *\n * (255 long clips are simply not pickable — videos over 6s stayed on\n * dataplatform's tunnel. Nothing to work around.)\n */\n\n/** The catalogue this picker reads. */\nexport const MEME_CATALOGUE_PATH = '/dataplatform/api/memes';\n\nexport interface MemePickerProps {\n open: boolean;\n onClose: () => void;\n /** Called with the picked item. The host decides what that means. */\n onPick: (item: CatalogueItem) => void;\n /**\n * Fired when the catalogue answers 5xx. Hosts should hide the meme\n * affordance for the session — pasting an image has to keep working when the\n * library does not, so the two features never share a failure.\n */\n onUnavailable?: () => void;\n}\n\n/**\n * Hover-intent delay. Sweeping the pointer across a row of nine memes on the\n * way to the tenth should not strobe nine panels at you.\n */\nconst HOVER_INTENT_MS = 120;\n\ninterface Preview {\n item: CatalogueItem;\n left: number;\n top: number;\n}\n\n/**\n * The open panel. Mounted ONLY while the picker is open — see `MemePicker`.\n */\nfunction MemePickerPanel({ onClose, onPick, onUnavailable }: Omit<MemePickerProps, 'open'>) {\n const [query, setQuery] = useState('');\n const panelRef = useRef<HTMLDivElement | null>(null);\n const inputRef = useRef<HTMLInputElement | null>(null);\n const client = getCdnClient();\n\n // Trimmed so that adding a trailing space does not look like a NEW query to\n // the hook and refire an identical request. It is not a correctness fix:\n // dataplatform trims server-side (verified — 'warhammer', 'warhammer ' and\n // ' warhammer' return byte-identical result sets), and cdn-client trims as\n // of 1.0.2. This only avoids the redundant round trip.\n const { items, loading, error, hasMore, loadMore } = useCatalogueSearch(\n client,\n MEME_CATALOGUE_PATH,\n query.trim(),\n );\n\n // `@wolffm/cdn-client` >= 1.0.2 shares ONE `CdnError` across its entry\n // points, so a reason classified by the core survives the hook. In 1.0.0 it\n // did not — both entries inlined their own class, `instanceof` failed inside\n // the hooks, and every failure arrived as `unknown`. That is why the version\n // floor matters; do not relax it below 1.0.2 (1.0.1 still has the bug).\n const reason = error?.reason ?? null;\n\n /**\n * Dedup by key before rendering.\n *\n * Keyset pagination plus a content-addressed catalogue (one image filed\n * under several categories collapses to a single key) makes a repeat across\n * pages possible, and `useCatalogueSearch` appends pages verbatim. Two rows\n * with the same key is a duplicate React key — a rendering bug, not a\n * cosmetic one — so the grid dedups rather than trusting the feed.\n */\n const rows = useMemo(() => {\n const seen = new Set<string>();\n return items.filter((item) => {\n if (seen.has(item.key)) return false;\n seen.add(item.key);\n return true;\n });\n }, [items]);\n\n // ── hover-to-expand ────────────────────────────────────────────────────\n // Tiles are square and cropped with `object-fit: cover`, which is the wrong\n // shape for deciding WHICH meme this is — a wide one is showing you its\n // middle third. Resting on one expands it, uncropped.\n //\n // A `position: fixed` box, NOT a `transform: scale()` on the tile. That is\n // forced rather than stylistic: the grid scrolls (`overflow-y-auto`), which\n // clips a scaled child, so every tile in the first and last row would be\n // sliced by the container it lives in. Fixed positioning escapes the clip.\n // Same reasoning and same geometry as dataplatform's `/tube/` picker.\n const [preview, setPreview] = useState<Preview | null>(null);\n const hoverTimer = useRef<number | null>(null);\n\n const cancelHover = useCallback(() => {\n if (hoverTimer.current !== null) {\n window.clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n }, []);\n\n const hidePreview = useCallback(() => {\n cancelHover();\n setPreview(null);\n }, [cancelHover]);\n\n // Only where hovering is a real gesture. A touch screen synthesises a hover\n // on tap, so without this the preview flashes over the grid on the way to\n // selecting something.\n const canHover =\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(hover: hover)').matches;\n\n const showPreview = useCallback(\n (item: CatalogueItem, tile: HTMLElement, immediate = false) => {\n const panel = panelRef.current;\n if (!panel) return;\n const place = () =>\n setPreview({\n item,\n ...placePreview(tile.getBoundingClientRect(), panel.getBoundingClientRect(), {\n width: window.innerWidth,\n height: window.innerHeight,\n }),\n });\n cancelHover();\n if (immediate) place();\n else hoverTimer.current = window.setTimeout(place, HOVER_INTENT_MS);\n },\n [cancelHover],\n );\n\n // A pending timer must not fire into an unmounted component.\n useEffect(() => () => cancelHover(), [cancelHover]);\n\n useEffect(() => {\n if (reason === 'unavailable') onUnavailable?.();\n }, [reason, onUnavailable]);\n\n useEffect(() => { inputRef.current?.focus(); }, []);\n\n // Escape closes, and a click outside closes. This component only exists\n // while open, so there is nothing to gate.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } };\n const onDown = (e: MouseEvent) => {\n if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();\n };\n document.addEventListener('keydown', onKey);\n document.addEventListener('mousedown', onDown);\n return () => {\n document.removeEventListener('keydown', onKey);\n document.removeEventListener('mousedown', onDown);\n };\n }, [onClose]);\n\n const pick = useCallback((item: CatalogueItem) => {\n onPick(item);\n onClose();\n }, [onPick, onClose]);\n\n const isEmpty = !loading && !error && rows.length === 0;\n\n return (\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-label=\"Meme library\"\n data-testid=\"meme-picker\"\n className=\"absolute bottom-full left-0 right-0 mb-2 z-30 flex max-h-80 flex-col rounded border border-border bg-bg-card shadow-lg\"\n >\n <div className=\"border-b border-border-light p-2\">\n <input\n ref={inputRef}\n type=\"text\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search memes...\"\n aria-label=\"Search memes\"\n data-testid=\"meme-search\"\n className=\"w-full rounded border border-border-light bg-bg-alt px-2 py-1 text-sm text-text placeholder:text-text-secondary focus:outline-none focus:border-primary\"\n />\n </div>\n\n <div\n className=\"flex-1 overflow-y-auto p-2\"\n /* onScroll must sit on the element that ACTUALLY scrolls — scroll does\n not bubble from it to the grid inside. Scrolling moves the tile the\n box was measured against, so the preview would hang beside nothing;\n dropping it is cheaper and more honest than recomputing per frame. */\n onScroll={hidePreview}\n /* Leaving the panel entirely (not just one tile) also drops it. */\n onMouseLeave={hidePreview}\n >\n {error && (\n <p\n className=\"m-2 p-2 text-center text-sm bg-danger-bg text-on-danger-bg rounded\"\n role=\"alert\"\n /* The reason drives both the copy and the host's decision to hide\n the affordance, so it is exposed for assertions — an outage and\n a permission failure look identical in the rendered text. */\n data-reason={reason}\n >\n {reason === 'unauthenticated'\n ? 'Sign in to browse memes'\n : reason === 'forbidden'\n ? \"Your account can't browse the meme library\"\n : 'Meme library is unavailable'}\n </p>\n )}\n\n {isEmpty && (\n <p className=\"p-4 text-center text-sm text-text-secondary\">\n {query.trim() ? `No memes match \"${query.trim()}\"` : 'The meme library is empty'}\n </p>\n )}\n\n {rows.length > 0 && (\n <div className=\"grid grid-cols-3 gap-1.5 sm:grid-cols-4\">\n {rows.map((item) => {\n // Compose from the KEY, never interpolate it. `null` means the\n // key failed the content-addressed shape check, so the row is\n // dropped rather than reaching an <img src>.\n const src = resolveChatImageUrl(item.key);\n if (!src) return null;\n return (\n <button\n key={item.key}\n type=\"button\"\n onClick={() => pick(item)}\n title={item.title}\n data-testid=\"meme-result\"\n onMouseEnter={(e) => {\n if (canHover) showPreview(item, e.currentTarget);\n }}\n onMouseLeave={hidePreview}\n /* Keyboard users get it too, and without the intent delay —\n arrowing onto a tile is already deliberate, so there is no\n sweep to protect against. */\n onFocus={(e) => showPreview(item, e.currentTarget, true)}\n onBlur={hidePreview}\n className=\"group relative aspect-square overflow-hidden rounded border border-border-light bg-bg-alt focus:outline-none focus:border-primary hover:border-primary\"\n >\n <img\n src={src}\n /* The title is frequently the ONLY text describing the\n image, and a meme-heavy chat log is unusable with a\n screen reader without it. */\n alt={item.title || 'Meme'}\n loading=\"lazy\"\n decoding=\"async\"\n className=\"h-full w-full object-cover\"\n />\n </button>\n );\n })}\n </div>\n )}\n\n {loading && (\n <p className=\"p-2 text-center text-xs text-text-secondary\">Loading…</p>\n )}\n\n {hasMore && !loading && (\n <button\n type=\"button\"\n onClick={loadMore}\n data-testid=\"meme-load-more\"\n className=\"mt-2 w-full rounded border border-border-light py-1 text-xs text-text-secondary hover:text-text hover:border-primary\"\n >\n Load more\n </button>\n )}\n </div>\n\n {preview && (\n <div\n data-testid=\"meme-preview\"\n className=\"fixed z-50 flex flex-col gap-1 rounded border border-border-light bg-bg-card p-1.5 shadow-2xl\"\n style={{\n left: preview.left,\n top: preview.top,\n width: PREVIEW_PX,\n /* The placement maths reserves exactly PREVIEW_PX of width.\n Without border-box the padding and border sit OUTSIDE that\n number and the box laps over the panel it is meant to sit\n beside. */\n boxSizing: 'border-box',\n /* Never eats the click it is describing. */\n pointerEvents: 'none',\n }}\n /* Decoration for a control that is already labelled: the tile it\n belongs to carries the same title as its `alt` and `title`, so a\n screen reader announcing this again would only echo. */\n aria-hidden=\"true\"\n >\n <img\n src={resolveChatImageUrl(preview.item.key) ?? undefined}\n alt=\"\"\n /* The box HUGS the image rather than being a fixed square. A wide\n meme in a tall box is mostly empty card, which reads as a\n loading state. */\n className=\"block w-full rounded bg-bg object-contain\"\n style={{ height: 'auto', maxHeight: PREVIEW_MAX_IMG_PX }}\n />\n {preview.item.title && (\n /* One line. A long title must not change the box height, because\n the box height is what the placement maths clamped against. */\n <span className=\"overflow-hidden text-ellipsis whitespace-nowrap text-center text-[10px] text-text-secondary\">\n {preview.item.title}\n </span>\n )}\n </div>\n )}\n </div>\n );\n}\n\n/**\n * Mount gate — the panel does not exist until the picker is opened.\n *\n * **This split is load-bearing, not organisation.** `useCatalogueSearch` fires\n * its first search on MOUNT (deliberately: an empty query returns the\n * catalogue's default set, so a picker opens populated). Hooks cannot be\n * called conditionally, so an `if (!open) return null` inside the panel sits\n * AFTER the hook and does not prevent it — the search still runs.\n *\n * With the panel rendered unconditionally, every page load fetched the meme\n * library for every viewer, whether or not they ever opened the picker: a\n * friend-gated request per load, and an error banner already showing the\n * moment someone first clicked. Keeping the mount itself behind `open` is what\n * makes \"opens populated\" cost nothing until it is opened.\n */\nexport default function MemePicker({ open, ...rest }: MemePickerProps) {\n if (!open) return null;\n return <MemePickerPanel {...rest} />;\n}\n","import { createContext, useContext } from 'react';\nimport { DEFAULT_HTTP_PORT, DEFAULT_CDN_BASE } from '@watchparty/shared';\nimport { setChatMediaConfig } from './chatMedia';\n\n/**\n * Where chat image bytes are read from in local dev.\n *\n * NOT a cosmetic difference. hadoku.me runs Cloudflare hotlink protection,\n * which answers a request carrying a `localhost` Referer with `403\n * text/plain`; Chrome surfaces that as `ERR_BLOCKED_BY_ORB` and the image\n * simply never appears. Its extension list (jpg/jpeg/png/gif/ico) predates\n * WebP, so `.webp` renders and everything else does not — a split that reads\n * like a bug in the app rather than an edge policy.\n *\n * `apps/ui/vite.config.ts` proxies this path to the CDN server-side with the\n * Referer stripped. Production keeps the real `cdnBase`, so no request path\n * differs in prod. Override either with `VITE_CDN_BASE`.\n */\nconst DEV_CDN_PROXY_BASE = '/_cdn';\n\nexport interface WatchpartyConfig {\n serverOrigin: string;\n defaultRoomKey: string;\n /** Public read base for chat image attachments (see `chatImages.ts`). Only\n * the RENDER side needs it — uploads go to the same-origin `/cdn/api/upload`\n * so the browser never handles a key. */\n cdnBase: string;\n}\n\nexport const DEFAULT_CONFIG: WatchpartyConfig = {\n serverOrigin: import.meta.env.VITE_SERVER_ORIGIN || `http://localhost:${DEFAULT_HTTP_PORT}`,\n defaultRoomKey: 'dev-room',\n cdnBase: import.meta.env.VITE_CDN_BASE || (import.meta.env.DEV ? DEV_CDN_PROXY_BASE : DEFAULT_CDN_BASE),\n};\n\n// Runtime config used by non-component code (socket, catalog, etc.)\nlet runtimeConfig: WatchpartyConfig = { ...DEFAULT_CONFIG };\n\nexport function setRuntimeConfig(config: Partial<WatchpartyConfig>) {\n // Filter out undefined values to preserve defaults\n const filtered = Object.fromEntries(\n Object.entries(config).filter(([_, v]) => v !== undefined)\n );\n runtimeConfig = { ...runtimeConfig, ...filtered };\n // The chat-media layer keeps its own copy so it can render a key without\n // reaching into this app's config — the property its extraction into\n // `@wolffm/chat-media` depends on. Mirrored here so a host that overrides\n // `cdnBase` (mount prop, VITE_CDN_BASE) configures both in one call.\n setChatMediaConfig({ cdnBase: runtimeConfig.cdnBase });\n}\n\nexport function getRuntimeConfig(): WatchpartyConfig {\n return runtimeConfig;\n}\n\n// React Context for component tree\nexport const ConfigContext = createContext<WatchpartyConfig>(DEFAULT_CONFIG);\n\nexport function useConfig(): WatchpartyConfig {\n return useContext(ConfigContext);\n}\n\n/** Returns the server origin with trailing slash stripped. */\nexport function getServerOrigin(config: WatchpartyConfig): string {\n return config.serverOrigin?.replace(/\\/$/, '') || '';\n}\n\n/** Returns the runtime config's server origin with trailing slash stripped. */\nexport function getRuntimeServerOrigin(): string {\n return getServerOrigin(getRuntimeConfig());\n}\n\n","import { createContext, useContext } from 'react';\n\n/**\n * Where a portal-rendered overlay (the chat image lightbox) mounts when there\n * is no fullscreen element to go into.\n *\n * `null` means `document.body` — right for the app, which owns the page. The\n * embeddable `RoomChrome` provides its own root instead: its stylesheet is\n * scoped under `.wp-room-chrome`, so an overlay appended to the host's <body>\n * would render unstyled, and it would be a node in the host's document that\n * the host never asked for.\n */\nexport const PortalTargetContext = createContext<HTMLElement | null>(null);\n\nexport function usePortalTarget(): HTMLElement | null {\n return useContext(PortalTargetContext);\n}\n","/**\n * Single source of truth for every localStorage key the UI reads or writes.\n *\n * - `ANON_KEY` — anonymous (public) user's preferences slot.\n * Authed users get `${ANON_KEY}:${userId}` (composed by `storageKey()`\n * in preferences.ts).\n * - `DEBUG_FLAG` — opt-in client log verbosity (see debug.ts).\n * - `REPORT_LOGS_FLAG` — opt-in to forward client logs to the server.\n * - `voteKey(owner, playlistId, mediaId)` — per-video vote in a playlist\n * session, scoped to WHO cast it (see `voteOwnerScope`). Unscoped, a\n * second person on the same browser inherited and re-submitted the first\n * person's ballot.\n * (see PlaylistPanel.tsx).\n *\n * This module is import-cycle-free on purpose: it has no other UI lib\n * dependencies so both debug.ts and preferences.ts can safely import it.\n */\nexport const STORAGE_KEYS = {\n ANON_KEY: 'watchparty_preferences',\n DEBUG_FLAG: 'wp:debug',\n REPORT_LOGS_FLAG: 'wp:reportLogs',\n voteKey: (owner: string, playlistId: string, mediaId: string) =>\n `watchparty_vote:${owner}:${playlistId}:${mediaId}`,\n} as const;\n","/**\n * localStorage access that cannot throw.\n *\n * Every call site in this app has to handle the same two facts: storage may be\n * unavailable (private mode, blocked site data, a sandboxed iframe — reading it\n * THROWS rather than returning null), and this bundle also runs where there is\n * no `window` at all. Twelve sites had hand-rolled that `try`/`catch`, each\n * with its own fallback spelling, and a missed one is an uncaught exception on\n * a path no test covers because the test environment always has storage.\n *\n * Absence and failure are deliberately NOT distinguished: no caller has ever\n * wanted to tell \"the user blocked storage\" apart from \"nothing stored yet\",\n * and a helper that returned a discriminated result would just get `?? null`'d\n * at every site.\n */\n\nfunction storage(): Storage | null {\n try {\n // `globalThis.localStorage`, NOT `window.localStorage` — they are the same\n // object in a browser, but the narrower spelling is wrong twice: it is\n // absent in a worker context, and the UI test suite runs in vitest's `node`\n // environment where specs stub the GLOBAL (`vi.stubGlobal('localStorage',\n // …)`). Requiring `window` made ten existing specs read every stub as\n // \"storage unavailable\".\n //\n // The property access itself throws in a sandboxed iframe, so it sits\n // inside the try alongside the read.\n return globalThis.localStorage ?? null;\n } catch {\n return null;\n }\n}\n\n/** Read a key. `null` when absent, unavailable, or the read threw. */\nexport function readStored(key: string): string | null {\n try {\n return storage()?.getItem(key) ?? null;\n } catch {\n return null;\n }\n}\n\n/** Write a key. Returns whether it landed — callers that care can fall back. */\nexport function writeStored(key: string, value: string): boolean {\n try {\n const s = storage();\n // NOT `storage()?.setItem(...); return true` — that optional chain\n // short-circuits when storage is absent and still reports success, which\n // is the exact lie this return value exists to prevent.\n if (!s) return false;\n s.setItem(key, value);\n return true;\n } catch {\n // Also covers a full quota, not just a blocked store.\n return false;\n }\n}\n\n/** Every key currently stored. Empty when storage is unavailable. */\nexport function storedKeys(): string[] {\n try {\n const s = storage();\n if (!s) return [];\n const keys: string[] = [];\n for (let i = 0; i < s.length; i++) {\n const k = s.key(i);\n if (k !== null) keys.push(k);\n }\n return keys;\n } catch {\n return [];\n }\n}\n\n/** Remove a key. Silent when storage is unavailable. */\nexport function removeStored(key: string): void {\n try {\n storage()?.removeItem(key);\n } catch { /* nothing to undo */ }\n}\n\n/**\n * Read a key that holds a boolean-ish flag. `null` when unset, so a caller can\n * tell \"explicitly false\" from \"never set\" — which is the distinction the\n * fragment-delivery override and the debug flag both depend on.\n */\nexport function readStoredFlag(key: string): boolean | null {\n const raw = readStored(key);\n if (raw === null) return null;\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n return null;\n}\n","/**\n * `window.__*` handles for devtools and E2E — registered everywhere, PUBLISHED\n * only when a host asks.\n *\n * Six modules used to assign their handle at import time (`__SOCKET__`,\n * `__ZUSTAND_STORE__`, `__wpDebug`, `__DISPLAY_NAMES__`, `__WP_PERF__`,\n * `__WP_SOUNDS__`). That is harmless when watchparty owns the whole page, and\n * wrong the moment it is a component on someone else's: importing\n * `@wolffm/watchparty-ui/room-chrome` would plant a live socket and the whole\n * store on the host's `window` without being asked.\n *\n * So a module REGISTERS its handle here, and nothing reaches `window` until\n * `exposeDebugGlobals()` is called. `mount()` — the whole-app entry, which\n * every E2E spec drives through these handles — calls it at boot, so the app\n * behaves exactly as before. `RoomChrome` calls it only for `debug`.\n *\n * A handle registered AFTER exposure is published immediately, so the order of\n * module evaluation and the call does not matter.\n */\n\nconst registry = new Map<string, unknown>();\nlet exposed = false;\n\nexport function registerDebugGlobal(name: string, value: unknown): void {\n registry.set(name, value);\n if (exposed) publish(name, value);\n}\n\nexport function exposeDebugGlobals(): void {\n exposed = true;\n for (const [name, value] of registry) publish(name, value);\n}\n\nfunction publish(name: string, value: unknown): void {\n if (typeof window === 'undefined') return;\n (window as unknown as Record<string, unknown>)[name] = value;\n}\n","/**\n * Debug logging utility for Watchparty UI\n *\n * Usage:\n * import { debug, warn, error } from './debug';\n *\n * debug('socket', 'Connected to server');\n * warn('chat', 'Message rate limited');\n * error('room', 'Failed to join room', err);\n *\n * Enable debug mode:\n * - Set localStorage.setItem('wp:debug', 'true')\n * - Or add ?debug=true to URL\n * - Or set window.__WP_DEBUG__ = true\n *\n * Filter by category:\n * - localStorage.setItem('wp:debug', 'socket,chat') - only show socket and chat logs\n *\n * Error reporting:\n * - All error() and warn() calls are automatically sent to the server\n * - Server endpoint: POST /api/client-log\n */\n\nimport { DEFAULT_UI_PORT, DEFAULT_HTTP_PORT } from '@watchparty/shared';\nimport { getRuntimeConfig } from './config';\nimport { STORAGE_KEYS } from './storageKeys';\nimport { readStored, writeStored, removeStored } from './browserStorage';\nimport { registerDebugGlobal } from './debugGlobals';\n\ndeclare global {\n interface Window {\n __WP_DEBUG__?: boolean;\n }\n}\n\ntype LogLevel = 'debug' | 'warn' | 'error';\n\ninterface LogEntry {\n level: LogLevel;\n category: string;\n message: string;\n data?: unknown;\n timestamp: number;\n}\n\n// Log history for debugging\nconst logHistory: LogEntry[] = [];\nconst MAX_HISTORY = 500;\n\n// Log queue for batching server reports\nlet logQueue: LogEntry[] = [];\nlet logFlushTimeout: ReturnType<typeof setTimeout> | null = null;\nconst LOG_FLUSH_DELAY = 1000; // Batch logs for 1 second\nconst LOG_FLUSH_DELAY_FAST = 200; // Faster flush when report-all is active\n\n// Room context for correlating client logs with server session logs\nlet currentRoomContext: { room_key: string; client_id: string } | null = null;\n\n/**\n * Set room context so client logs include room_key and client_id.\n * Call on join (with context) and on disconnect (with null).\n */\nexport function setLogRoomContext(ctx: { room_key: string; client_id: string } | null): void {\n currentRoomContext = ctx;\n}\n\n// Check if report-all mode is enabled (send all log levels to server)\nexport function isReportAllEnabled(): boolean {\n return readStored(STORAGE_KEYS.REPORT_LOGS_FLAG) === 'true';\n}\n\n// Check if debug mode is enabled\nexport function isDebugEnabled(): boolean {\n if (typeof window === 'undefined') return false;\n\n // Check window flag\n if (window.__WP_DEBUG__) return true;\n\n // Check URL param\n const params = new URLSearchParams(window.location.search);\n if (params.get('debug') === 'true') return true;\n\n // Check localStorage. NOT readStoredFlag: any value other than 'false'\n // enables debug, because the flag doubles as a comma-separated category\n // filter (see getAllowedCategories) — 'buffer,seek' means \"on, for those\".\n const stored = readStored(STORAGE_KEYS.DEBUG_FLAG);\n return stored !== null && stored !== 'false';\n}\n\n// Get allowed categories (empty = all)\nfunction getAllowedCategories(): Set<string> | null {\n const stored = readStored(STORAGE_KEYS.DEBUG_FLAG);\n if (!stored || stored === 'true') return null; // All categories\n\n // Parse comma-separated categories\n const categories = stored.split(',').map(c => c.trim().toLowerCase()).filter(Boolean);\n return categories.length > 0 ? new Set(categories) : null;\n}\n\n// Format timestamp\nfunction formatTime(): string {\n const now = new Date();\n return now.toLocaleTimeString('en-US', {\n hour12: false,\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n fractionalSecondDigits: 3\n });\n}\n\n// Add to history\nfunction addToHistory(entry: LogEntry) {\n logHistory.push(entry);\n if (logHistory.length > MAX_HISTORY) {\n logHistory.shift();\n }\n}\n\n// Style for console output\nconst styles = {\n debug: 'color: #64748b; font-weight: normal;',\n warn: 'color: #f59e0b; font-weight: bold;',\n error: 'color: #ef4444; font-weight: bold;',\n category: 'color: #3b82f6; font-weight: bold;',\n time: 'color: #6b7280; font-weight: normal;',\n};\n\n/** JSON.stringify with a fallback for circular or unserializable data. */\nfunction safeStringify(data: unknown): string {\n try { return JSON.stringify(data); } catch { return '[unserializable]'; }\n}\n\n/**\n * Internal shared log implementation.\n *\n * @param level - Log level (determines console style and history entry level)\n * @param consoleFn - The console function to call (console.log / console.warn / console.error)\n * @param alwaysReport - If true, always queue for server regardless of report-all mode\n * @param alwaysShow - If true, always print to console even when debug mode is off\n * @param category - Log category for filtering\n * @param message - Log message\n * @param data - Optional structured data\n */\nfunction _log(\n level: LogLevel,\n consoleFn: typeof console.log,\n alwaysReport: boolean,\n alwaysShow: boolean,\n category: string,\n message: string,\n data?: unknown,\n): void {\n const entry: LogEntry = {\n level,\n category,\n message,\n data,\n timestamp: Date.now(),\n };\n addToHistory(entry);\n\n if (alwaysReport) {\n queueLogForServer(entry);\n } else if (isReportAllEnabled()) {\n queueLogForServer(entry);\n }\n\n if (!alwaysShow) {\n if (!isDebugEnabled()) return;\n const allowed = getAllowedCategories();\n if (allowed && !allowed.has(category.toLowerCase())) return;\n }\n\n const time = formatTime();\n // Stringify data inline so Playwright msg.text() captures it\n // (msg.text() only returns the first console argument).\n const suffix = data !== undefined ? ' ' + safeStringify(data) : '';\n consoleFn(\n `%c[${time}]%c [${category}]%c ${message}${suffix}`,\n styles.time,\n styles.category,\n styles[level],\n );\n}\n\n/**\n * Debug log - only shows in debug mode\n */\nexport function debug(category: string, message: string, data?: unknown): void {\n _log('debug', console.log, false, false, category, message, data);\n}\n\n/**\n * Warning log - always reports to server, only shows in console in debug mode\n */\nexport function warn(category: string, message: string, data?: unknown): void {\n _log('warn', console.warn, true, false, category, message, data);\n}\n\n/**\n * Error log - always shows and reports to server\n */\nexport function error(category: string, message: string, data?: unknown): void {\n _log('error', console.error, true, true, category, message, data);\n}\n\n/**\n * Get log history (useful for debugging)\n */\nfunction getLogHistory(): LogEntry[] {\n return [...logHistory];\n}\n\n/**\n * Clear log history\n */\nfunction clearLogHistory(): void {\n logHistory.length = 0;\n}\n\n/**\n * Enable debug mode programmatically\n */\nfunction enableDebug(categories?: string[]): void {\n const value = categories && categories.length > 0 ? categories.join(',') : 'true';\n writeStored(STORAGE_KEYS.DEBUG_FLAG, value);\n console.log('[wp] Debug mode enabled');\n}\n\n/**\n * Disable debug mode\n */\nfunction disableDebug(): void {\n removeStored(STORAGE_KEYS.DEBUG_FLAG);\n console.log('[wp] Debug mode disabled');\n}\n\n// Max serialized size for a single log entry's data field (chars)\nconst MAX_DATA_SIZE = 1024;\n// Max payload size per request (stay well under server's 64kb limit)\nconst MAX_BATCH_SIZE = 50_000;\n\n/**\n * Truncate data field to prevent oversized payloads\n */\nfunction truncateData(data: unknown): unknown {\n if (data === undefined || data === null) return data;\n try {\n const json = JSON.stringify(data);\n if (json.length <= MAX_DATA_SIZE) return data;\n // For objects, try to keep keys but truncate values\n if (typeof data === 'object' && !Array.isArray(data)) {\n const truncated: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(data as Record<string, unknown>)) {\n const vStr = JSON.stringify(v);\n truncated[k] = vStr && vStr.length > 200 ? `[truncated ${vStr.length} chars]` : v;\n }\n return truncated;\n }\n return `[truncated ${json.length} chars]`;\n } catch {\n return '[unserializable]';\n }\n}\n\n/**\n * Synchronous best-effort log delivery for events that IMMEDIATELY precede a\n * page reload (window.location.reload). The normal async batch in\n * flushLogsToServer() is lost when the document tears down, so the reason a\n * client auto-reloaded never reached the server (observed 2026-06-16: 31\n * POST-AUTO-RELOAD with 0 trigger logs). navigator.sendBeacon is built for\n * exactly this — the browser guarantees delivery during unload. Also console.warns.\n */\nexport function beaconLog(level: string, category: string, message: string, data?: unknown): void {\n try { console.warn(`[${category}] ${message}`, data ?? ''); } catch { /* noop */ }\n try {\n if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '';\n if (origin === '/' || !origin) {\n if (typeof window !== 'undefined') {\n const loc = window.location;\n origin = (loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)\n ? `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`\n : loc.origin;\n }\n }\n const url = `${origin.replace(/\\/$/, '')}/api/client-log`;\n const body = JSON.stringify({ logs: [{\n level, category, message,\n data: truncateData(data),\n timestamp: Date.now(),\n url: typeof window !== 'undefined' ? window.location.href : undefined,\n userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,\n room_key: currentRoomContext?.room_key,\n client_id: currentRoomContext?.client_id,\n }] });\n navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));\n } catch { /* noop */ }\n}\n\n/**\n * Send queued logs to the server\n */\nasync function flushLogsToServer(): Promise<void> {\n if (logQueue.length === 0) return;\n\n const batch = logQueue;\n logQueue = [];\n logFlushTimeout = null;\n\n try {\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '';\n if (origin === '/' || !origin) {\n if (typeof window !== 'undefined') {\n const loc = window.location;\n if ((loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)) {\n origin = `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`;\n } else {\n origin = loc.origin;\n }\n }\n }\n\n const url = `${origin.replace(/\\/$/, '')}/api/client-log`;\n\n const prepared = batch.map(e => ({\n level: e.level,\n category: e.category,\n message: e.message,\n data: truncateData(e.data),\n timestamp: e.timestamp,\n url: typeof window !== 'undefined' ? window.location.href : undefined,\n userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,\n room_key: currentRoomContext?.room_key,\n client_id: currentRoomContext?.client_id,\n }));\n\n // Split into chunks that fit under the server limit\n let chunk: typeof prepared = [];\n let chunkSize = 0;\n for (const entry of prepared) {\n const entrySize = JSON.stringify(entry).length + 1; // +1 for comma\n if (chunkSize + entrySize > MAX_BATCH_SIZE && chunk.length > 0) {\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ logs: chunk }),\n });\n chunk = [];\n chunkSize = 0;\n }\n chunk.push(entry);\n chunkSize += entrySize;\n }\n if (chunk.length > 0) {\n await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ logs: chunk }),\n });\n }\n } catch (err: unknown) {\n console.warn('[wp] log flush failed (client logs may be lost):', errorFields(err).error);\n }\n}\n\n/**\n * Queue a log entry to be sent to the server\n */\nfunction queueLogForServer(entry: LogEntry): void {\n logQueue.push(entry);\n\n // Debounce the flush (faster in report-all mode)\n if (!logFlushTimeout) {\n const delay = isReportAllEnabled() ? LOG_FLUSH_DELAY_FAST : LOG_FLUSH_DELAY;\n logFlushTimeout = setTimeout(() => { void flushLogsToServer(); }, delay);\n }\n}\n\n/**\n * Narrow an unknown thrown value to the `{ error, name }` pair every log site\n * in this app reports.\n *\n * Catch sites used to annotate the variable `any` and reach into it directly\n * (`e?.message`), which turns off strict mode at precisely the point where the\n * value is least trustworthy — a thrown string or a rejected non-Error then\n * logs `undefined` instead of saying what happened.\n */\nexport function errorFields(err: unknown): { error: string; name: string } {\n return err instanceof Error\n ? { error: err.message, name: err.name }\n : { error: String(err), name: typeof err };\n}\n\n// Console access — published only when the host opts in (lib/debugGlobals.ts).\nregisterDebugGlobal('__wpDebug', {\n enable: enableDebug,\n disable: disableDebug,\n history: getLogHistory,\n clear: clearLogHistory,\n});\n","/**\n * Auth state passed in via mf-loader mount props.\n *\n * mf-loader (hadoku_site) resolves the HttpOnly `hadoku_session` cookie\n * through /session/whoami before our bundle loads and passes `userType` plus\n * `userId` — the key registry's userId, or null — as mount props. We stash\n * them in module scope so non-component code (socket setup, fetch wrappers)\n * can read identity without prop-drilling.\n *\n * There is NO session id here, on purpose (#151, hadoku_site#459). It is a\n * bearer credential for a 30-day sliding session, and about twenty bundles\n * share the hadoku.me origin, so a copy readable by page JavaScript is one\n * XSS away from being stolen. Same-origin requests carry the cookie by\n * default and edge-router resolves it exactly as it resolved `X-Session-Id`.\n * Do not read `props.sessionId` or `localStorage.hadoku_session_id` again.\n */\n\nimport { warn, errorFields } from './debug';\n\n// The full hadoku ladder. This mirrors the server's UserType; a tier missing\n// here is not a type error at the boundary (the value arrives as a string from\n// mount props) — it just makes every comparison against it fail silently.\ntype UserType = 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';\n\ntype AuthState = {\n /** False until the host's `setAuth` lands. Before it (the store's\n * module-init read) nobody knows who this is. */\n resolved: boolean;\n userType: UserType;\n /** Display name from /session/whoami; null until fetched (or for public users). */\n name: string | null;\n /** Registry userId: the host's `userId`, or whoami's. Null for anonymous\n * viewers and for a registry row that has none. */\n accountId: string | null;\n};\n\nconst _state: AuthState = {\n resolved: false,\n userType: 'public',\n name: null,\n accountId: null,\n};\n\n/**\n * The user id `@wolffm/prefs-client` builds its cache key from, mirroring its\n * `whoami` (`dist/index.js`, `whoami()`):\n * - account known -> the registry userId\n * - anonymous -> 'anon'\n * - before setAuth, or signed in with no account -> null: the SDK's key is\n * not knowable (for the latter it is `session:<id>`, which this bundle no\n * longer reads), and guessing is how a signed-in visitor gets seeded from\n * someone else's row (prod prefs-cross-user, 2026-10-05: the shared\n * `anon` row handed UserB UserA's name).\n */\nexport function getPrefsCacheUserId(): string | null {\n if (_state.accountId) return _state.accountId;\n if (!_state.resolved) return null;\n return _state.userType === 'public' ? 'anon' : null;\n}\n\nexport function setAuth(input: { userType?: UserType; userId?: string | null }) {\n _state.resolved = true;\n if (input.userType) _state.userType = input.userType;\n // Before hadoku_site 002584fe the prop was the SESSION id, or 'public' —\n // never an account. Only a registry userId names one.\n if (input.userId !== undefined) {\n _state.accountId = input.userId && input.userId !== 'public' ? input.userId : null;\n }\n}\n\nexport function getUserType(): UserType {\n return _state.userType;\n}\n\n// Rank ladder, LOW to HIGH — the browser-side mirror of TIER_RANK in\n// apps/server/src/middleware/whoami.ts. This bundle cannot import the server's\n// copy, so it must be kept complete: an unlisted tier gets rank -1 and every\n// affordance below is hidden from it.\nconst TIER_RANK: Record<UserType, number> = {\n public: 0,\n member: 1,\n friend: 2,\n service: 3,\n wife: 4,\n admin: 5,\n};\n\nfunction tierAtLeast(tier: UserType, minimum: UserType): boolean {\n return (TIER_RANK[tier] ?? -1) >= TIER_RANK[minimum];\n}\n\n/**\n * True when the user is service tier or above. Use this to gate UI affordances\n * tied to service-tier endpoints (room creation, leader URLs).\n *\n * Ranks, never equality. This read `=== 'service' || === 'admin'` until\n * 2026-08-04, which is the same exact-match allowlist that was fixed in the\n * SERVER's adminAuth at the same time — but this copy was missed, so the server\n * would have admitted a `wife` caller to those endpoints while the UI hid the\n * buttons that reach them. A UI-only version of the bug is still the bug.\n */\nexport function isPrivileged(): boolean {\n return tierAtLeast(_state.userType, 'service');\n}\n\n/** The registry userId, or 'public' when there is none. */\nexport function getUserId(): string {\n return _state.accountId ?? 'public';\n}\n\n/**\n * Whose browser-local copy of a ballot this is: the account when known, else\n * the shared anonymous scope, else `unscoped` for a signed-in viewer whose\n * account is not known — never another person's scope.\n */\nexport function voteOwnerScope(): string {\n return getPrefsCacheUserId() ?? 'unscoped';\n}\n\n/**\n * Fetch /session/whoami to populate `name` (display name from the friend-key\n * registry). The cookie is sent automatically; no headers needed. Idempotent\n * — safe to call multiple times. Returns the resolved name (or null).\n */\nexport async function loadWhoami(): Promise<string | null> {\n if (_state.userType === 'public') return null;\n if (_state.name) return _state.name;\n try {\n const res = await fetch('/session/whoami', {\n credentials: 'include',\n cache: 'no-store',\n });\n if (!res.ok) return null;\n const data = await res.json() as { valid?: boolean; name?: string | null; userId?: string | null };\n if (data.valid) {\n _state.accountId = typeof data.userId === 'string' && data.userId ? data.userId : null;\n }\n if (data.valid && typeof data.name === 'string') {\n _state.name = data.name;\n return data.name;\n }\n } catch (e: unknown) {\n warn('auth', 'whoami fetch failed', errorFields(e));\n }\n return null;\n}\n","import {\n CHARACTER_ORDER,\n CHARACTER_COLORS,\n isCharacterId,\n portraitPath,\n type CharacterId,\n} from '@watchparty/shared';\nimport { getRuntimeConfig } from './config';\nimport { debug, errorFields } from './debug';\nimport { registerDebugGlobal } from './debugGlobals';\n\n// Re-export so existing `from './characters'` imports keep working.\nexport { CHARACTER_ORDER, isCharacterId };\nexport type { CharacterId };\n\nexport function colorForSprite(sprite: string | null | undefined): string {\n return isCharacterId(sprite) ? CHARACTER_COLORS[sprite] : 'var(--color-neutral-bg)';\n}\n\n/**\n * The portrait URL for a sprite. Takes the raw value off the wire and answers\n * undefined for anything that is not a character, so a bogus sprite renders no\n * image instead of a 404. The path rule, cache-busting included, is\n * `portraitPath` in @watchparty/shared.\n */\nexport function spriteUrlFor(sprite: string | null | undefined): string | undefined {\n if (!isCharacterId(sprite)) return undefined;\n const origin = getRuntimeConfig().serverOrigin.replace(/\\/$/, '');\n return `${origin}${portraitPath(sprite)}`;\n}\n\n// --- Optional display name mapping (loaded from media/sprites/names.json) ---\n\nlet displayNames: Record<string, string> | null = null;\nlet loadPromise: Promise<void> | null = null;\n\n/** Fetch display names from server. No-op if already loaded or file doesn't exist. */\nexport function loadDisplayNames(): Promise<void> {\n if (displayNames) return Promise.resolve();\n if (loadPromise) return loadPromise;\n loadPromise = (async () => {\n try {\n const origin = getRuntimeConfig().serverOrigin.replace(/\\/$/, '');\n // no-cache, not force-cache: the server sent this file `immutable` for a\n // year until new characters were added, so a returning browser holds a\n // copy that predates them. Revalidating is what gets past it.\n const res = await fetch(`${origin}/media/sprites/names.json`, { cache: 'no-cache' });\n if (!res.ok) return; // File doesn't exist — use defaults\n const data = await res.json();\n displayNames = data;\n registerDebugGlobal('__DISPLAY_NAMES__', data);\n debug('characters', 'Loaded display names', data);\n } catch (e: unknown) {\n // names.json may not exist — degrade to sprite IDs.\n debug('characters', 'display names fetch failed (degrading to sprite IDs)', errorFields(e));\n }\n })();\n return loadPromise;\n}\n\n/** Get the display name for a sprite ID. Falls back to capitalized ID. */\nexport function displayNameFor(id: string): string {\n return displayNames?.[id] || id.charAt(0).toUpperCase() + id.slice(1);\n}\n\n/** Check if a name matches any display name or sprite ID (case-insensitive). */\nexport function isCharacterName(name: string): boolean {\n const lower = name.toLowerCase();\n if (CHARACTER_ORDER.some(c => c === lower)) return true;\n if (displayNames) {\n return Object.values(displayNames).some(dn => dn.toLowerCase() === lower);\n }\n return false;\n}\n","/**\n * Watchparty app-specific preferences client.\n *\n * Holds everything user-related that's NOT theme (theme lives in\n * themePrefs.ts under the platform-global 'portfolio' appId). Scope is\n * 'user' so the same identity sees the same settings across devices —\n * cross-device sync is the main reason for migrating off the old\n * watchparty_preferences localStorage + /api/me/prefs system.\n *\n * Bug pattern to avoid (per the migration brief): write-only\n * `void watchpartyPrefs.save(...)` without a corresponding usePrefs\n * subscription causes settings to revert on browser close. Either use\n * the React hook OR subscribe via `watchpartyPrefs.subscribe(...)` in\n * the boot path so the in-memory zustand store stays in sync with the\n * SDK's cache + server.\n */\nimport { z } from 'zod';\nimport { createPrefsClient } from '@wolffm/prefs-client';\n\nexport const WatchpartyPrefsSchema = z.object({\n // Identity. There is deliberately NO `name`: a person's name is their\n // hadoku key-registry name, resolved by the server from whoami and renamed\n // through hadoku_site's POST /session/name (operator decision 2026-10-08).\n // A watchparty-only copy disagreed with cardtable's and is not read.\n //\n // `sprite` is neither read nor written by watchparty any more: the\n // character is chosen in the fleet settings popout and read from prefs-api\n // (lib/avatar.ts). It stays in the schema because prefs-api still reads it:\n // it is the fallback character for a person who never chose one at the\n // top level (operator decision 2026-10-31).\n sprite: z.string().optional(),\n\n // Chat settings\n chatCollapsed: z.boolean().optional(),\n floatingChatEnabled: z.boolean().optional(),\n // RoomChrome's chat column (roomChrome/chatLayout.ts) — separate from the\n // app's chatCollapsed on purpose.\n chromeChatCollapsed: z.boolean().optional(),\n chromeChatWidth: z.number().optional(),\n\n // Media settings\n volume: z.number().min(0).max(1).optional(),\n muted: z.boolean().optional(),\n preferredAudioLang: z.string().optional(),\n subtitlesEnabled: z.boolean().optional(),\n subtitleStyle: z.string().optional(),\n\n});\n\nexport type WatchpartyPrefs = z.infer<typeof WatchpartyPrefsSchema>;\n\nexport const watchpartyPrefs = createPrefsClient({\n appId: 'watchparty',\n schema: WatchpartyPrefsSchema,\n});\n","/**\n * Compatibility adapter over @wolffm/prefs-client.\n *\n * Existing callers (store.ts, ChatPanel.tsx, entry.tsx, Room.tsx) keep\n * using `loadPreferences()` / `savePref()` / `syncFromServerIfFirstLoad()`.\n * Under the hood we route to two SDK clients:\n * - themePrefs (appId: 'portfolio', scope: 'device') — platform-global\n * - watchpartyPrefs (appId: 'watchparty', scope: 'user') — app-specific\n *\n * Why an adapter instead of rewriting every consumer per the brief: the\n * zustand store reads `loadPreferences()` synchronously at module\n * init (line 155 of store.ts), so flipping to the async `usePrefs()` hook\n * everywhere would mean restructuring store boot. Adapter keeps the API\n * sync and reads the SDK's localStorage cache directly for the\n * first-paint values; SDK-driven server refreshes then call into the\n * store's rehydrateFromPrefs() via subscribers wired up in\n * syncFromServerIfFirstLoad.\n *\n * Bug pattern to avoid (carried from the cross-ecosystem rollout):\n * write-only `void watchpartyPrefs.save(...)` without a usePrefs\n * subscription causes settings to revert on browser close. We satisfy\n * the subscription requirement via the `subscribe(...)` wiring at boot\n * — every change anywhere drives the store back into sync.\n */\n\nimport { debug, warn } from './debug';\nimport { getUserId, getPrefsCacheUserId } from './auth';\nimport { themePrefs, type ThemePrefs } from '@wolffm/themes';\nimport { watchpartyPrefs, type WatchpartyPrefs } from '../prefs/watchpartyPrefs';\nimport { readStored, removeStored, storedKeys, writeStored } from './browserStorage';\n\n// Keep the old type export so callers don't need re-imports.\ninterface UserPreferences extends ThemePrefs, WatchpartyPrefs {}\n\n// Keys routed to the platform-global theme client. Everything else lands\n// on the watchparty client.\nconst THEME_KEYS = new Set<string>(['theme', 'themeMode']);\ntype ThemeKey = keyof ThemePrefs;\n// `sprite` stays in the schema (prefs-api falls back to it for someone who\n// never chose a character at the top level) but watchparty no longer writes\n// it: the character is chosen in the fleet settings popout (lib/avatar.ts).\ntype WatchpartyKey = Exclude<keyof WatchpartyPrefs, 'sprite'>;\ntype PrefKey = ThemeKey | WatchpartyKey;\n\n/** SDK cache key, or null while the SDK's own key for this session is not yet\n * knowable. Mirrors @wolffm/prefs-client's construction\n * (`prefs-cache:<whoami userId>:<appId>`). It used to build the key from the\n * SESSION id, which the SDK never uses for a signed-in account — so the boot\n * read always missed and `writeThrough` wrote rows nothing read. See\n * `getPrefsCacheUserId`. */\nfunction sdkCacheKey(appId: string): string | null {\n const uid = getPrefsCacheUserId();\n return uid ? `prefs-cache:${uid}:${appId}` : null;\n}\n\n/**\n * A real user is coming, but we do not know which SDK cache row is theirs.\n *\n * Everything keyed on identity is untrustworthy in this window — the shared\n * `anon` row and the in-memory mirror can both hold the PREVIOUS user's\n * preferences. With the session→account record (`getPrefsCacheUserId`) this\n * window is normally only a signed-in visitor's FIRST boot in this browser.\n */\nfunction identityPendingUnresolved(): boolean {\n return getPrefsCacheUserId() === null;\n}\n\n/** Sync read of the SDK's localStorage cache for the current user.\n * Returns the merged blob if present; {} otherwise. Used by the zustand\n * store at module-init before the SDK has had a chance to async-init. */\nfunction readSdkCache(appId: string): Record<string, unknown> {\n // NEVER seed a signed-in visitor from the shared `anon` row.\n //\n // `sdkCacheKey` used to map an unresolved identity to `prefs-cache:anon:<app>`,\n // which every user of this browser shares and which keeps whatever the LAST\n // session left in it. At module-init the identity is unresolved (mf-loader\n // calls setAuth after importing this bundle), so a signed-in visitor was\n // seeded with the PREVIOUS user's name and sprite — and `rehydrateFromPrefs` only applies fields that\n // are PRESENT, so a user whose own prefs are empty never cleared them.\n // Reproduced 2026-08-10: UserB signs in with their own key and session and\n // shows UserA's identity indefinitely.\n //\n // Returning {} (no key) means such a visitor starts from defaults and gets\n // their real prefs when the pull lands, a beat later. A genuinely anonymous\n // visitor has no session mirror and still reads the anon row, so pre-auth\n // picks survive. A returning visitor reads their OWN row by account id.\n const key = sdkCacheKey(appId);\n if (!key) return {};\n const raw = readStored(key);\n if (!raw) return {};\n try {\n // The try still earns its place: JSON.parse throws on a corrupt cache\n // entry, which readStored cannot protect against.\n const parsed = JSON.parse(raw) as { blob?: Record<string, unknown> };\n return parsed?.blob ?? {};\n } catch {\n return {};\n }\n}\n\n/**\n * Strip the retired `name` field from every watchparty prefs row this browser\n * holds. Returns how many rows it rewrote.\n *\n * A person's name is their key-registry name (2026-10-08); watchparty's own\n * `name` pref is gone and nothing reads it. hadoku_site sweeps the server rows;\n * these local copies are what that cannot reach — the SDK cache row of every\n * account that ever used this browser, the `anon` row (never synced at all),\n * and a pre-SDK `watchparty_preferences[:uid]` blob, which `loadPreferences`\n * still folds in. Only the one field goes; everything else in each row is left\n * exactly as it was. Idempotent: a row without `name` is not rewritten.\n */\nexport function stripRetiredNameFromCaches(): number {\n let rewritten = 0;\n for (const key of storedKeys()) {\n const sdkRow = key.startsWith('prefs-cache:') && key.endsWith(':watchparty');\n const legacyRow = key === LEGACY_PREFIX || (key.startsWith(`${LEGACY_PREFIX}:`) && !key.endsWith(':_serverSynced_v1'));\n if (!sdkRow && !legacyRow) continue;\n const raw = readStored(key);\n if (!raw) continue;\n let parsed: Record<string, unknown> | null;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown> | null;\n } catch {\n continue; // a corrupt row is its owner's to replace, not ours to guess at\n }\n // An SDK row is `{ blob, userVersion, ... }`; a legacy one is the blob itself.\n const blob = sdkRow ? (parsed?.blob as Record<string, unknown> | undefined) : parsed;\n if (!blob || typeof blob !== 'object' || !('name' in blob)) continue;\n const { name: _retired, ...rest } = blob;\n const next = sdkRow ? { ...parsed, blob: rest } : rest;\n if (writeStored(key, JSON.stringify(next))) rewritten++;\n }\n if (rewritten > 0) debug('preferences', 'Stripped retired name from cached prefs rows', { rewritten });\n return rewritten;\n}\n\n// IN-MEMORY MIRROR of the SDK's currentBlob. Updated by subscribe handlers\n// in syncFromServerIfFirstLoad. The 2026-05-27 cedar-ridge telemetry\n// showed `sdkAppBytesPost:0` IMMEDIATELY AFTER read() resolved with\n// data: the SDK's storage.set wrapper swallows quota / private-mode /\n// blocked-storage errors silently, so the localStorage cache stays\n// empty even though the SDK's in-memory currentBlob is populated. With\n// only-cache reads, every page load lost the data. These vars are the\n// sync-readable backstop: when localStorage write fails, subscribers\n// still capture the blob here, and loadPreferences picks it up. Means\n// even with completely broken localStorage, the store boots with the\n// user's real identity as soon as the first SDK fetch resolves.\nlet latestThemeBlob: ThemePrefs | null = null;\nlet latestAppBlob: WatchpartyPrefs | null = null;\n\n/** Synchronous read for store-init / rehydrate time.\n *\n * Legacy `watchparty_preferences[:userId]` is merged as a fallback ONCE\n * per userId per page session (tracked by `legacyMergedFor` below).\n * Subsequent reads for the same userId return SDK-only — because the\n * legacy blob can hold STALE values for fields the user has since\n * changed via the SDK in this session. Example bug if we kept merging:\n *\n * 1. Two settings change in a row → savePref('volume') +\n * savePref('subtitleStyle').\n * 2. save({volume}) fires its notify before save({subtitleStyle}) starts →\n * my subscribe handler rehydrates while SDK has ONLY {volume}.\n * 3. loadPreferences merges {...legacy{subtitleStyle:'old'}, ...sdk}\n * → returns subtitleStyle from legacy.\n * 4. rehydrateFromPrefs applies the old style — REVERTING the click the\n * user just made.\n *\n * Per-uid flag: module-init runs under userId='public' (pre-setAuth);\n * entry.tsx then calls rehydrateFromPrefs() post-setAuth which resolves\n * the real uid. Each gets its own one-shot legacy merge so the keyed\n * slot's data still flows on first authed read.\n *\n * Why the fallback exists at all: returning users hit this code with\n * EMPTY SDK caches (their data was on the old /api/me/prefs endpoint,\n * not the new hadoku.me/prefs prefs-store). Without it, the store boots\n * with defaults; ensureAutoIdentity in Room.tsx then picks Frieren+sage\n * off the unused-character list before the async migration completes,\n * and connectAndJoin ships the wrong identity (the 2026-05-26 cedar-\n * ridge regression). */\nconst legacyMergedFor = new Set<string>();\nexport function loadPreferences(): UserPreferences {\n // Read order (most → least authoritative for sync reads):\n // 1. SDK localStorage cache — written by SDK's writeCache after every\n // server read. Survives page reloads. Source of truth when working.\n // 2. In-memory subscriber state — backstop when storage.set silently\n // failed (quota/private mode/blocked). SDK still notified us with\n // currentBlob even though cache stayed empty.\n // 3. Legacy `watchparty_preferences[:userId]` — pre-SDK key, merged\n // ONCE per uid per page session. See legacyMergedFor below.\n // A session is coming but has not resolved: serve NOTHING, from any source.\n //\n // Guarding `readSdkCache` alone was not enough — verified against production,\n // the leak survived it. Source 2, the in-memory subscriber mirror, is fed by\n // the SDK's `subscribe`, and the SDK emits its ANON blob before it resolves\n // the user. So blanking the cache just moved the previous user's values one\n // fallback down: `Object.keys(cacheApp).length > 0 ? cacheApp : latestAppBlob`\n // handed back exactly the same data.\n //\n // Both sources are keyed on an identity we do not have yet, so neither can be\n // trusted to belong to THIS user. Defaults for a beat, then the real prefs.\n if (identityPendingUnresolved()) {\n debug('preferences', 'Boot read deferred — session pending, identity unresolved');\n return {};\n }\n\n const cacheTheme = readSdkCache('portfolio');\n const cacheApp = readSdkCache('watchparty');\n const themeBlob = Object.keys(cacheTheme).length > 0\n ? cacheTheme\n : ((latestThemeBlob as Record<string, unknown> | null) ?? {});\n const appBlob = Object.keys(cacheApp).length > 0\n ? cacheApp\n : ((latestAppBlob as Record<string, unknown> | null) ?? {});\n const uid = getUserId() || 'public';\n\n if (legacyMergedFor.has(uid)) {\n debug('preferences', 'Loaded (sync, SDK-only after first read)', {\n uid,\n sdkThemeKeys: Object.keys(themeBlob).length,\n sdkAppKeys: Object.keys(appBlob).length,\n // 'mem' indicates in-memory backstop fired (cache was empty,\n // we used latestBlob instead).\n themeSource: Object.keys(cacheTheme).length > 0 ? 'cache' : 'mem',\n appSource: Object.keys(cacheApp).length > 0 ? 'cache' : 'mem',\n });\n return { ...appBlob, ...themeBlob };\n }\n\n legacyMergedFor.add(uid);\n const legacy = readLegacy() ?? {};\n const merged = { ...legacy, ...appBlob, ...themeBlob } as UserPreferences;\n debug('preferences', 'Loaded (sync, first read — legacy + SDK)', {\n uid,\n sdkThemeKeys: Object.keys(themeBlob).length,\n sdkAppKeys: Object.keys(appBlob).length,\n legacyKeys: Object.keys(legacy).length,\n themeSource: Object.keys(cacheTheme).length > 0 ? 'cache' : 'mem',\n appSource: Object.keys(cacheApp).length > 0 ? 'cache' : 'mem',\n });\n return merged;\n}\n\n/** Save a single preference. Routes to the correct SDK client. Fire-and-\n * forget — the SDK debounces PUTs internally + we don't want callsites\n * to be async. `.catch(log)` per the cross-ecosystem bug pattern.\n *\n * Note: theme writes ALWAYS go to scope:'device' (per platform contract\n * — see hadoku_site themePrefs.ts). Everything else is scope:'user'\n * which is the SDK default. */\n/**\n * Did THIS page load write any preference while genuinely anonymous?\n *\n * Gates `promoteAnonPrefsToUser()` — see the call site in `syncFromServer` for\n * why promoting unconditionally leaks one user's identity to the next.\n */\nlet anonWritesThisLoad = false;\n\n/** True only while no real session identity has resolved yet. */\nfunction isAnonymousNow(): boolean {\n const uid = getUserId();\n return !uid || uid === 'public';\n}\n\nexport function savePref<K extends PrefKey>(key: K, value: UserPreferences[K]): void {\n if (isAnonymousNow()) anonWritesThisLoad = true;\n if (THEME_KEYS.has(key as string)) {\n void themePrefs\n .save({ [key]: value } as Partial<ThemePrefs>, { scope: 'device' })\n .catch(err => warn('preferences', 'themePrefs.save failed', { key, err: (err as Error)?.message }));\n } else {\n void watchpartyPrefs\n .save({ [key]: value } as Partial<WatchpartyPrefs>)\n .catch(err => warn('preferences', 'watchpartyPrefs.save failed', { key, err: (err as Error)?.message }));\n }\n debug('preferences', 'Saved (via SDK)', { key, value });\n}\n\n/** First-load server pull + subscriber wiring.\n *\n * The old implementation pulled from `/api/me/prefs` and gated with a\n * per-user \"_serverSynced_v1\" localStorage flag. The SDK handles the\n * pull itself (server-driven cache refresh on first `read()`); we just\n * need to:\n * 1. Trigger the read for both clients (so the server is consulted).\n * 2. Subscribe to ongoing updates and rehydrate the zustand store on\n * each change — drives cross-tab sync + late-server-resolve.\n * 3. Promote anon prefs to the user slot if applicable (auth landed\n * after the user already saved some anon settings).\n *\n * Returns true if the SDK reads succeeded. Errors are non-fatal; callers\n * should proceed to join regardless. In-flight dedup via the cached\n * promise so entry.tsx + Room.tsx can both call it without racing.\n */\nlet syncInFlight: Promise<boolean> | null = null;\nlet subscribedOnce = false;\nexport function syncFromServerIfFirstLoad(): Promise<boolean> {\n if (syncInFlight) return syncInFlight;\n syncInFlight = (async () => {\n if (typeof window === 'undefined') return false;\n try {\n // Subscribe FIRST so the read()'s initial cache notification +\n // any background refresh both flow into the store. Idempotent —\n // we only attach once per page lifetime.\n if (!subscribedOnce) {\n subscribedOnce = true;\n // Capture the blob into the in-memory mirror first, then trigger\n // a rehydrate. The mirror is loadPreferences's backstop for when\n // localStorage cache writes silently fail (the 2026-05-27 bug\n // where the SDK successfully fetched data but couldn't persist\n // it to localStorage, so every reload re-fetched async + lost\n // the race against ensureAutoIdentity → Frieren).\n //\n // ALSO write through to localStorage ourselves with explicit\n // error logging. If the SDK's storage.set silently failed, this\n // surfaces the cause + lets us see whether it's the SDK or the\n // browser. If our write succeeds, next page load gets a hot\n // cache and the in-memory backstop won't be needed.\n themePrefs.subscribe(blob => {\n latestThemeBlob = blob;\n writeThrough('portfolio', blob);\n rehydrate('theme');\n });\n watchpartyPrefs.subscribe(blob => {\n latestAppBlob = blob;\n writeThrough('watchparty', blob);\n rehydrate('watchparty');\n });\n }\n\n const [themeFromServer, appFromServer] = await Promise.all([\n themePrefs.read(),\n watchpartyPrefs.read(),\n ]);\n\n // CRITICAL: synchronously rehydrate the store BEFORE this promise\n // resolves. The subscribe-driven rehydrate path uses a dynamic\n // `import('./store')` (to break the store↔preferences module\n // cycle), which means notify → subscribe → rehydrate is ASYNC\n // — the import-promise resolves on a later microtask. Room.tsx\n // awaits syncFromServerIfFirstLoad and then reads\n // useAppStore.getState() immediately; without this inline\n // rehydrate, the store hasn't picked up the SDK data yet AT THAT\n // POINT, so ensureAutoIdentity sees empty name and assigns\n // Frieren+sage even though the SDK successfully fetched Hadoku\n // from the server (the 2026-05-26/27 cedar-ridge regression).\n // After this line: the store reflects the SDK's just-fetched\n // values, and Room.tsx's getState() will see them.\n try {\n // Static import would create a cycle (store imports preferences\n // at module top), but at the moment THIS function is invoked the\n // store module IS fully initialized — module-init has long since\n // completed. So we can safely require()-at-call-time without\n // tripping the cycle.\n const { useAppStore } = await import('./store');\n useAppStore.getState().rehydrateFromPrefs();\n } catch (err) {\n warn('preferences', 'inline rehydrate after SDK read failed', { err: (err as Error)?.message });\n }\n\n // Boot diagnostic: log what the SDK actually read from hadoku.me/prefs.\n // Lets us see whether the server has data for this user vs whether\n // the issue is purely client-side. 2026-05-26 added during cedar-ridge\n // regression investigation — pair with the entry.tsx boot beacon.\n //\n // Also re-check localStorage RIGHT NOW: the SDK's refreshFromServer\n // is supposed to writeCache after every fetch. If sdkAppBytes is 0\n // here despite a non-empty appFromServer, then writeCache is\n // silently failing (quota? private mode? some other code wiping?)\n // — that's the entire reason the boot beacon shows empty cache\n // even though the server clearly has data. This second snapshot\n // tells us whether the cache write is the problem.\n const themeKeyPost = sdkCacheKey('portfolio');\n const appKeyPost = sdkCacheKey('watchparty');\n const sdkThemePost = themeKeyPost ? readStored(themeKeyPost) : null;\n const sdkAppPost = appKeyPost ? readStored(appKeyPost) : null;\n // debug, not warn — same rationale as the boot beacon in entry.tsx:\n // closed-investigation instrumentation that fired on every boot.\n debug('preferences', 'sdk.read', {\n themeKeys: Object.keys(themeFromServer ?? {}),\n appKeys: Object.keys(appFromServer ?? {}),\n // Post-fetch cache state: should be non-zero if SDK wrote it.\n sdkThemeBytesPost: sdkThemePost?.length ?? 0,\n sdkAppBytesPost: sdkAppPost?.length ?? 0,\n });\n\n // One-shot migration: users with prefs only in the legacy\n // `watchparty_preferences[:userId]` localStorage key (from the pre-SDK\n // implementation) would otherwise see fresh defaults on the new code,\n // since the SDK fetches from hadoku.me/prefs which has nothing for\n // them yet. If the SDK is empty for this user AND the legacy key has\n // data, split it across the two clients and save. SDK save() does\n // API-Always-Wins reconciliation on 409, so cross-device conflicts\n // (another device already migrated under this user) resolve cleanly.\n await migrateLegacyIfNeeded(themeFromServer, appFromServer);\n\n // Surface pre-auth picks under the newly-known identity — but ONLY if\n // this page load actually made some.\n //\n // `promoteAnonPrefsToUser()` copies the SDK's `anon` row into the current\n // user's row. The anon row is shared by every user of the browser, and it\n // keeps whatever the LAST session left in it, so promoting on every boot\n // hands one user's preferences to the next one who signs in. Reproduced\n // 2026-08-10: UserA signs in and sets an identity; UserB then signs in\n // with their own key and session and sees UserA's name and sprite —\n // persistently, because B's own (empty) server prefs never overwrite it.\n // It stayed hidden because a user with existing server prefs has them\n // pulled over the promoted values a moment later.\n //\n // A genuine pre-auth pick can only exist if we WROTE one while anonymous\n // in this page's lifetime, which `savePref` records. A user who arrived\n // already authenticated has nothing to promote, and the anon row they can\n // see belongs to somebody else.\n if (anonWritesThisLoad) {\n debug('preferences', 'Promoting this load\\'s pre-auth picks to the signed-in user');\n await Promise.all([\n themePrefs.promoteAnonPrefsToUser().catch(() => {}),\n watchpartyPrefs.promoteAnonPrefsToUser().catch(() => {}),\n ]);\n } else {\n debug('preferences', 'Skipping anon promotion — no pre-auth writes this load');\n }\n\n debug('preferences', 'First-load SDK sync complete');\n return true;\n } catch (err) {\n warn('preferences', 'First-load SDK sync failed (non-fatal)', { err: (err as Error)?.message });\n return false;\n }\n })();\n return syncInFlight;\n}\n\n// ── Legacy localStorage migration ─────────────────────────────────────────\n//\n// Pre-SDK builds wrote everything to ONE localStorage key:\n// anon: `watchparty_preferences`\n// authed: `watchparty_preferences:{userId}`\n//\n// On first load with the SDK in place, the migration below splits that\n// blob across the two new SDK clients (themePrefs / watchpartyPrefs) and\n// deletes the legacy key. Only runs when the SDK is empty for the current\n// user — never overwrites real SDK data. Cross-device conflicts (another\n// device already migrated under this user) resolve via the SDK's\n// API-Always-Wins 409 reconciliation in save().\n\nconst LEGACY_PREFIX = 'watchparty_preferences';\n\n// Before anything reads a cached row (LEGACY_PREFIX must be initialised first).\nstripRetiredNameFromCaches();\n\nfunction legacyKey(): string {\n const uid = getUserId();\n return !uid || uid === 'public' ? LEGACY_PREFIX : `${LEGACY_PREFIX}:${uid}`;\n}\n\nfunction readLegacy(): Record<string, unknown> | null {\n const raw = readStored(legacyKey());\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n } catch {\n return null; // corrupt entry — JSON.parse is what throws here\n }\n}\n\nfunction deleteLegacy(): void {\n removeStored(legacyKey());\n // Also wipe the per-user \"_serverSynced_v1\" flag from the old\n // syncFromServerIfFirstLoad implementation — it has no meaning under the SDK\n // and would just be dead storage.\n removeStored(`${legacyKey()}:_serverSynced_v1`);\n}\n\nasync function migrateLegacyIfNeeded(\n themeFromServer: ThemePrefs,\n appFromServer: WatchpartyPrefs,\n): Promise<void> {\n const legacy = readLegacy();\n if (!legacy || Object.keys(legacy).length === 0) return;\n\n // Per-client \"is SDK populated for this user yet?\" — checked separately\n // so a user whose theme is already on the platform-global portfolio row\n // (e.g. set elsewhere in the hadoku ecosystem) still gets their app\n // prefs migrated. Previous all-or-nothing check skipped the entire\n // migration when EITHER client had any field set, which silently\n // stranded watchparty-specific prefs in legacy localStorage forever.\n const sdkHasTheme = Object.keys(themeFromServer ?? {}).length > 0;\n const sdkHasApp = Object.keys(appFromServer ?? {}).length > 0;\n\n // Field-by-field allowlist with type guards. Older clients may have\n // written stray pre-schema fields; they get dropped without tripping\n // Zod parse downstream.\n const themePatch: Partial<ThemePrefs> = {};\n const appPatch: Partial<WatchpartyPrefs> = {};\n for (const [k, v] of Object.entries(legacy)) {\n if (sdkHasTheme) {\n // Theme SDK row is authoritative; skip the theme half entirely.\n } else if (k === 'theme' && typeof v === 'string') {\n themePatch.theme = v;\n } else if (k === 'themeMode' && (v === 'simple' || v === 'advanced')) {\n themePatch.themeMode = v;\n }\n if (sdkHasApp) {\n // App SDK row is authoritative; skip the app half entirely.\n } else if (k === 'chatCollapsed' && typeof v === 'boolean') appPatch.chatCollapsed = v;\n else if (k === 'floatingChatEnabled' && typeof v === 'boolean') appPatch.floatingChatEnabled = v;\n else if (k === 'volume' && typeof v === 'number' && v >= 0 && v <= 1) appPatch.volume = v;\n else if (k === 'muted' && typeof v === 'boolean') appPatch.muted = v;\n else if (k === 'preferredAudioLang' && typeof v === 'string') appPatch.preferredAudioLang = v;\n else if (k === 'subtitlesEnabled' && typeof v === 'boolean') appPatch.subtitlesEnabled = v;\n else if (k === 'subtitleStyle' && typeof v === 'string') appPatch.subtitleStyle = v;\n }\n\n const themeKeys = Object.keys(themePatch);\n const appKeys = Object.keys(appPatch);\n\n if (themeKeys.length === 0 && appKeys.length === 0) {\n // Either both SDK rows already had data (legacy is now stale) OR the\n // legacy blob had no recognizable schema fields. Either way, drop it.\n deleteLegacy();\n return;\n }\n\n debug('preferences', 'Migrating legacy localStorage prefs to SDK', {\n themeKeys,\n appKeys,\n sdkHasTheme,\n sdkHasApp,\n });\n try {\n await Promise.all([\n themeKeys.length > 0 ? themePrefs.save(themePatch, { scope: 'device' }) : Promise.resolve(),\n appKeys.length > 0 ? watchpartyPrefs.save(appPatch) : Promise.resolve(),\n ]);\n deleteLegacy();\n debug('preferences', 'Legacy migration complete');\n } catch (err) {\n warn('preferences', 'Legacy migration save failed — will retry next load', {\n err: (err as Error)?.message,\n });\n // Don't deleteLegacy — leave it for the next first-load to try again.\n }\n}\n\n/** Mirror the SDK's currentBlob into localStorage ourselves. The SDK's\n * built-in storage.set wraps localStorage.setItem in a try/catch that\n * swallows errors silently — so the cache stayed empty even though the\n * SDK had data in memory (the 2026-05-27 sdkAppBytesPost:0 bug).\n *\n * Here we attempt the write + warn on failure with the actual exception.\n * If THIS write succeeds when the SDK's didn't, the SDK's silent-catch\n * is the bug. If THIS write also fails, localStorage is genuinely\n * broken in this browser (quota / private mode / blocked) and the\n * in-memory mirror is the only path forward. Either way the warn\n * text in server.jsonl tells us which.\n */\nlet writeFailureWarned = false;\n// Deliberately NOT lib/browserStorage's writeStored: that returns a boolean and\n// discards the exception, and the whole point of this function is to warn with\n// the actual error so server.jsonl can say WHICH failure happened.\nfunction writeThrough(appId: 'portfolio' | 'watchparty', blob: Record<string, unknown>): void {\n // Not knowable yet → nothing to write through TO. Writing a guessed row is\n // how the old sessionId-keyed shim piled up rows nothing ever read.\n const key = sdkCacheKey(appId);\n if (!key) return;\n try {\n const env = {\n blob,\n userVersion: 0,\n deviceVersion: 0,\n cachedAt: Date.now(),\n };\n localStorage.setItem(key, JSON.stringify(env));\n } catch (err) {\n if (!writeFailureWarned) {\n writeFailureWarned = true;\n warn('preferences', 'writeThrough localStorage failed (in-memory backstop only)', {\n appId,\n err: (err as Error)?.message,\n name: (err as Error)?.name,\n });\n }\n }\n}\n\n/** Lazy import + invoke rehydrateFromPrefs on the zustand store. Imported\n * inside the call to avoid the module-init cycle (store.ts imports from\n * here at top level). */\nfunction rehydrate(reason: string): void {\n void import('./store')\n .then(({ useAppStore }) => {\n try {\n useAppStore.getState().rehydrateFromPrefs();\n } catch (err) {\n warn('preferences', 'rehydrateFromPrefs threw', { reason, err: (err as Error)?.message });\n }\n })\n .catch(err => warn('preferences', 'store import failed', { reason, err: (err as Error)?.message }));\n}\n","/**\n * The chat column's geometry inside `RoomChrome`: collapsed or open, and how\n * wide when open. Pure, so the rules a host's layout depends on are tested\n * without a browser.\n *\n * Saved per viewer under their OWN pref keys (`chromeChatCollapsed`,\n * `chromeChatWidth`), not the app's `chatCollapsed`: a host's table and\n * watchparty's player want the chat differently, and sharing the key would\n * make hiding chat over a card game also hide it beside the next episode.\n */\n\n/** The column's width before the viewer has dragged it: the 20rem it always was. */\nexport const CHAT_WIDTH_DEFAULT_PX = 320;\n/** Narrower than this and the toolbar's buttons and the input stop fitting. */\nexport const CHAT_WIDTH_MIN_PX = 240;\nexport const CHAT_WIDTH_MAX_PX = 640;\n/** However wide the viewer drags, the host keeps at least this much. */\nexport const HOST_MIN_PX = 320;\n/** The collapsed column: just wide enough for the button that reopens it. */\nexport const COLLAPSED_RAIL_PX = 36;\n/** One arrow-key press on the resize handle. */\nexport const KEYBOARD_STEP_PX = 16;\n\n/**\n * A width the column may take. `frameWidth` (the whole chrome's width, when\n * known) caps it so the host always keeps `HOST_MIN_PX`; on a frame too narrow\n * for both, the chat's minimum wins, since a chat narrower than its toolbar is\n * broken while a squeezed host is merely squeezed. A saved value that is not a\n * finite number (a corrupt or foreign pref) is the default, never NaN px.\n */\nexport function clampChatWidth(px: number, frameWidth?: number): number {\n if (!Number.isFinite(px)) return CHAT_WIDTH_DEFAULT_PX;\n let max = CHAT_WIDTH_MAX_PX;\n if (frameWidth !== undefined && Number.isFinite(frameWidth) && frameWidth > 0) {\n max = Math.min(max, frameWidth - HOST_MIN_PX);\n }\n max = Math.max(max, CHAT_WIDTH_MIN_PX);\n return Math.round(Math.min(max, Math.max(CHAT_WIDTH_MIN_PX, px)));\n}\n\n/** The column sits on the RIGHT, so dragging its left edge leftward widens it. */\nexport function widthFromDrag(startWidth: number, startX: number, currentX: number, frameWidth?: number): number {\n return clampChatWidth(startWidth + (startX - currentX), frameWidth);\n}\n\n/** The handle's keyboard contract (a vertical `separator`): Left widens,\n * Right narrows, Home/End jump to the bounds. `null` for any other key, so\n * the caller leaves it alone. */\nexport function widthFromKey(width: number, key: string, frameWidth?: number): number | null {\n switch (key) {\n case 'ArrowLeft': return clampChatWidth(width + KEYBOARD_STEP_PX, frameWidth);\n case 'ArrowRight': return clampChatWidth(width - KEYBOARD_STEP_PX, frameWidth);\n case 'Home': return clampChatWidth(CHAT_WIDTH_MIN_PX, frameWidth);\n case 'End': return clampChatWidth(CHAT_WIDTH_MAX_PX, frameWidth);\n default: return null;\n }\n}\n\nexport interface ChatLayoutState {\n collapsed: boolean;\n /** The column's RENDERED width in CSS px — the rail's while collapsed —\n * i.e. how much of the frame the host does not have. */\n width: number;\n}\n\nexport function chatLayoutState(collapsed: boolean, width: number): ChatLayoutState {\n return { collapsed, width: collapsed ? COLLAPSED_RAIL_PX : width };\n}\n","import { create } from 'zustand';\nimport type { THealthSnapshot, TBufferStatus, TPlaylistConfig, TVoteValue, TPlaylistVoteProgressOut, TPlaylistResultsOut, TTier, TTierlistSnapshotOut } from '@watchparty/shared';\nimport type { TChatImage } from '@watchparty/shared';\nimport { DEFAULT_CHAT_UI_BUFFER } from '@watchparty/shared';\nimport { CHARACTER_ORDER, displayNameFor } from './characters';\nimport { debug, warn } from './debug';\nimport { loadPreferences, savePref } from './preferences';\nimport type { ProbedRoomKeyType } from './leaderAffordance';\nimport { registerDebugGlobal } from './debugGlobals';\nimport { clampChatWidth, CHAT_WIDTH_DEFAULT_PX } from '../roomChrome/chatLayout';\n\n// --- Phase 2 types ---\nexport interface RosterUser {\n clientId: string;\n /** Participant handle (see `PresenceUser.author`); absent from older servers. */\n author?: string;\n name: string;\n sprite: string | null;\n isLeader?: boolean;\n /** `name` is the person's key-registry name — see `PresenceUser.registry_name`. */\n registryName?: boolean;\n /** Their avatar picture (Discord, Steam, uploaded), shown INSTEAD of the\n * character portrait wherever the room shows who they are — never in a\n * game, which keeps the character. Absent = the character is the picture. */\n avatarUrl?: string;\n}\n\nexport interface SnapshotState {\n /** The room's id (not the key this client joined with); null before join. */\n roomId: string | null;\n mediaId: string | null;\n playheadMs: number;\n playing: boolean;\n serverSeq: number; // last authoritative seq applied\n leaderId: string | null;\n}\n\ninterface MeState {\n clientId: string | null;\n nextClientSeq: number; // local incrementing control seq (client scoped)\n}\n\ninterface DriftState {\n rtts: number[]; // last 5 RTT samples (ms)\n offsetMs: number; // estimated serverTime - clientTime\n}\n\nexport interface ChatMessage {\n id: string; // could be server seq or timestamp derived\n clientId: string;\n /** Sender's participant handle — survives their reconnect, unlike clientId. */\n author?: string;\n name: string;\n sprite: string | null;\n text: string;\n ts: number; // epoch ms\n isSystem?: boolean;\n systemType?: string; // 'join' | 'leave' | 'media_change'\n /** CDN image attachments. Content-addressed KEYS, never absolute URLs —\n * see `chatImages.ts` in @watchparty/shared for why that matters. */\n images?: TChatImage[];\n}\n\n// Media preferences state\ninterface MediaPrefsState {\n volume: number;\n muted: boolean;\n preferredAudioLang: string;\n subtitlesEnabled: boolean;\n subtitleStyle: string;\n}\n\n// Buffer state for UI feedback\nexport interface BufferState {\n isBuffering: boolean;\n bufferedEndMs: number;\n}\n\ninterface AppState {\n // existing identity\n ready: boolean;\n name: string;\n sprite: string | null;\n // networking + protocol state\n me: MeState;\n snapshot: SnapshotState;\n roster: Record<string, RosterUser>; // keyed by clientId\n chat: ChatMessage[];\n chatCollapsed: boolean;\n /** RoomChrome's own collapse + width (roomChrome/chatLayout.ts). */\n chromeChatCollapsed: boolean;\n chromeChatWidth: number;\n floatingChatEnabled: boolean;\n drift: DriftState;\n snapshotReceivedAt: number; // client timestamp when playheadMs was last updated from server\n loadGeneration: number; // bumped on every load broadcast to force seek effect re-evaluation\n readiness: { mediaId: string | null; ready: boolean; readyCount: number; total: number };\n bufferState: BufferState;\n toasts: { id: string; kind: 'error' | 'info'; msg: string; ts: number }[];\n // media preferences\n mediaPrefs: MediaPrefsState;\n // admin health monitor\n healthSnapshot: THealthSnapshot | null;\n sidebarTab: 'chat' | 'health' | 'playlist';\n // buffer loading overlay (all clients)\n bufferStatus: TBufferStatus | null;\n // Autoplay muted bridge: true when the browser blocked AUDIBLE autoplay so\n // we started muted; cleared when sound resumes on the first interaction.\n // Drives a \"tap for sound\" hint. Transient — never persisted.\n mutedForAutoplay: boolean;\n // playlist mode (Phase 4)\n playlist: TPlaylistConfig | null;\n activePlaylistIndex: number | null;\n myVotes: Record<string, TVoteValue>; // mediaId -> vote\n voteProgress: TPlaylistVoteProgressOut['progress']; // leader: per-client progress\n playlistResults: TPlaylistResultsOut | null; // set after reveal\n // The GLOBAL tierlist. Not room state and not a playlist: one board the\n // whole site shares, fetched over HTTP and openable from anywhere.\n tierlistOpen: boolean;\n tierlist: TTierlistSnapshotOut | null;\n /** The board being edited right now — starts as the saved one, diverges as\n * you drag, and is what Save sends. Kept apart from `tierlist.myBoard` so\n * an incoming refresh (someone else saved) cannot overwrite your edits. */\n tierlistDraft: Partial<Record<TTier, string[]>>;\n tierlistSaving: boolean;\n // Ready-check (PR-6): leader-triggered \"are you ready?\" prompt. Set of\n // clientIds that have flagged ready. Cleared automatically by the server\n // on the next leader unpause.\n readyCheck: { active: boolean; ready: string[] };\n // What a room key entitles this client to, from the /api/rooms/validate\n // probe, tagged with the key it answers for. Lets the presenter-only toolbar\n // buttons render before the socket join resolves leadership — see\n // lib/leaderAffordance.ts. null = no probe has answered yet (or it failed).\n roomKeyType: ProbedRoomKeyType | null;\n // Waiting-room game: leader-selected room-wide active game. null = no\n // game; non-null = every client's idle screen auto-mounts the iframe.\n activeGameId: string | null;\n // The open game's settings, for the \"App preferences\" section of the\n // settings popout: draws them into an element and returns the cleanup\n // (gameBundleHost's `mountSettings`). null when no open game offers any.\n // Set by the overlay GameBundleSlot only.\n gameSettings: ((el: HTMLElement) => (() => void) | null) | null;\n // actions\n setReady: () => void;\n /** This connection's identity. Never saved: the character is chosen in the\n * fleet settings popout and lives in prefs-api (lib/avatar.ts), the name is\n * the key registry's, and anything else is what the server or an automatic\n * retry handed this connection. */\n setSessionIdentity: (name: string, sprite: string | null) => void;\n ensureAutoIdentity: () => void;\n ensureMe: (clientId: string) => void;\n applySnapshot: (snap: Partial<SnapshotState> & { users?: RosterUser[] }) => void;\n upsertUser: (u: RosterUser) => void;\n removeUser: (clientId: string) => void;\n appendChat: (m: ChatMessage) => void;\n setChat: (messages: ChatMessage[]) => void;\n toggleChatCollapsed: () => void;\n toggleChromeChatCollapsed: () => void;\n setChromeChatWidth: (px: number) => void;\n toggleFloatingChat: () => void;\n allocateClientSeq: () => number;\n // leaderId is `| null` on purpose: null means \"no leader\", a real state the\n // server broadcasts. Typing it `string | undefined` made that unrepresentable\n // (surfaced when the UI was first typechecked, 2026-08-17).\n updateControlState: (delta: { playheadMs?: number; playing?: boolean; serverSeq?: number; leaderId?: string | null }) => void;\n bumpLoadGeneration: () => void;\n addRttSample: (rtt: number, serverTime?: number, clientSendTs?: number) => void;\n pushToast: (kind: 'error' | 'info', msg: string) => void;\n popToast: (id: string) => void;\n setReadiness: (r: { mediaId: string | null; ready: boolean; readyCount: number; total: number }) => void;\n setBufferState: (state: Partial<BufferState>) => void;\n setMutedForAutoplay: (v: boolean) => void;\n // media pref actions\n setVolume: (volume: number) => void;\n setMuted: (muted: boolean) => void;\n setPreferredAudioLang: (lang: string) => void;\n setSubtitlesEnabled: (enabled: boolean) => void;\n setSubtitleStyle: (style: string) => void;\n // admin health monitor actions\n setHealthSnapshot: (snapshot: THealthSnapshot) => void;\n setSidebarTab: (tab: 'chat' | 'health' | 'playlist') => void;\n setBufferStatus: (status: TBufferStatus) => void;\n // playlist actions\n applyPlaylistState: (state: { playlist: TPlaylistConfig | null; activeIndex: number | null; myVotes: Record<string, TVoteValue> }) => void;\n setVoteProgress: (progress: TPlaylistVoteProgressOut['progress']) => void;\n setPlaylistResults: (results: TPlaylistResultsOut | null) => void;\n setTierlistOpen: (open: boolean) => void;\n setTierlistSnapshot: (snap: TTierlistSnapshotOut, adoptDraft: boolean) => void;\n setTierlistSaving: (saving: boolean) => void;\n moveTierlistItem: (itemId: string, tier: TTier | null, index?: number) => void;\n // Ready-check actions\n setReadyCheckState: (state: { active: boolean; ready: string[] }) => void;\n // Waiting-room game action — set on inbound game_active.\n setActiveGameId: (gameId: string | null) => void;\n setGameSettings: (mount: ((el: HTMLElement) => (() => void) | null) | null) => void;\n setRoomKeyType: (probed: ProbedRoomKeyType) => void;\n // Prefs rehydrate — called by entry.tsx after setAuth() resolves the\n // userId. The eager loadPreferences() at module-init below runs with\n // userId='public' (auth hasn't been wired yet), so for authed users it\n // reads the anonymous slot — which is typically empty after the first\n // migration, leaving every reload starting from defaults. rehydrate\n // re-reads from the correct user-keyed slot once auth is known.\n rehydrateFromPrefs: () => void;\n}\n\n// Load saved preferences at startup\nconst savedPrefs = loadPreferences();\n\nexport const useAppStore = create<AppState>((set, get) => ({\n ready: false,\n name: '',\n sprite: null,\n me: { clientId: null, nextClientSeq: 1 },\n snapshot: { roomId: null, mediaId: null, playheadMs: 0, playing: false, serverSeq: 0, leaderId: null },\n roster: {},\n chat: [],\n chatCollapsed: savedPrefs.chatCollapsed ?? false,\n chromeChatCollapsed: savedPrefs.chromeChatCollapsed ?? false,\n chromeChatWidth: clampChatWidth(savedPrefs.chromeChatWidth ?? CHAT_WIDTH_DEFAULT_PX),\n // Hardcoded on for everyone — the per-user toggle was removed from the\n // control bar; FloatingChat + toggleFloatingChat code is kept for future\n // re-enablement but the value is no longer user-controllable.\n floatingChatEnabled: true,\n drift: { rtts: [], offsetMs: 0 },\n snapshotReceivedAt: Date.now(),\n loadGeneration: 0,\n readiness: { mediaId: null, ready: false, readyCount: 0, total: 0 },\n bufferState: { isBuffering: false, bufferedEndMs: 0 },\n toasts: [],\n mediaPrefs: {\n volume: savedPrefs.volume ?? 1,\n muted: savedPrefs.muted ?? false,\n preferredAudioLang: savedPrefs.preferredAudioLang || 'jpn',\n subtitlesEnabled: savedPrefs.subtitlesEnabled ?? true,\n subtitleStyle: (['classic', 'cinema', 'minimal', 'contrast'].includes(savedPrefs.subtitleStyle as string)\n ? savedPrefs.subtitleStyle\n : 'classic') as string,\n },\n healthSnapshot: null,\n sidebarTab: 'chat' as const,\n bufferStatus: null,\n mutedForAutoplay: false,\n playlist: null,\n activePlaylistIndex: null,\n myVotes: {},\n voteProgress: [],\n playlistResults: null,\n tierlistOpen: false,\n tierlist: null,\n tierlistDraft: {},\n tierlistSaving: false,\n readyCheck: { active: false, ready: [] },\n activeGameId: null,\n gameSettings: null,\n roomKeyType: null,\n setReady: () => {\n debug('store', 'App ready');\n set({ ready: true });\n },\n setSessionIdentity: (name, sprite) => {\n debug('store', 'Session identity set (not persisted)', { name, sprite });\n set({ name, sprite });\n },\n ensureAutoIdentity: () => set((s) => {\n if (s.name) return {};\n // A requested character (prefs-api, lib/avatar.ts) names the identity\n // rather than being overwritten by an auto-assignment.\n if (s.sprite) {\n const name = displayNameFor(s.sprite);\n debug('store', 'Auto-identity: deriving name from the requested sprite', { name, sprite: s.sprite });\n return { name };\n }\n // No requested sprite — pick the first available from the current roster.\n const usedSprites = new Set(Object.values(s.roster).map(r => r.sprite).filter(Boolean));\n debug('store', 'ensureAutoIdentity checking', {\n rosterSize: Object.keys(s.roster).length,\n usedSprites: Array.from(usedSprites)\n });\n for (const c of CHARACTER_ORDER) {\n if (!usedSprites.has(c)) {\n const name = displayNameFor(c);\n debug('store', 'Auto-identity assigned', { name, sprite: c });\n return { name, sprite: c };\n }\n }\n // All characters taken — leave identity unset; the server error handler\n // will retry with the next available once we get a sprite_taken response.\n warn('store', 'All characters taken, leaving identity unset');\n return {};\n }),\n ensureMe: (clientId) => {\n debug('store', 'Client ID set', { clientId });\n set((s) => ({ me: { ...s.me, clientId } }));\n },\n applySnapshot: (snap) => set((s) => {\n const rosterUpdates: Record<string, RosterUser> = { ...s.roster };\n if (snap.users) {\n for (const u of snap.users) rosterUpdates[u.clientId] = u;\n }\n // Use !== undefined (not ??) so that explicit null values (e.g. mediaId: null from 'home')\n // are applied correctly instead of falling back to the previous value\n const mediaId = snap.mediaId !== undefined ? snap.mediaId : s.snapshot.mediaId;\n const leaderId = snap.leaderId !== undefined ? snap.leaderId : s.snapshot.leaderId;\n debug('store', 'Snapshot applied', {\n mediaId,\n playing: snap.playing ?? s.snapshot.playing,\n serverSeq: snap.serverSeq ?? s.snapshot.serverSeq,\n userCount: snap.users?.length ?? 0\n });\n const newPlayheadMs = snap.playheadMs ?? s.snapshot.playheadMs;\n return {\n snapshot: {\n roomId: snap.roomId !== undefined ? snap.roomId : s.snapshot.roomId,\n mediaId,\n playheadMs: newPlayheadMs,\n playing: snap.playing ?? s.snapshot.playing,\n serverSeq: snap.serverSeq ?? s.snapshot.serverSeq,\n leaderId\n },\n roster: rosterUpdates,\n // Always update — initial snapshot on join often has playheadMs=0\n // matching the store default, so the old playheadChanged guard left\n // snapshotReceivedAt at page-load time. This inflated viewer correction\n // elapsed by browser launch + buffer gate duration, causing 60-115s drift.\n snapshotReceivedAt: Date.now(),\n };\n }),\n upsertUser: (u) => {\n debug('store', 'User upserted', { clientId: u.clientId, name: u.name, isLeader: u.isLeader });\n set((s) => ({ roster: { ...s.roster, [u.clientId]: u } }));\n },\n removeUser: (clientId) => {\n debug('store', 'User removed', { clientId });\n set((s) => {\n const copy = { ...s.roster }; delete copy[clientId]; return { roster: copy };\n });\n },\n appendChat: (m) => {\n debug('chat', 'Message appended', { id: m.id, from: m.name, isSystem: m.isSystem });\n set((s) => ({ chat: [...s.chat, m].slice(-DEFAULT_CHAT_UI_BUFFER) }));\n },\n setChat: (messages) => set({ chat: messages.slice(-DEFAULT_CHAT_UI_BUFFER) }),\n toggleChatCollapsed: () => set((s) => {\n const newVal = !s.chatCollapsed;\n savePref('chatCollapsed', newVal);\n return { chatCollapsed: newVal };\n }),\n toggleChromeChatCollapsed: () => set((s) => {\n const newVal = !s.chromeChatCollapsed;\n savePref('chromeChatCollapsed', newVal);\n return { chromeChatCollapsed: newVal };\n }),\n setChromeChatWidth: (px) => set(() => {\n const width = clampChatWidth(px);\n savePref('chromeChatWidth', width);\n return { chromeChatWidth: width };\n }),\n toggleFloatingChat: () => set((s) => {\n const newVal = !s.floatingChatEnabled;\n savePref('floatingChatEnabled', newVal);\n return { floatingChatEnabled: newVal };\n }),\n allocateClientSeq: () => {\n const n = get().me.nextClientSeq;\n set((s) => ({ me: { ...s.me, nextClientSeq: n + 1 } }));\n return n;\n },\n bumpLoadGeneration: () => set((s) => ({ loadGeneration: s.loadGeneration + 1 })),\n updateControlState: (delta) => {\n debug('store', 'updateControlState called', delta);\n set((s) => ({\n snapshot: { ...s.snapshot, ...delta },\n // Always update snapshotReceivedAt — any control_broadcast from the\n // server resets the elapsed-time calculation. The old condition\n // (playheadMs !== s.snapshot.playheadMs) missed media reloads that\n // set playheadMs=0 when it was already 0, causing prefetch to use\n // wall-clock-since-page-load as the expected position.\n snapshotReceivedAt: Date.now(),\n }));\n },\n addRttSample: (rtt, serverTime, clientSendTs) => set((s) => {\n const rtts = [...s.drift.rtts, rtt].slice(-5);\n let offsetMs = s.drift.offsetMs;\n if (serverTime && clientSendTs) {\n const clientMid = clientSendTs + rtt / 2;\n offsetMs = serverTime - clientMid;\n }\n return { drift: { rtts, offsetMs } };\n })\n ,\n pushToast: (kind, msg) => {\n debug('store', 'Toast pushed', { kind, msg });\n set((s) => ({ toasts: [...s.toasts, { id: Math.random().toString(36).slice(2), kind, msg, ts: Date.now() }].slice(-5) }));\n },\n popToast: (id) => set((s) => ({ toasts: s.toasts.filter(t => t.id !== id) })),\n setReadiness: (r) => set({ readiness: { mediaId: r.mediaId, ready: r.ready, readyCount: r.readyCount, total: r.total } }),\n setBufferState: (state) => set((s) => ({ bufferState: { ...s.bufferState, ...state } })),\n setMutedForAutoplay: (v) => set({ mutedForAutoplay: v }),\n // Media preference actions\n setVolume: (volume) => set((s) => {\n savePref('volume', volume);\n return { mediaPrefs: { ...s.mediaPrefs, volume } };\n }),\n setMuted: (muted) => set((s) => {\n savePref('muted', muted);\n return { mediaPrefs: { ...s.mediaPrefs, muted } };\n }),\n setPreferredAudioLang: (lang) => set((s) => {\n savePref('preferredAudioLang', lang);\n return { mediaPrefs: { ...s.mediaPrefs, preferredAudioLang: lang } };\n }),\n setSubtitlesEnabled: (enabled) => set((s) => {\n savePref('subtitlesEnabled', enabled);\n return { mediaPrefs: { ...s.mediaPrefs, subtitlesEnabled: enabled } };\n }),\n setSubtitleStyle: (style) => set((s) => {\n savePref('subtitleStyle', style);\n return { mediaPrefs: { ...s.mediaPrefs, subtitleStyle: style } };\n }),\n setHealthSnapshot: (snapshot) => set({ healthSnapshot: snapshot }),\n setBufferStatus: (status) => set({ bufferStatus: status }),\n applyPlaylistState: (state) => set({\n playlist: state.playlist,\n activePlaylistIndex: state.activeIndex,\n myVotes: state.myVotes,\n // Clear stale results when a new playlist state arrives\n playlistResults: null,\n }),\n setVoteProgress: (progress) => set({ voteProgress: progress }),\n setPlaylistResults: (results) => set({ playlistResults: results }),\n\n setTierlistOpen: (open) => set({ tierlistOpen: open }),\n setTierlistSaving: (saving) => set({ tierlistSaving: saving }),\n /**\n * Apply a server snapshot.\n *\n * `adoptDraft` is false for a refresh triggered by SOMEONE ELSE saving: the\n * shared result must update live, but replacing the draft would yank cards\n * out from under someone mid-drag. It is true only when this client opens the\n * board or saves its own, which are the two moments its draft is not ahead of\n * the server.\n */\n setTierlistSnapshot: (snap, adoptDraft) => set(prev => ({\n tierlist: snap,\n tierlistDraft: adoptDraft ? { ...snap.myBoard } : prev.tierlistDraft,\n })),\n /**\n * Move one item on the draft board. Local only — nothing reaches the server\n * until Save, which is the whole point of the Save button.\n *\n * Remove from every row FIRST, so a move within a row reorders rather than\n * duplicating, then insert at `index`. Absent from every row — not\n * present-with-a-low-value — is what keeps \"not rated\" distinguishable from\n * \"rated F\" in placeTierlist.\n */\n moveTierlistItem: (itemId, tier, index) => set(state => {\n const next: Partial<Record<TTier, string[]>> = {};\n for (const [t, row] of Object.entries(state.tierlistDraft)) {\n const without = (row ?? []).filter(id => id !== itemId);\n if (without.length > 0) next[t as TTier] = without;\n }\n if (tier !== null) {\n const row = next[tier] ?? [];\n const at = Math.max(0, Math.min(index ?? row.length, row.length));\n row.splice(at, 0, itemId);\n next[tier] = row;\n }\n return { tierlistDraft: next };\n }),\n setReadyCheckState: (state) => set({ readyCheck: state }),\n setActiveGameId: (gameId) => set({ activeGameId: gameId }),\n setGameSettings: (mount) => set({ gameSettings: mount }),\n setRoomKeyType: (probed) => set({ roomKeyType: probed }),\n rehydrateFromPrefs: () => set((s) => {\n // Re-read from localStorage. Triggered both from entry.tsx after\n // setAuth (boot path) AND from the SDK subscribe callback in\n // preferences.ts (when SDK cache changes — saves, cross-tab events,\n // background server refresh).\n //\n // ONLY write fields that are PRESENT in prefs. Older logic always\n // wrote all fields and used `|| default` for absent ones — that\n // reverted in-flight user actions any time the SDK had partial data.\n // With the present-only check, fields absent from prefs simply leave the\n // store value alone.\n //\n // The prefs row's `sprite` is NOT read: the character comes from\n // prefs-api's avatar settings (lib/avatar.ts), which already fall back to\n // that field for someone who never chose at the top level.\n const prefs = loadPreferences();\n const next: Partial<AppState> = {};\n if (prefs.chatCollapsed !== undefined) next.chatCollapsed = prefs.chatCollapsed;\n if (prefs.chromeChatCollapsed !== undefined) next.chromeChatCollapsed = prefs.chromeChatCollapsed;\n if (prefs.chromeChatWidth !== undefined) next.chromeChatWidth = clampChatWidth(prefs.chromeChatWidth);\n // floatingChatEnabled is hardcoded on (toggle removed from UI); never rehydrate.\n\n // Media prefs: only build the nested object if AT LEAST ONE media key\n // is present in prefs — otherwise we'd write a whole mediaPrefs object\n // with defaults, undoing in-memory tweaks the user made without saving.\n const mediaKeys = ['volume', 'muted', 'preferredAudioLang', 'subtitlesEnabled', 'subtitleStyle'] as const;\n const hasMedia = mediaKeys.some(k => prefs[k] !== undefined);\n if (hasMedia) {\n const m = { ...s.mediaPrefs };\n if (prefs.volume !== undefined) m.volume = prefs.volume;\n if (prefs.muted !== undefined) m.muted = prefs.muted;\n if (prefs.preferredAudioLang !== undefined) m.preferredAudioLang = prefs.preferredAudioLang;\n if (prefs.subtitlesEnabled !== undefined) m.subtitlesEnabled = prefs.subtitlesEnabled;\n if (prefs.subtitleStyle !== undefined && ['classic', 'cinema', 'minimal', 'contrast'].includes(prefs.subtitleStyle)) {\n m.subtitleStyle = prefs.subtitleStyle;\n }\n next.mediaPrefs = m;\n }\n return next;\n }),\n setSidebarTab: (tab) => set((s) => {\n // If switching to the same tab, toggle back to chat\n // Also uncollapse the sidebar if it's collapsed\n if (s.sidebarTab === tab && tab !== 'chat') {\n return { sidebarTab: 'chat' };\n }\n return { sidebarTab: tab, chatCollapsed: false };\n }),\n}));\n\n// E2E handle — published only when the host opts in (lib/debugGlobals.ts).\nregisterDebugGlobal('__ZUSTAND_STORE__', useAppStore);\n","import { FRAGMENT_MP4_FILENAME } from '@watchparty/shared';\nimport { getRuntimeServerOrigin } from './config';\n\n/** Derive the fMP4 and index URLs for a given mediaId. Single source of truth used by Room and usePlaylistPreBuffer. */\nexport function resolveMediaUrls(mediaId: string): { mediaUrl: string; indexUrl: string } {\n const origin = getRuntimeServerOrigin();\n const root = `${origin}/media/${mediaId}`.replace(/\\\\/g, '/');\n return { mediaUrl: `${root}/${FRAGMENT_MP4_FILENAME}`, indexUrl: `${root}/index.json` };\n}\n\n/** Resolve a media-relative asset path (e.g. a playlist poster/cover) to a full URL. */\nexport function resolveMediaAsset(relPath: string): string {\n const origin = getRuntimeServerOrigin();\n return `${origin}/media/${relPath}`.replace(/\\\\/g, '/');\n}\n\n/**\n * The card image for one playlist/tierlist entry.\n *\n * An explicit `thumbnail` always wins — that is what flat-layout ingest writes\n * into playlist.json, and it may point anywhere. Everything else falls back to\n * `<mediaId>/poster.jpg`, which is the SAME convention that ingest's\n * `copy_poster` already uses; `scripts/fetch-posters.py` fills it in for the\n * entries ingest never covered (subfolder-layout clips, and anime SERIES, which\n * have no video of their own to grab a frame from).\n *\n * Deriving it by convention rather than writing paths into every playlist.json\n * means a poster appears the moment the file exists — no manifest to re-emit,\n * and no migration for the playlists that already ship a thumbnail. A missing\n * file is a 404 the caller hides, which is exactly the blank tile it replaced.\n */\nexport function resolveCardImage(mediaId: string, thumbnail?: string): string {\n return resolveMediaAsset(thumbnail ?? `${mediaId}/poster.jpg`);\n}\n","/**\n * Client for the global tierlist.\n *\n * HTTP, not socket events: the board is not room state, so it must be readable\n * from anywhere — including before a room socket exists. The only socket\n * involvement is the `tierlist_updated` ping that tells this client to re-fetch\n * (see `wireTierlist` in socket.ts).\n */\nimport { getRuntimeServerOrigin } from './config';\nimport { resolveCardImage } from './mediaUrls';\nimport { useAppStore } from './store';\nimport { debug, warn, errorFields } from './debug';\nimport type { TTierlistSnapshotOut, TTier } from '@watchparty/shared';\n\nfunction url(path: string): string {\n return `${getRuntimeServerOrigin()}${path}`.replace(/\\\\/g, '/');\n}\n\n/**\n * Pull the board.\n *\n * `adoptDraft` decides whether the fetched `myBoard` replaces what is on screen.\n * True when this client opens the board or saves its own; false for a refresh\n * caused by someone ELSE saving — their result must land live, but overwriting\n * a draft mid-drag would move cards under the user's hands.\n */\nexport async function refreshTierlist(adoptDraft: boolean): Promise<TTierlistSnapshotOut | null> {\n try {\n const res = await fetch(url('/api/tierlist'));\n if (!res.ok) {\n warn('tierlist', 'Board fetch failed', { status: res.status });\n return null;\n }\n const snap = (await res.json()) as TTierlistSnapshotOut;\n useAppStore.getState().setTierlistSnapshot(snap, adoptDraft);\n debug('tierlist', 'Board loaded', {\n items: snap.items.length, voters: snap.voterCount, placed: snap.placed.length,\n });\n return snap;\n } catch (err: unknown) {\n warn('tierlist', 'Board fetch threw', errorFields(err));\n return null;\n }\n}\n\n/**\n * Pull the poster images into the browser cache.\n *\n * Called on INTENT — hovering or focusing the toolbar button — not on mount.\n * The board is ~7 images of ~50KB; fetching them for everyone who never opens\n * it would put ~400KB in front of the media fetches during a load, and this\n * codebase has a long history of exactly that kind of opportunistic prefetch\n * starving playback (see usePlaylistPreBuffer). Intent-triggered costs nothing\n * until someone reaches for it, and by the time the click lands the images are\n * already cached, so the board opens populated instead of filling in.\n *\n * Fire-and-forget and idempotent: the browser dedupes in-flight requests for\n * the same URL, so repeated hovers cost nothing.\n */\nlet warmed = false;\nexport function warmTierlistPosters(): void {\n if (warmed || typeof Image === 'undefined') return;\n const snap = useAppStore.getState().tierlist;\n if (!snap || snap.items.length === 0) return; // nothing known yet; the mount fetch will land first\n warmed = true;\n for (const item of snap.items) {\n const img = new Image();\n img.decoding = 'async';\n img.src = resolveCardImage(item.id, item.thumbnail);\n }\n debug('tierlist', 'Warming posters', { count: snap.items.length });\n}\n\n/**\n * Publish this client's draft.\n *\n * The response is the freshly-recomputed board, so the saver sees their own\n * effect without waiting for the broadcast to come back around. Everyone else\n * is updated by the `tierlist_updated` ping the server emits.\n */\nexport async function saveTierlist(\n board: Partial<Record<TTier, string[]>>,\n): Promise<boolean> {\n const store = useAppStore.getState();\n store.setTierlistSaving(true);\n try {\n // Only the board: the server names a voter by their account and draws their\n // character from prefs-api, so nothing about the person travels with it.\n const res = await fetch(url('/api/tierlist/me'), {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ board }),\n });\n if (!res.ok) {\n warn('tierlist', 'Save rejected', { status: res.status });\n return false;\n }\n const snap = (await res.json()) as TTierlistSnapshotOut;\n useAppStore.getState().setTierlistSnapshot(snap, true);\n return true;\n } catch (err: unknown) {\n warn('tierlist', 'Save threw', errorFields(err));\n return false;\n } finally {\n useAppStore.getState().setTierlistSaving(false);\n }\n}\n","import { CHARACTER_ORDER, displayNameFor, isCharacterName } from './characters';\nimport { useAppStore } from './store';\nimport { debug, warn } from './debug';\n\n/**\n * The server refused our sprite (`conflict / sprite_taken`): take the next free\n * character and ask for it.\n *\n * Two rules, both because this retry is AUTOMATIC, not a choice the person made:\n * - a custom name is kept. Only a missing or character-default name takes the\n * new character's default;\n * - the result is NOT saved. This used to write `user`-scope prefs, so one\n * retry on a second browser rewrote the account's saved name and sprite\n * everywhere. Nothing in watchparty saves an identity now: the character is\n * chosen in the fleet settings popout (lib/avatar.ts).\n *\n * Returns the payload to emit, or null when no character is free.\n */\nexport function retryTakenSprite(): { name: string; sprite: string } | null {\n const st = useAppStore.getState();\n const used = new Set(Object.values(st.roster).map(r => r.sprite).filter(Boolean));\n const free = CHARACTER_ORDER.find(c => !used.has(c));\n if (!free) {\n warn('socket', 'sprite_taken: no available sprites to retry with');\n return null;\n }\n const keepName = !!st.name && !isCharacterName(st.name);\n const name = keepName ? st.name : displayNameFor(free);\n debug('socket', 'sprite_taken: retrying with next available', { from: st.sprite, to: free, name, keptCustomName: keepName });\n st.setSessionIdentity(name, free);\n return { name, sprite: free };\n}\n\n/**\n * Whether a `sprite_taken` refusal is already settled, so retrying would only\n * ask for a sprite nobody needs: there is no seat for us in the roster yet\n * (the first presence brings the server's pick), or the seat's sprite is the\n * one we hold.\n */\nexport function isStaleSpriteRefusal(myClientId: string | undefined): boolean {\n const st = useAppStore.getState();\n const seat = myClientId ? st.roster[myClientId] : undefined;\n return !seat || (!!seat.sprite && seat.sprite === st.sprite);\n}\n","/**\n * The person's avatar, as hadoku_site's prefs-api resolves it.\n *\n * A person picks their CHARACTER and their PICTURE (Discord, Steam, an upload,\n * or the character itself) at the top level, in the fleet settings popout\n * (`ConnectedSettings` → `AvatarPicker`, @wolffm/task-ui-components). Watchparty\n * has no picker of its own and no longer reads or writes the `sprite` field of\n * its prefs row: prefs-api falls back to that field for someone who never chose\n * at the top level, so it stays in the schema, but only prefs-api reads it.\n *\n * - The character is REQUESTED at join (`fetchRequestedSprite`, awaited by the\n * pre-join wait) and live whenever the popout changes it (`AVATAR_EVENT`).\n * The room still decides: a taken character falls back as it always has.\n * - The picture is never read here. The server resolves each participant's by\n * registry userId and sends it in presence (`avatar_url`, apps/server\n * `rooms/avatars.ts`), so a person sees everyone's, not only their own.\n */\nimport { createAvatarClient, AvatarError, type AvatarSettings } from '@wolffm/prefs-client/avatar';\nimport { useAppStore } from './store';\nimport { displayNameFor, isCharacterId, isCharacterName } from './characters';\nimport { debug, warn, errorFields } from './debug';\n\n/**\n * The settings popout's window event, `detail: AvatarSettings`, fired after\n * every change the person makes. @wolffm/task-ui-components exports it as\n * `AVATAR_EVENT` from 4.22.3, which is not on the registry yet (4.22.1 is\n * installed) — and the room chrome must never import that package anyway (a\n * game host need not install it), so the value is spelled here.\n */\nexport const AVATAR_EVENT = 'hadoku:avatar';\n\n/** The character a person asked for: their top-level pick, else the one\n * prefs-api resolved (it falls back to the old watchparty pref). */\nexport function requestedSpriteOf(settings: Pick<AvatarSettings, 'sprite' | 'avatar'> | null | undefined): string | null {\n const id = settings?.sprite ?? settings?.avatar?.sprite ?? null;\n return isCharacterId(id) ? id : null;\n}\n\n/**\n * The signed-in person's requested character, or null. Never throws.\n *\n * Normal answers, logged at debug: a signed-out visitor's 401/403 (they keep\n * today's auto-assign), and a 404 — an origin with no prefs-api mounted, which\n * is the local dev/E2E server and any non-hadoku host of the room chrome.\n * Anything else is a fault worth a warn, and still resolves null: no answer\n * from prefs-api must never block or break a join.\n */\nexport async function fetchRequestedSprite(opts: { baseUrl?: string; fetch?: typeof fetch } = {}): Promise<string | null> {\n const f = opts.fetch ?? (typeof fetch === 'undefined' ? undefined : fetch);\n const origin = typeof window === 'undefined' ? undefined : window.location?.origin;\n const baseUrl = opts.baseUrl ?? (origin ? `${origin}/prefs` : undefined);\n if (!f || !baseUrl) return null;\n try {\n const settings = await createAvatarClient({ baseUrl, fetch: f }).get();\n const sprite = requestedSpriteOf(settings);\n debug('avatar', 'Requested character from prefs-api', { sprite });\n return sprite;\n } catch (err: unknown) {\n if (err instanceof AvatarError && (err.status === 401 || err.status === 403 || err.status === 404)) {\n debug('avatar', 'No avatar settings here (signed out, or no prefs-api on this origin)', { status: err.status });\n } else {\n warn('avatar', 'Avatar settings read failed — keeping the auto-assigned character', {\n status: err instanceof AvatarError ? err.status : undefined, ...errorFields(err),\n });\n }\n return null;\n }\n}\n\n/**\n * Make `sprite` the identity this tab asks for. A SESSION identity: nothing is\n * saved — the choice lives in prefs-api. The name follows the character only\n * when it is a character name; a registry name or a host-offered one stays.\n * Returns whether anything changed.\n */\nexport function adoptRequestedSprite(sprite: string | null): boolean {\n if (!sprite) return false;\n const st = useAppStore.getState();\n if (st.sprite === sprite) return false;\n const registryNamed = !!(st.me.clientId && st.roster[st.me.clientId]?.registryName);\n // An empty name stays empty, so ensureAutoIdentity derives it at join.\n const name = registryNamed || !st.name || !isCharacterName(st.name) ? st.name : displayNameFor(sprite);\n st.setSessionIdentity(name, sprite);\n return true;\n}\n\n/** Call `onChange` for every settings-popout avatar change; returns the unsubscribe.\n * A no-op where there is no window (the chrome mounts under jsdom/happy-dom\n * in consumers' tests, and the UI's own tests run in node). */\nexport function listenForAvatarChanges(\n onChange: (settings: AvatarSettings) => void,\n target: EventTarget | undefined = typeof window === 'undefined' ? undefined : window,\n): () => void {\n if (!target) return () => {};\n const handler = (e: Event) => {\n const detail = (e as CustomEvent<AvatarSettings | undefined>).detail;\n if (!detail || typeof detail !== 'object') return;\n onChange(detail);\n };\n target.addEventListener(AVATAR_EVENT, handler);\n return () => target.removeEventListener(AVATAR_EVENT, handler);\n}\n","/**\n * How is this app framed?\n *\n * Watchparty is reachable two ways that are INDISTINGUISHABLE in every log we\n * currently write:\n *\n * 1. Direct — `https://hadoku.me/watchparty/...` as a top-level document.\n * 2. From the hadoku.me home page — the shell mounts a scene iframe, and the\n * scene mounts watchparty in a second iframe inside it (depth 2).\n *\n * `client_url` is read from the app's OWN window, so it is byte-identical in\n * both cases (`https://hadoku.me/watchparty/room/...`). During the 2026-08-04\n * investigation that made \"did this viewer enter from the home page?\"\n * unfalsifiable from logs — the hypothesis could be neither confirmed nor\n * refuted, only argued about. This closes that gap: `framed`/`depth` go out\n * with `join` and land in the `session_open` record.\n *\n * Cross-origin safe: reading `window.parent`/`window.top` and comparing\n * WindowProxy identity is permitted across origins (only reaching *into* their\n * document is blocked). The walk is still wrapped defensively — a telemetry\n * field must never be able to break a join.\n */\n\nexport interface EmbedContext {\n /** True when this document is not the top-level window. */\n framed: boolean;\n /** Nesting depth: 0 = top-level, 1 = one iframe, 2 = the home-page path. */\n depth: number;\n}\n\n/** Hard cap on the parent walk, so a malformed/cyclic frame tree can't hang join. */\nconst MAX_FRAME_DEPTH = 10;\n\nexport function describeEmbedding(win: Window | undefined = typeof window !== 'undefined' ? window : undefined): EmbedContext {\n if (!win) return { framed: false, depth: 0 };\n try {\n let depth = 0;\n let w: Window = win;\n while (w !== w.parent && depth < MAX_FRAME_DEPTH) {\n depth++;\n w = w.parent;\n }\n // `top` can read as null in exotic/detached cases; fall back to the walk.\n const framed = win.top ? win.top !== win.self : depth > 0;\n return { framed, depth };\n } catch {\n // Never let telemetry break the join path.\n return { framed: false, depth: 0 };\n }\n}\n","/**\n * Edge-cacheable fragment delivery — URL construction and the enable decision.\n *\n * Two ways to fetch the same bytes:\n *\n * range GET <base>/output_frag.mp4 Range: bytes=S-E -> 206\n * fragment GET <base>/v/<token>/frag/<n> (no Range header) -> 200\n *\n * The fragment form exists so Cloudflare can cache a ~1MB object instead of failing to cache a\n * 700-800MB one (and dropping the Range on every fill — measured in prod 2026-08-05). The bytes\n * are identical; only the addressing differs. Server side is `media/fragmentRoute.ts`, and\n * `fragment-route.realmedia.test.ts` proves byte-identity against the real library.\n *\n * This module is deliberately pure. Getting a URL wrong here does not throw — it fetches a\n * *valid-looking* wrong range and appends plausible garbage to a SourceBuffer, which is\n * undetectable client-side and surfaces later as the corrupt-fragment / structural-gap failures\n * this codebase has spent months on. So the URL math lives in one tested place rather than\n * inline in FragmentManager.\n *\n * DEFAULT OFF. See `isFragmentDeliveryEnabled`.\n */\n\nimport {\n resolveDeliveryArm, normalizeFragmentDeliveryFlag, DEFAULT_FRAGMENT_DELIVERY_FLAG,\n type FragmentDeliveryFlag as FragmentDeliveryFlagT, type DeliveryArm,\n} from '@watchparty/shared';\nimport { debug, warn } from './debug';\nimport { readStored, writeStored, readStoredFlag } from './browserStorage';\n\nexport const FRAGMENT_DELIVERY_FLAG = 'watchparty_fragment_delivery';\n\n/** Everything needed to address one FragmentManager's media via the fragment route. */\nexport interface DeliveryConfig {\n /** Leaf base, no trailing slash: `<origin>/media/<mediaId>` */\n baseUrl: string;\n /** Full 64-hex SHA-256 of output_frag.mp4, from index.json. */\n token: string;\n /** Present for audio FragmentManagers only; identifies the track in the index. */\n trackId?: string;\n}\n\n/** `init`, or a zero-based fragment index. */\nexport type DeliveryTarget = 'init' | number;\n\nconst TOKEN_RE = /^[0-9a-f]{64}$/;\nconst TRACK_ID_RE = /^[A-Za-z0-9_.-]+$/;\n\n/**\n * Is the config usable for fragment-route requests?\n *\n * Mirrors the server's route regex (`fragmentRoute.ts` ROUTE / resolveTarget). A config that\n * fails here can only ever produce 404s, so we keep using Range instead — never a broken URL.\n */\nexport function isDeliveryConfigValid(cfg: DeliveryConfig | null | undefined): cfg is DeliveryConfig {\n if (!cfg) return false;\n if (!cfg.baseUrl || cfg.baseUrl.endsWith('/')) return false;\n // Full digest only. A truncated token would reintroduce a collision path on the one value\n // whose wrongness means a YEAR of an edge serving wrong bytes under `immutable`.\n if (!TOKEN_RE.test(cfg.token)) return false;\n if (cfg.trackId !== undefined && !TRACK_ID_RE.test(cfg.trackId)) return false;\n return true;\n}\n\n/**\n * Build the fragment-route URL, or null when this config/target cannot be addressed.\n *\n * Null is always safe: the caller falls back to a Range request for the same bytes.\n */\nexport function buildFragmentUrl(cfg: DeliveryConfig | null | undefined, target: DeliveryTarget): string | null {\n if (!isDeliveryConfigValid(cfg)) return null;\n let tail: string;\n if (target === 'init') {\n tail = 'init';\n } else {\n // Fragment indices are array positions in index.json. A non-integer or negative index would\n // 404 server-side, but building it at all means a bug upstream — refuse instead.\n if (!Number.isInteger(target) || target < 0) return null;\n tail = `frag/${target}`;\n }\n const prefix = cfg.trackId ? `audio/${cfg.trackId}/` : '';\n return `${cfg.baseUrl}/v/${cfg.token}/${prefix}${tail}`;\n}\n\n/** Local override from `?fragmentDelivery=0|1`, or null when the URL says nothing. */\nexport function readDeliveryOverride(): boolean | null {\n if (typeof window === 'undefined') return null;\n try {\n const q = new URLSearchParams(window.location.search).get('fragmentDelivery');\n if (q === '1' || q === 'true') return true;\n if (q === '0' || q === 'false') return false;\n } catch { /* no location */ }\n return readStoredFlag(FRAGMENT_DELIVERY_FLAG);\n}\n\n// ── Server-controlled rollout ────────────────────────────────────────────────\n//\n// The operator flips this from `POST /api/flags/fragment-delivery` (service-tier) with no\n// deploy, and can run `ab` to split one room between both delivery paths at once. See\n// packages/shared/src/fragmentDeliveryFlag.ts for why the split is per-client and deterministic.\n//\n// Until the flag is fetched we resolve OFF. That is the safe direction: a client that starts\n// before the answer arrives uses the delivery path that has always worked.\n\nlet serverFlag: FragmentDeliveryFlagT = { ...DEFAULT_FRAGMENT_DELIVERY_FLAG };\nlet resolvedArm: DeliveryArm | null = null;\n/** True once something that ACTS on the arm has read it (i.e. FragmentManager). */\nlet armPinned = false;\n\nexport const DELIVERY_CLIENT_ID_KEY = 'watchparty_delivery_client_id';\n\n/**\n * Stable per-browser id used ONLY for A/B bucketing.\n *\n * Deliberately not the socket id. That is what `me.clientId` holds, and it is reassigned on\n * every reconnect — bucketing on it would let a viewer flip delivery paths mid-party on a\n * transient disconnect, which both breaks the \"never change path mid-episode\" invariant and\n * turns the A/B comparison into noise (the same person contributing samples to both arms).\n * It is also unavailable at join time, when the arm has to be reported.\n *\n * Persisted so a viewer stays in one arm across reloads and across sessions, which is what\n * makes a percentage rollout mean anything. Not an identity or a tracking id: it is a random\n * value with no server-side meaning, read by nothing except this bucketing.\n */\nexport function deliveryClientId(): string {\n const existing = readStored(DELIVERY_CLIENT_ID_KEY);\n if (existing) return existing;\n const fresh = (globalThis.crypto?.randomUUID?.() ?? `d${Math.random().toString(36).slice(2)}`);\n // An id we could not persist is worse than none: it would put this client in\n // a DIFFERENT arm on every reload, feeding samples to both. Empty id =>\n // resolveDeliveryArm keeps it out of the experiment entirely, which is the\n // safe direction.\n return writeStored(DELIVERY_CLIENT_ID_KEY, fresh) ? fresh : '';\n}\n\n/**\n * Store the flag fetched from the server.\n *\n * Clears a provisional arm, but never a PINNED one.\n *\n * The distinction is the whole reason this exists. `/api/flags` is fetched during app boot and\n * lands after the socket join, so an arm resolved at join time is decided against the default\n * (off) — which on 2026-08-08 made an `ab`/50 rollout produce 0 of 5 clients in the fragment\n * arm. Every client had pinned `range` milliseconds before learning the flag. So a resolution\n * made purely for reporting stays provisional and is recomputed when the flag arrives.\n *\n * Once FragmentManager has actually read the arm (isFragmentDeliveryEnabled), it is pinned and\n * a later flag change cannot move it — that upholds the invariant that a client never switches\n * delivery path mid-episode. Media loads seconds after boot, long after the flag lands, so in\n * practice the pin happens on the correct value.\n */\nexport function setServerDeliveryFlag(raw: unknown): void {\n serverFlag = normalizeFragmentDeliveryFlag(raw);\n if (!armPinned) resolvedArm = null;\n}\n\nexport function getServerDeliveryFlag(): FragmentDeliveryFlagT {\n return { ...serverFlag };\n}\n\n/**\n * Which arm is this client in? Memoised for the session.\n *\n * Memoisation is load-bearing, not an optimisation: an in-flight media load must not change\n * delivery path halfway through because an operator moved the flag. The arm is fixed for the\n * session and a change takes effect on the next page load.\n */\nexport function resolveSessionArm(): DeliveryArm {\n if (resolvedArm === null) {\n resolvedArm = resolveDeliveryArm(serverFlag, deliveryClientId(), readDeliveryOverride());\n }\n return resolvedArm;\n}\n\n/**\n * Fetch the rollout flag. Idempotent, safe to call more than once, never throws.\n *\n * Deliberately NOT tied to any React effect's AbortController. It was, chained onto the\n * app's health-check effect, and `if (controller.signal.aborted) return` then discarded the\n * flag whenever that effect cleaned up before the response landed — a pure race. Two\n * consecutive 8-viewer DO runs on 2026-08-08 disagreed completely because of it: one put 5 of\n * 7 clients in the fragment arm, the next 0 of 6, with the same flag and the same build.\n *\n * A rollout flag is not tied to a component's lifetime. It is a tiny idempotent GET whose only\n * job is to land before media loads (seconds later), so it gets one retry and no cancellation.\n */\nlet flagFetchStarted = false;\nexport async function loadServerDeliveryFlag(origin: string): Promise<void> {\n if (flagFetchStarted) return;\n flagFetchStarted = true;\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n const res = await fetch(`${origin}/api/flags`, { cache: 'no-store' });\n if (res.ok) {\n const body = await res.json();\n setServerDeliveryFlag(body?.fragment_delivery);\n debug('app', 'Delivery flag loaded', { ...getServerDeliveryFlag(), attempt });\n return;\n }\n } catch { /* fall through to the retry */ }\n if (attempt === 0) await new Promise((r) => setTimeout(r, 500));\n }\n // Both attempts failed — stay on the default (range), which is the path that always works.\n warn('app', 'Delivery flag fetch failed; staying on the default delivery path');\n}\n\n/** Test seam — drop the memoised arm. */\nexport function resetSessionArm(): void { resolvedArm = null; armPinned = false; flagFetchStarted = false; }\n\n/**\n * The arm for this session, for telemetry. Safe to call at join time — the bucketing id is a\n * persisted per-browser value, not the socket id, so it exists before the socket does.\n */\nexport function currentDeliveryArm(): DeliveryArm {\n return resolveSessionArm(); // provisional until something pins it — see setServerDeliveryFlag\n}\n\n/**\n * Is fragment delivery active for this session?\n *\n * **Off unless the server says otherwise.** As of 2026-08-07 Cloudflare returns\n * `cf-cache-status: DYNAMIC` for these URLs (extensionless paths are not cached by default),\n * and the route sits ahead of the Range gate in `priorityQueue.ts` so it consumes no\n * `MAX_CONCURRENT_MEDIA` slot and is never sorted by `X-Buffer-Ahead-Ms`. Enabled today that is\n * strictly worse than Range: no edge cache AND no fairness queue.\n */\nexport function isFragmentDeliveryEnabled(): boolean {\n const arm = resolveSessionArm();\n // Reading it to ACT on it fixes it for the session.\n armPinned = true;\n return arm === 'fragment';\n}\n","import { DEVICE_ID_KEY, getDeviceId } from '@wolffm/prefs-client';\nimport { readStored } from './browserStorage';\nimport { debug } from './debug';\n\n/**\n * The browser-persisted device id, reported on join so the server can derive a\n * `dev:`-tier participant identity for a client with no friend key.\n *\n * ## Why this id and not a new one\n *\n * `@wolffm/prefs-client` already owns `hadoku_device_id` — one id per browser,\n * shared across every hadoku microfrontend by design. Minting a\n * watchparty-specific id would give the same physical browser a different\n * identity per app, for no gain: the id is opaque, carries no privileges, and\n * the whole point is \"this is the same browser as last time\".\n *\n * `lib/fragmentDelivery.ts` keeps its OWN `watchparty_delivery_client_id`, and\n * that is deliberate rather than an oversight to clean up — it is an A/B\n * bucketing salt, and pointing it at a shared ecosystem id would silently\n * re-bucket every viewer the first time any other hadoku app touched the key.\n * Two ids, two purposes.\n *\n * ## Why there is a synchronous read at all\n *\n * `getDeviceId()` is async (so the SDK can swap in Capacitor storage), but the\n * join payload is assembled synchronously and a join that waits on I/O is a\n * join that races the socket. So: read localStorage directly for the common\n * case — a returning browser already has the key — and use the async path only\n * to MINT one, priming the cache for the next join.\n *\n * Reading `DEVICE_ID_KEY` ourselves is not reaching into the SDK's internals;\n * the package exports that constant precisely so consumers can.\n *\n * ## The unresolved case is a normal outcome, not an error\n *\n * A brand-new browser, or one with storage blocked (private mode, blocked site\n * data), yields `undefined`. The server then derives an `eph:` identity — the\n * pre-existing behaviour, where a name lasts as long as the socket. It must\n * never block or delay the join: an anonymous viewer losing their display name\n * on reload is a small thing, and not joining at all is not.\n */\n\nlet cached: string | undefined;\n\n/** Synchronous read of an id this browser already holds. */\nfunction storedDeviceId(): string | undefined {\n return readStored(DEVICE_ID_KEY) || undefined;\n}\n\n/**\n * Kick off a mint if this browser has no id yet. Fire-and-forget: callers get\n * the id from `cachedDeviceId()` when it is there, and the ephemeral tier when\n * it isn't. Call it as early as possible so the mint is done before join.\n */\nexport function primeDeviceId(): void {\n if (cached) return;\n const stored = storedDeviceId();\n if (stored) {\n cached = stored;\n return;\n }\n void getDeviceId()\n .then((id) => {\n if (id) cached = id;\n })\n .catch(() => {\n // Storage refused the write. Nothing to recover: the ephemeral tier is\n // the honest answer, and swallowing this is the whole point — a device\n // id is an optimisation, never a requirement for joining a room.\n debug('identity', 'device id unavailable — joining without one');\n });\n}\n\n/**\n * The device id, if this browser has one *right now*. Never blocks, never\n * mints. Returns undefined on a first-ever visit whose mint has not landed.\n */\nexport function cachedDeviceId(): string | undefined {\n return (cached ??= storedDeviceId());\n}\n\n/** Test seam: drop the memoised value so a spec can re-read storage. */\nexport function resetDeviceIdCacheForTests(): void {\n cached = undefined;\n}\n","import { removeStored } from './browserStorage';\nimport { STORAGE_KEYS } from './storageKeys';\n\n/**\n * Forget this browser's stored ballot for a playlist. Called when the leader\n * removes us from the results (`playlist_votes_cleared`). The ballot is kept in\n * localStorage and RE-SENT whenever the playlist loads (PlaylistPanel's restore\n * effect), so clearing only the server's copy would be undone by the next\n * reload. Returns how many entries were removed.\n */\nexport function clearStoredVotes(playlist: { id: string; videos: ReadonlyArray<{ mediaId: string }> }, owner: string): number {\n let removed = 0;\n for (const video of playlist.videos) {\n const key = STORAGE_KEYS.voteKey(owner, playlist.id, video.mediaId);\n if (globalThis.localStorage?.getItem(key) != null) removed++;\n removeStored(key);\n }\n return removed;\n}\n","/**\n * Client-side liveness of the socket, judged by time_pong replies.\n *\n * The browser only learns a connection died when something closes it. A\n * connection that goes quiet without closing — half-open, or stalled somewhere\n * upstream — keeps `socket.connected === true` indefinitely: engine.io's own\n * timeout is pingInterval + SOCKET_PING_TIMEOUT_MS, over five minutes here.\n * mistral-bluff 2026-10-20 had both kinds: four IPv4 viewers' sockets were cut\n * and reconnected on their own, while an IPv6 viewer's sat silent for 96s on\n * the same socket and recovered only when the network did.\n *\n * Rule: a ping unanswered for PONG_DEAD_AFTER_MS of AWAKE time means the\n * socket is dead, and the caller replaces it. \"Awake\" matters — a frozen or\n * throttled background page stops running timers, so a long gap between two\n * ticks is the page sleeping, not the network failing. Such a gap restarts the\n * clock instead of counting toward it.\n *\n * No timer of its own: it is evaluated on the existing time-ping tick.\n */\n\n/** Pings go out every DEFAULT_CLIENT_TIME_PING_INTERVAL_MS (5s); four of them unanswered. */\nexport const PONG_DEAD_AFTER_MS = 20_000;\n/** A tick this late after the previous one means the page was asleep. */\nexport const SLEEP_GAP_MS = 10_000;\n\nexport interface Liveness {\n /** When the oldest ping with no pong since was sent; null when all answered. */\n unansweredSince: number | null;\n lastPongAt: number | null;\n lastTickAt: number | null;\n}\n\nexport function createLiveness(): Liveness {\n return { unansweredSince: null, lastPongAt: null, lastTickAt: null };\n}\n\nexport function notePong(l: Liveness, now: number): void {\n l.lastPongAt = now;\n l.unansweredSince = null;\n}\n\n/**\n * Called on each ping tick, before the ping is sent. Returns true when the\n * socket should be treated as dead.\n */\nexport function tick(l: Liveness, now: number, connected: boolean): boolean {\n const slept = l.lastTickAt !== null && now - l.lastTickAt > SLEEP_GAP_MS;\n l.lastTickAt = now;\n if (!connected) {\n l.unansweredSince = null;\n return false;\n }\n if (slept) {\n l.unansweredSince = now;\n return false;\n }\n if (l.unansweredSince === null) {\n l.unansweredSince = now;\n return false;\n }\n return now - l.unansweredSince > PONG_DEAD_AFTER_MS;\n}\n\n/** ms since the last pong, for the health report; undefined before the first. */\nexport function pongAge(l: Liveness, now: number): number | undefined {\n return l.lastPongAt === null ? undefined : Math.max(0, now - l.lastPongAt);\n}\n","import type { Socket } from 'socket.io-client';\nimport { io } from 'socket.io-client';\nimport { DEFAULT_UI_PORT, DEFAULT_HTTP_PORT, DEFAULT_WS_PATH, PLAYLIST_EVENTS, TIERLIST_EVENTS, READY_EVENTS, CORE_EVENTS, GAME_EVENTS, DEFAULT_CLIENT_TIME_PING_INTERVAL_MS } from '@watchparty/shared';\nimport { isChatImageKey, DEFAULT_CHAT_MAX_IMAGES } from '@watchparty/shared';\nimport type { TVoteValue, TChatImage } from '@watchparty/shared';\nimport type { RosterUser, ChatMessage } from './store';\nimport { useAppStore } from './store';\nimport { refreshTierlist } from './tierlistApi';\nimport { getRuntimeConfig } from './config';\nimport { displayNameFor, isCharacterName } from './characters';\nimport { debug, warn, error, setLogRoomContext } from './debug';\nimport { isStaleSpriteRefusal, retryTakenSprite } from './spriteRetry';\nimport { adoptRequestedSprite } from './avatar';\nimport { describeEmbedding } from './embedContext';\nimport { currentDeliveryArm } from './fragmentDelivery';\nimport { cachedDeviceId } from './deviceId';\nimport { resolveIdentityReassert } from '@wolffm/rooms-protocol';\nimport type {\n SnapshotPayload, ControlBroadcastPayload, ControlAckPayload, TimePongPayload,\n ReadyStatePayload, PresencePayload, ChatBroadcastPayload, ChatHistoryPayload,\n PlaylistStatePayload, PlaylistVoteProgressPayload, PlaylistResultsPayload, PlaylistVotesClearedPayload,\n ReadyCheckStatePayload, GameActivePayload, HealthSnapshotPayload,\n BufferStatusPayload, ErrorPayload,\n} from './wireEvents';\nimport { registerDebugGlobal } from './debugGlobals';\nimport { clearStoredVotes } from './playlistVotes';\nimport { voteOwnerScope } from './auth';\nimport { createLiveness, notePong, tick as livenessTick, pongAge } from './connectionLiveness';\n\nlet socket: Socket | null = null;\nlet timePingInflight: { ts: number } | null = null;\nlet timePingInterval: ReturnType<typeof setInterval> | null = null;\nlet timePingTimeout: ReturnType<typeof setTimeout> | null = null;\nlet lastJoinPayload: { room_key: string; name?: string; sprite?: string | null; embed?: { framed: boolean; depth: number }; device_id?: string; tab_id: string } | null = null;\n/** One per page load, sent on the join and every rejoin, so the server can tell\n * this tab reconnecting from a second tab and retire the old connection. */\nconst TAB_ID = makeTabId();\nconst liveness = createLiveness();\n/** How the previous connection ended, reported on the next rejoin. */\nlet lastDisconnect: { reason: string; at: number } | null = null;\n/** Set just before the pong watchdog closes the transport, so the rejoin says why. */\nlet pendingDisconnectCause: string | null = null;\nlet reconnectCount = 0;\n/** The identity the latest join (or rejoin) carried — what the server has\n * already been asked for, so the first snapshot never asks again. */\nlet joinedIdentity: { name?: string; sprite?: string | null } | null = null;\n/** True while this module sets an identity it is about to emit itself, so the\n * store-change reassert below does not send a second copy of it. */\nlet identityEmitInHand = false;\n/** Set when the server said our wanted sprite is held by this account's other connection; cleared per connect. */\nlet spriteHeldByOwnOtherConnection = false;\nlet hasConnectedOnce = false; // Distinguishes initial connect from reconnects\nlet lastReadyMediaId: string | null = null; // Tracks last client_ready so it can be re-emitted on reconnect\n\ninterface JoinOpts {\n room_key: string;\n name?: string;\n sprite?: string | null;\n}\n\nexport function connectAndJoin(opts: JoinOpts): Socket {\n const s = getSocket();\n const state = useAppStore.getState();\n // If we already have a clientId and roster contains it, avoid duplicate join.\n if (state.me.clientId && state.roster[state.me.clientId]) {\n if (opts.name || opts.sprite) {\n debug('socket', 'Identity update (already joined)', { name: opts.name, sprite: opts.sprite });\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: opts.name, sprite: opts.sprite });\n }\n return s;\n }\n const payload = sanitizeJoin(opts);\n lastJoinPayload = payload; // Store for reconnection\n joinedIdentity = { name: payload.name, sprite: payload.sprite };\n if (s.connected) {\n debug('socket', 'Emitting join', payload);\n s.emit(CORE_EVENTS.JOIN, payload);\n } else {\n s.once('connect', () => { debug('socket', 'Emitting join', payload); s.emit(CORE_EVENTS.JOIN, payload); });\n }\n return s;\n}\n\nfunction sanitizeJoin(o: JoinOpts) {\n return {\n room_key: o.room_key,\n name: o.name,\n sprite: o.sprite,\n // Entry-path telemetry — see lib/embedContext.ts. Sent on every join AND\n // rejoin (lastJoinPayload carries it), so a reconnect keeps the attribution.\n embed: describeEmbedding(),\n delivery_arm: currentDeliveryArm(),\n // Identity tier input — see lib/deviceId.ts. Sent on every join AND rejoin\n // (lastJoinPayload carries it) so a reconnect lands on the same\n // ParticipantId rather than minting a new person.\n device_id: cachedDeviceId(),\n tab_id: TAB_ID,\n };\n}\n\nfunction makeTabId(): string {\n const c = globalThis.crypto;\n if (c && typeof c.randomUUID === 'function') return c.randomUUID();\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') c.getRandomValues(bytes);\n else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);\n return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');\n}\n\n/** ms since this connection's last time_pong, for the health report. */\nexport function currentPongAgeMs(): number | undefined {\n return pongAge(liveness, Date.now());\n}\n\nexport function getSocket(): Socket {\n if (!socket) {\n const config = getRuntimeConfig();\n let origin = config.serverOrigin || '/';\n if (origin === '/' && typeof window !== 'undefined') {\n const loc = window.location;\n if ((loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port === String(DEFAULT_UI_PORT)) {\n origin = `${loc.protocol}//${loc.hostname}:${DEFAULT_HTTP_PORT}`;\n debug('socket', 'Using dev fallback origin', { origin });\n }\n }\n // Strip any path from the origin - socket.io treats URL paths as namespaces\n const socketOrigin = new URL(origin.startsWith('http') ? origin : `${window.location.origin}${origin}`).origin;\n socket = io(socketOrigin, { path: DEFAULT_WS_PATH, transports: ['websocket', 'polling'] });\n debug('socket', 'Socket initialized', { origin });\n\n // E2E handle — published only when the host opts in (lib/debugGlobals.ts).\n registerDebugGlobal('__SOCKET__', socket);\n\n // Start time ping loop immediately when socket is created.\n // This must live here (not in wireCoreHandlers) so that cleanupTimers() + getSocket()\n // restarts the interval correctly. wireCoreHandlers is called once at creation time\n // but not on reconnect, so placing the interval there would leave it dead after cleanup.\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n timePingInflight = null;\n timePingInterval = setInterval(() => {\n if (livenessTick(liveness, Date.now(), !!socket?.connected)) {\n // Half-open or stalled upstream: nothing will ever close it for us.\n // Closing the transport hands it to socket.io's reconnect, and the\n // rejoin's tab id lets the server retire the old row at once.\n warn('socket', 'PONG-WATCHDOG no time_pong for 20s — replacing the connection', {\n sid: socket?.id, lastPongAgeMs: pongAge(liveness, Date.now()) ?? null,\n });\n pendingDisconnectCause = 'pong-watchdog';\n liveness.unansweredSince = null;\n socket?.io.engine?.close();\n return;\n }\n initiateTimePing();\n }, DEFAULT_CLIENT_TIME_PING_INTERVAL_MS);\n\n socket.on('connect', () => {\n spriteHeldByOwnOtherConnection = false;\n const sid = socket?.id;\n debug('socket', 'Connected', { id: sid, isReconnect: hasConnectedOnce });\n if (sid) {\n try {\n useAppStore.getState().ensureMe(sid);\n // Reset client sequence on (re)connect to match server's fresh connection state\n // Server starts with lastClientSeq=0, so we need nextClientSeq=1\n useAppStore.setState(s => ({ me: { ...s.me, nextClientSeq: 1 } }));\n debug('socket', 'Reset client sequence for new connection');\n } catch (e) {\n error('socket', 'Failed to initialize store on connect', { error: (e as Error).message, sid });\n }\n }\n // Re-join on reconnect only — initial join is handled by connectAndJoin()\n if (hasConnectedOnce && lastJoinPayload) {\n const st = useAppStore.getState();\n reconnectCount += 1;\n const rejoinPayload = {\n ...lastJoinPayload, name: st.name, sprite: st.sprite,\n reconnect: {\n count: reconnectCount,\n reason: (lastDisconnect?.reason ?? 'unknown').slice(0, 64),\n offline_ms: lastDisconnect ? Math.max(0, Date.now() - lastDisconnect.at) : 0,\n },\n };\n const readyMediaId = lastReadyMediaId;\n debug('socket', 'Re-emitting join after reconnect', { room_key: rejoinPayload.room_key, name: rejoinPayload.name, sprite: rejoinPayload.sprite });\n // Wire snapshot ack BEFORE emitting join so we never miss the response.\n // The server emits `snapshot` once per successful join — we use it as\n // the join-ack. Re-emitting client_ready before snapshot arrives loses\n // the event server-side because the new socket isn't yet in the room\n // (Bug 2 follow-up: the previous fix race-condition'd by re-emitting\n // client_ready unconditionally; this version waits.)\n if (readyMediaId) {\n let resolved = false;\n const onSnapshot = () => {\n if (resolved) return;\n resolved = true;\n socket!.off(CORE_EVENTS.SNAPSHOT, onSnapshot);\n socket!.emit(CORE_EVENTS.CLIENT_READY, { media_id: readyMediaId });\n debug('socket', 'Re-emitted client_ready after reconnect snapshot', { mediaId: readyMediaId });\n };\n socket!.on(CORE_EVENTS.SNAPSHOT, onSnapshot);\n setTimeout(() => {\n if (resolved) return;\n resolved = true;\n socket!.off(CORE_EVENTS.SNAPSHOT, onSnapshot);\n warn('socket', 'client_ready re-emit timed out waiting for snapshot', { mediaId: readyMediaId });\n }, 10_000);\n }\n joinedIdentity = { name: rejoinPayload.name, sprite: rejoinPayload.sprite };\n socket!.emit(CORE_EVENTS.JOIN, rejoinPayload);\n // Abort any stale in-flight fetches from the previous connection.\n // On socket.io reconnect the FragmentManager carries over pending\n // fetch indices from the old session which block new fetches at\n // lower indices indefinitely (until their 60-90s adaptive timeouts\n // expire). Clearing them here lets prefetch restart immediately.\n // Notify MediaPlayer via event rather than reaching into __FRAGMENT_MANAGER__\n // directly. MediaPlayer owns the FM; socket should not depend on the window global.\n window.dispatchEvent(new CustomEvent('watchparty:socket-reconnect'));\n } else if (hasConnectedOnce) {\n warn('socket', 'Reconnected — no join payload, not re-joining any room');\n }\n hasConnectedOnce = true;\n });\n socket.on('disconnect', (reason) => {\n setLogRoomContext(null);\n lastDisconnect = { reason: pendingDisconnectCause ?? reason, at: Date.now() };\n pendingDisconnectCause = null;\n debug('socket', 'Disconnected', { reason });\n });\n socket.on('connect_error', (err) => error('socket', 'Connection error', { message: err?.message }));\n socket.on('reconnect_attempt', (n) => warn('socket', 'Reconnect attempt', { attempt: n }));\n socket.on('reconnect_failed', () => error('socket', 'Reconnect failed'));\n wireCoreHandlers(socket);\n }\n return socket;\n}\n\n// --- Helpers ---\n\nfunction isServerPlaying(payload: { play_state?: string; playing?: boolean }): boolean {\n return payload.play_state === 'playing' || payload.playing === true;\n}\n\nfunction normalizeChatMessage(m: ChatBroadcastPayload, seqFallback?: number): ChatMessage {\n return {\n id: String(m.id ?? m.server_seq ?? seqFallback ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),\n clientId: m.client_id,\n author: typeof m.author === 'string' ? m.author : undefined,\n name: m.name || 'Anon',\n sprite: m.sprite ?? null,\n text: m.text || '',\n ts: m.ts || Date.now(),\n // Attachments arrive from other clients, so the key shape is re-checked\n // here at the boundary rather than trusted at render time. A malformed\n // entry is dropped, not rendered and not allowed near an <img src>.\n images: Array.isArray(m.images)\n ? m.images\n .filter((im: unknown): im is TChatImage =>\n !!im && isChatImageKey((im as TChatImage).key))\n .slice(0, DEFAULT_CHAT_MAX_IMAGES)\n : undefined,\n isSystem: m.isSystem || false,\n systemType: m.systemType,\n };\n}\n\n// --- Domain-split wire functions ---\n\nfunction wireSync(s: Socket): void {\n // snapshot\n s.on(CORE_EVENTS.SNAPSHOT, (payload: SnapshotPayload) => {\n // First snapshot = join confirmed. Ask which game is open, so a late\n // joiner mounts it without waiting for the next leader broadcast.\n // Idempotent on later snapshots.\n s.emit(GAME_EVENTS.SYNC);\n // NOTE: the snapshot carries no roster and no client_id — `buildSnapshot`\n // emits exactly seven fields (room_id, play_state, playhead_ms, leader_id,\n // media_id, server_seq, server_ts). Anything roster-shaped must come from the store,\n // which the `presence` handler maintains.\n // Server sends play_state: 'playing'|'paused', convert to boolean\n useAppStore.getState().applySnapshot({\n roomId: payload.room_id ?? null,\n mediaId: payload.media_id ?? null,\n playheadMs: payload.playhead_ms || 0,\n playing: isServerPlaying(payload),\n serverSeq: payload.server_seq || 0,\n leaderId: payload.leader_id || null,\n });\n // No auto identity assignment here: with no roster in the snapshot,\n // ensureAutoIdentity() would always pick the first character (sage) and\n // send an identity_update the server accepts for name but rejects for\n // sprite, leaving duplicate names across clients. `presence` arrives right\n // after with the full roster and its handler owns that correction.\n if (!useAppStore.getState().name) {\n debug('socket', 'Deferring identity to presence event (snapshot carries no roster)');\n }\n\n // Reassert local identity (name + sprite) on the first snapshot after\n // join. Two motivations:\n // 1. Custom NAME — replaces the old 40ms postJoinIdentity setTimeout.\n // 2. Saved SPRITE — when /api/me/prefs resolves AFTER Room.tsx fires\n // connectAndJoin (the typical fresh-device case: SPA bundle on a\n // direct /room/{key} link), the join payload sends the auto-\n // assigned sprite. rehydrateFromPrefs then updates the LOCAL store\n // to the saved sprite, but joinedRef pins the join — without this\n // reassert, other clients keep seeing the auto-assigned sprite\n // forever. Bug 1 from the 2026-05-20 cedar-ridge retrospective\n // (Coz + LittleMiss with wrong avatars).\n // Each field is gated independently: only emit it if the local store has a\n // non-default value AND the server's roster entry for us still shows a\n // different value, so once the server reflects us later snapshots are\n // no-ops. That entry comes from the STORE roster (maintained by the\n // `presence` handler) — it used to be looked up in `payload.users`, which\n // the snapshot has never carried, so the lookup was permanently undefined\n // and every client with a saved identity re-emitted on every snapshot.\n // Rule + regression tests: @wolffm/rooms-protocol resync.ts.\n const stAfter = useAppStore.getState();\n const myClientId = socket?.id;\n const myEntry = myClientId ? stAfter.roster[myClientId] : undefined;\n // The rule lives in @wolffm/rooms-protocol (resync.ts) so both ends of the\n // room layer agree on it. It speaks `avatarId`; the store and the WIRE both\n // say `sprite`, so the mapping happens here at the edge rather than\n // spreading watchparty's vocabulary into the package.\n // Before the first presence there is no roster entry, and the server's\n // view of us is what the join just carried: asserting that again only\n // repeats a request it has already answered (with a sprite_taken, when\n // the joined sprite was held — which used to start a retry cascade into\n // identity.rate_limit). Only what changed SINCE the join is asserted.\n const known = myEntry ?? joinedIdentity;\n const idResult = resolveIdentityReassert(\n { name: stAfter.name, avatarId: stAfter.sprite },\n known ? { name: known.name, avatarId: known.sprite ?? null } : undefined,\n // A join that carried no name counts as a default one, so a name that\n // landed after the join (prefs) is still asserted.\n (n) => n === '' || isCharacterName(n),\n );\n if (idResult) {\n const idPayload = {\n ...(idResult.name !== undefined ? { name: idResult.name } : {}),\n ...(idResult.avatarId !== undefined && !spriteHeldByOwnOtherConnection ? { sprite: idResult.avatarId } : {}),\n };\n if (Object.keys(idPayload).length > 0) {\n debug('socket', 'Identity update on snapshot', idPayload);\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, idPayload);\n }\n }\n\n debug('socket', 'Received snapshot', {\n mediaId: payload.media_id ?? null,\n playheadMs: payload.playhead_ms || 0,\n playing: isServerPlaying(payload),\n leaderId: payload.leader_id || null,\n serverSeq: payload.server_seq || 0,\n rosterSize: Object.keys(stAfter.roster).length,\n });\n\n // Set room context for client log correlation with server session logs\n if (lastJoinPayload?.room_key && socket?.id) {\n setLogRoomContext({ room_key: lastJoinPayload.room_key, client_id: socket.id });\n }\n });\n\n // control broadcasts (simplified before MSE): { server_seq, playhead_ms, playing, leader_id? }\n s.on(CORE_EVENTS.CONTROL_BROADCAST, (msg: ControlBroadcastPayload) => {\n const state = useAppStore.getState();\n // If media_id present and changed, promote it into snapshot + reset readiness gate\n if (Object.prototype.hasOwnProperty.call(msg, 'media_id')) {\n const newMediaId = msg.media_id ?? null;\n if (state.snapshot.mediaId !== newMediaId) {\n state.applySnapshot({ mediaId: newMediaId });\n // Reset readiness (server will emit fresh ready_state after clients append)\n if (newMediaId) state.setReadiness({ mediaId: newMediaId, ready: false, readyCount: 0, total: 0 });\n // Bump generation so the seek effect re-fires even when playheadMs doesn't\n // change (e.g. 0 → 0 on same-media reload). Only bump on actual media\n // changes — bumping on every play/pause/seek broadcast (which all include\n // media_id) disables the DEFAULT_DRIFT_SOFT_MS deduplication guard in\n // usePlaybackSync, causing cascading seek preemptions on multi-viewer seeks.\n state.bumpLoadGeneration();\n }\n }\n // Server sends play_state: 'playing'|'paused', convert to boolean\n state.updateControlState({\n playheadMs: msg.playhead_ms,\n playing: isServerPlaying(msg),\n serverSeq: msg.server_seq,\n leaderId: msg.leader_id ?? state.snapshot.leaderId\n });\n debug('socket', 'Control broadcast received', {\n kind: msg.kind, serverSeq: msg.server_seq, playheadMs: msg.playhead_ms,\n playState: msg.play_state, mediaId: msg.media_id,\n });\n });\n\n // control ack (map client_seq -> server_seq)\n s.on(CORE_EVENTS.CONTROL_ACK, (ack: ControlAckPayload) => {\n if (typeof ack?.client_seq === 'number' && typeof ack?.server_seq === 'number') {\n debug('socket', 'Control ack received', ack);\n }\n });\n\n // time_pong: server response to our time_ping { server_ts, client_sent_ts }\n s.on(CORE_EVENTS.TIME_PONG, (p: TimePongPayload) => {\n // Any pong proves the connection carries traffic — even one too late for\n // an RTT sample.\n notePong(liveness, Date.now());\n if (timePingInflight && timePingInflight.ts) {\n const rtt = Date.now() - timePingInflight.ts;\n useAppStore.getState().addRttSample(rtt, p?.server_ts, timePingInflight.ts);\n timePingInflight = null;\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n debug('socket', 'Time sync RTT', { rtt, offset: useAppStore.getState().drift.offsetMs });\n }\n });\n\n // ready_state: server-authoritative readiness broadcast\n s.on(CORE_EVENTS.READY_STATE, (payload: ReadyStatePayload) => {\n if (!payload) return;\n const mediaId = payload.media_id ?? null;\n const readyCount = typeof payload.ready_count === 'number' ? payload.ready_count : 0;\n const total = typeof payload.total === 'number' ? payload.total : 0;\n const ready = payload.ready === true;\n useAppStore.getState().setReadiness({ mediaId, ready, readyCount, total });\n debug('socket', 'Ready state received', { mediaId, readyCount, total, ready });\n });\n}\n\nfunction wirePresence(s: Socket): void {\n s.on(CORE_EVENTS.PRESENCE, (ev: PresencePayload) => {\n if (!ev) return;\n // Server currently emits { users: [...] }\n if (Array.isArray(ev.users)) {\n const roster: Record<string, RosterUser> = {};\n for (const u of ev.users) {\n roster[u.client_id] = {\n clientId: u.client_id,\n ...(u.author ? { author: u.author } : {}),\n name: u.name || 'Anon',\n sprite: u.sprite_id ?? u.sprite ?? null,\n isLeader: u.is_leader,\n ...(u.registry_name ? { registryName: true } : {}),\n ...(u.avatar_url ? { avatarUrl: u.avatar_url } : {}),\n };\n }\n // Server is authoritative for sprite assignments - update local state to match\n const st = useAppStore.getState();\n const myId = socket?.id;\n const mine = myId ? roster[myId] : undefined;\n if (mine?.registryName) {\n // The server named us from the key registry: that IS our name, and it\n // never takes ours instead (ws/join.ts). Adopt it, and the sprite with\n // it. The roster goes in FIRST so the store-change re-assert below sees\n // the server already agreeing and stays quiet.\n useAppStore.setState({ roster });\n if (mine.name !== st.name || (mine.sprite && mine.sprite !== st.sprite)) {\n debug('socket', 'Adopting registry name', { from: st.name, to: mine.name });\n useAppStore.getState().setSessionIdentity(mine.name, mine.sprite ?? st.sprite);\n }\n } else if (myId && roster[myId]) {\n const serverName = roster[myId].name;\n const serverSprite = roster[myId].sprite;\n // Keep roster display name in sync with local custom name\n if (st.name && !isCharacterName(st.name)) {\n roster[myId].name = st.name; // user chose a custom name\n }\n // The roster goes in FIRST, as in the registry branch: the store-change\n // reassert then sees the server agreeing with the sprite adopted below\n // and stays quiet, instead of sending it straight back.\n useAppStore.setState({ roster });\n // Server is authoritative for sprite (prevents duplicates), but name is user-owned.\n // If the server reassigned our sprite (e.g., another user took ours during disconnect),\n // accept the new sprite but preserve a custom name the user set.\n if (serverSprite && serverSprite !== st.sprite) {\n const hasCustomName = st.name && !isCharacterName(st.name);\n // Use the display name for the newly-assigned sprite, not the server's\n // stale name (which may still reflect the old sprite's default name).\n // Custom names are always preserved.\n const nameToUse = hasCustomName ? st.name : displayNameFor(serverSprite);\n debug('socket', 'Server reassigned sprite', { from: st.sprite, to: serverSprite, newName: nameToUse, preservingCustomName: hasCustomName });\n useAppStore.getState().setSessionIdentity(nameToUse, serverSprite);\n // Re-assert the name so other clients see it immediately — unless the\n // server already shows it.\n if (nameToUse !== serverName) s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: nameToUse });\n }\n }\n }\n debug('socket', 'Received presence update', ev);\n // If we still have no identity after presence, attempt assignment\n const stFinal = useAppStore.getState();\n if (!stFinal.name) {\n stFinal.ensureAutoIdentity?.();\n const st3 = useAppStore.getState();\n if (st3.name) {\n debug('socket', 'Auto-assigned identity on presence', { name: st3.name, sprite: st3.sprite });\n s.emit(CORE_EVENTS.IDENTITY_UPDATE, { name: st3.name, sprite: st3.sprite || undefined });\n }\n }\n });\n}\n\nfunction wireChat(s: Socket): void {\n // chat_broadcast: { id, client_id, text, name, sprite, ts, isSystem?, systemType? }\n s.on(CORE_EVENTS.CHAT_BROADCAST, (m: ChatBroadcastPayload) => {\n const chatMsg = normalizeChatMessage(m, m.server_seq);\n useAppStore.getState().appendChat(chatMsg);\n debug('chat', 'Message received', chatMsg);\n });\n\n // chat_history: { messages: [...] } - sent on join\n s.on(CORE_EVENTS.CHAT_HISTORY, (data: ChatHistoryPayload) => {\n if (!data?.messages || !Array.isArray(data.messages)) return;\n const messages: ChatMessage[] = data.messages.map((m) => normalizeChatMessage(m));\n useAppStore.getState().setChat(messages);\n debug('chat', 'History loaded', { count: messages.length });\n });\n}\n\nfunction wirePlaylist(s: Socket): void {\n s.on(PLAYLIST_EVENTS.STATE, (payload: PlaylistStatePayload) => {\n useAppStore.getState().applyPlaylistState({\n playlist: payload?.playlist ?? null,\n activeIndex: payload?.activeIndex ?? null,\n myVotes: payload?.myVotes ?? {},\n });\n debug('playlist', 'Playlist state received', { playlistId: payload?.playlist?.id ?? null, activeIndex: payload?.activeIndex, myVoteCount: Object.keys(payload?.myVotes || {}).length });\n });\n\n s.on(PLAYLIST_EVENTS.VOTE_PROGRESS, (payload: PlaylistVoteProgressPayload) => {\n useAppStore.getState().setVoteProgress(payload?.progress ?? []);\n debug('playlist', 'Vote progress', { progressCount: payload?.progress?.length });\n });\n\n s.on(PLAYLIST_EVENTS.RESULTS, (payload: PlaylistResultsPayload) => {\n useAppStore.getState().setPlaylistResults(payload ?? null);\n debug('playlist', 'Results received', { playlistId: payload?.playlist?.id, update: payload?.update === true });\n });\n\n // The leader removed us from the results: drop the ballot here too, or the\n // restore effect re-sends it on the next playlist load.\n // On rejoin this arrives BEFORE playlist_state, so the store may not know the\n // playlist yet — clear from the ids in the payload, never from the store.\n s.on(PLAYLIST_EVENTS.VOTES_CLEARED, (payload: PlaylistVotesClearedPayload) => {\n if (!payload?.playlistId || !Array.isArray(payload.mediaIds)) return;\n const removed = clearStoredVotes(\n { id: payload.playlistId, videos: payload.mediaIds.map(mediaId => ({ mediaId })) },\n voteOwnerScope(),\n );\n const st = useAppStore.getState();\n if (st.playlist?.id === payload.playlistId) {\n st.applyPlaylistState({ playlist: st.playlist, activeIndex: st.activePlaylistIndex, myVotes: {} });\n }\n debug('playlist', 'Votes cleared by leader', { playlistId: payload.playlistId, removed });\n });\n}\n\nfunction wireTierlist(s: Socket): void {\n // A \"go re-fetch\" ping, not a payload. The shared board is identical for\n // everyone but `myBoard` is not, so one broadcast cannot serve both — each\n // client pulls its own view. `adoptDraft: false` because this fires when\n // SOMEONE ELSE saved: their result must land live, but replacing this\n // client's draft would yank cards out from under a drag in progress.\n s.on(TIERLIST_EVENTS.UPDATED, () => {\n debug('tierlist', 'Someone saved — refreshing the board');\n void refreshTierlist(false);\n });\n}\n\nfunction wireReadyCheck(s: Socket): void {\n // Server-authoritative state: { active: boolean, ready: clientId[] }.\n // Client never derives this locally — every transition is a broadcast.\n s.on(READY_EVENTS.STATE, (payload: ReadyCheckStatePayload) => {\n if (!payload || typeof payload.active !== 'boolean') return;\n const ready = Array.isArray(payload.ready) ? payload.ready.filter((x: unknown) => typeof x === 'string') : [];\n useAppStore.getState().setReadyCheckState({ active: payload.active, ready });\n debug('ready-check', 'State received', { active: payload.active, readyCount: ready.length });\n });\n}\n\n/** Leader-only emit — opens a fresh ready check. Server validates role. */\nexport function emitReadyCheckStart(): void {\n socket?.emit(READY_EVENTS.START, {});\n}\n\n/** Any-client emit — toggles current user's ready flag. */\nexport function emitReadyCheckToggle(ready: boolean): void {\n socket?.emit(READY_EVENTS.TOGGLE, { ready });\n}\n\n// ---- Waiting-room game ---------------------------------------------------\n\nfunction wireGame(s: Socket): void {\n // The game open in the room (null = none). Every game is a bundle that\n // talks to its own server; the room only says which one to mount.\n s.on(GAME_EVENTS.ACTIVE, (payload: GameActivePayload) => {\n const gameId = typeof payload?.gameId === 'string' ? payload.gameId : null;\n useAppStore.getState().setActiveGameId(gameId);\n debug('game', 'game_active', { gameId });\n });\n}\n\n/** Leader-only emit: open a game for the whole room, or close it with null.\n * Server broadcasts game_active back to everyone. */\nexport function emitGameSelect(gameId: string | null): void {\n socket?.emit(GAME_EVENTS.SELECT, { gameId });\n}\n\nfunction wireHealth(s: Socket): void {\n // health_snapshot: aggregated client health (leader only)\n s.on(CORE_EVENTS.HEALTH_SNAPSHOT, (payload: HealthSnapshotPayload) => {\n useAppStore.getState().setHealthSnapshot(payload);\n });\n // buffer_status: lightweight buffer data broadcast to all clients in room\n s.on(CORE_EVENTS.BUFFER_STATUS, (payload: BufferStatusPayload) => {\n useAppStore.getState().setBufferStatus(payload);\n });\n}\n\nfunction wireCoreHandlers(s: Socket): void {\n wireSync(s);\n wirePresence(s);\n wireChat(s);\n wireHealth(s);\n wirePlaylist(s);\n wireTierlist(s);\n wireReadyCheck(s);\n wireGame(s);\n\n // error: { code, message? }\n s.on(CORE_EVENTS.ERROR, (e: ErrorPayload) => {\n const code = e?.code || 'error/unknown';\n\n // Sprite conflict: silently retry with next available sprite from roster.\n // This fires when identity_update requests a sprite that's already claimed.\n // The refusal's other reason: the sprite is held by ANOTHER CONNECTION OF THE SAME\n // ACCOUNT (a second browser). Accept what the server gave us; re-asking is what\n // tripped sprite_taken in the first place.\n if (code === 'conflict' && e?.message === 'sprite_held_by_you') {\n spriteHeldByOwnOtherConnection = true;\n debug('socket', 'sprite held by this account\\'s other connection: keeping the server-assigned sprite');\n return;\n }\n if (code === 'conflict' && e?.message === 'sprite_taken') {\n // A refusal can be stale. Until the first presence we have no seat to\n // compare against — the presence itself brings the server's pick, which\n // the handler above adopts. And once our sprite matches the server's,\n // the refusal answered an earlier ask the server has since settled.\n // Retrying in either case asked for a sprite nobody needed, drew a fresh\n // refusal, and cascaded into identity.rate_limit.\n if (isStaleSpriteRefusal(socket?.id)) {\n debug('socket', 'sprite_taken: stale, the server already has our sprite');\n return;\n }\n // The retry emits its own payload; the store-change reassert must not\n // send the new sprite a second time.\n identityEmitInHand = true;\n let retry: ReturnType<typeof retryTakenSprite>;\n try {\n retry = retryTakenSprite();\n } finally {\n identityEmitInHand = false;\n }\n if (retry) s.emit(CORE_EVENTS.IDENTITY_UPDATE, retry);\n return;\n }\n\n const map: Record<string, string> = {\n 'control/forbidden': 'You are not the leader.',\n 'control/stale_seq': 'Stale control sequence.',\n 'control/seq_gap': 'Control sequence gap detected.',\n 'rate/limited': 'Rate limited. Slow down.',\n 'payload/too_large': 'Payload too large.',\n 'room/full': 'Room is full.',\n 'auth/invalid_key': 'Invalid room key.',\n 'auth/leader_occupied': 'A leader is already connected to this room.',\n 'auth/friend_required': 'Sign in to join this room.',\n 'origin_block': 'Origin not allowed.',\n 'conflict': 'Character is already taken by another user.',\n 'recap/no_media': 'Load an episode first.',\n 'recap/no_previous': 'This is the first episode of the season — nothing to recap.',\n 'recap/not_episode': 'Recaps are only available for episodes.',\n 'recap/not_found': 'No recap available for the previous episode.'\n };\n const message = map[code] || code;\n // recap/* are informational outcomes of a command the user ran, not\n // faults: \"this is the first episode\" is an answer, and an error toast\n // (plus an error-level log) overstates it.\n if (code.startsWith('recap/')) {\n debug('socket', `recap unavailable: ${message}`, { code });\n useAppStore.getState().pushToast('info', message);\n return;\n }\n // An access refusal the UI shows (auth/*: invalid key, leader occupied,\n // friend required) is an expected outcome of a room visit, not a client\n // fault — it fires in normal use (a stale link, a signed-out visitor)\n // and in tests that deliberately provoke it (room-chrome's unprovisioned-\n // room-without-a-friend-key case). Logging it at error() forwards to the\n // server at pino ERROR, which scripts/e2e.js's summary.txt error count\n // fails the WHOLE SUITE on (test-runner.js: serverErrors !== 0) — outside\n // and unfiltered by the per-test/per-suite warn allowlist, which only\n // ever sees warn-level entries. Every other server error code keeps\n // logging at error(); only auth/* is downgraded (2026-10-01, board H7).\n const logRefusal = code.startsWith('auth/') ? warn : error;\n logRefusal('socket', `Server error: ${message}`, { code, raw: e });\n useAppStore.getState().pushToast('error', message);\n });\n}\n\n// outbound helpers\nexport function sendChat(text: string, images?: TChatImage[]) {\n // `images` is omitted rather than sent as [] so a text-only message is the\n // exact payload it was before attachments existed.\n getSocket().emit(CORE_EVENTS.CHAT_SEND, images?.length ? { text, images } : { text });\n}\n\nfunction initiateTimePing() {\n if (timePingInflight) return; // wait for prior\n timePingInflight = { ts: Date.now() };\n getSocket().emit(CORE_EVENTS.TIME_PING, {});\n // Clear inflight after 10s if pong never arrives, so future pings aren't blocked forever\n timePingTimeout = setTimeout(() => {\n if (timePingInflight) {\n debug('socket', 'Time ping timed out after 10s, clearing inflight');\n timePingInflight = null;\n }\n timePingTimeout = null;\n }, 10_000);\n}\n\n/**\n * The fleet settings popout changed this person's avatar (`hadoku:avatar`,\n * lib/avatar.ts). `sprite` is the character they now ask for, or null.\n *\n * Not joined yet: the identity is adopted and the join carries it. Joined: ONE\n * identity_update, carrying the new character (the room's uniqueness rules\n * still decide; a taken one falls back exactly as a join's does) and\n * `avatar: true`, which makes the server re-read the picture fresh — so a\n * picture-only change, with the same character, reaches the room too.\n */\nexport function sendAvatarChange(sprite: string | null): void {\n const before = useAppStore.getState();\n const myId = before.me.clientId;\n if (!myId || !before.roster[myId]) {\n adoptRequestedSprite(sprite);\n debug('socket', 'Avatar change before join: the join will carry it', { sprite });\n return;\n }\n // This module emits the change itself; the store-change reassert must not\n // send a second copy of it.\n identityEmitInHand = true;\n try {\n adoptRequestedSprite(sprite);\n } finally {\n identityEmitInHand = false;\n }\n const after = useAppStore.getState();\n const payload: { name?: string; sprite?: string; avatar: true } = { avatar: true };\n if (after.sprite && after.sprite !== before.sprite) payload.sprite = after.sprite;\n // A character name follows the character; a registry name never changes here.\n if (after.name && after.name !== before.name) payload.name = after.name;\n debug('socket', 'Sending avatar change', payload);\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n}\n\nexport function sendIdentityUpdate(name?: string, sprite?: string | null) {\n const payload: { name?: string; sprite?: string } = {};\n if (name) payload.name = name;\n if (sprite) payload.sprite = sprite;\n if (!payload.name && !payload.sprite) return;\n debug('socket', 'Sending identity update', payload);\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n}\n\n// Identity-reassert subscription: when the local store's name or sprite\n// changes AFTER we've joined a room, push the change to the server so\n// other clients' rosters update. Without this, a fresh-device direct\n// /room/{key} link races:\n// - Room.tsx fires connectAndJoin BEFORE syncFromServerIfFirstLoad\n// resolves (the prefs fetch is async, the join is sync-from-mount).\n// - Server auto-assigns a sprite; presence broadcasts it to everyone.\n// - Later, /api/me/prefs lands; rehydrateFromPrefs sets local sprite\n// to the saved one. But joinedRef in Room.tsx is now true — no\n// re-join fires, and the snapshot reassert handler can't help\n// because no new snapshot lands (it's only pushed on server events).\n// Bug 1 from the 2026-05-20 cedar-ridge retrospective.\n//\n// Guards: only emit if we're actually joined (me.clientId is in our own\n// roster, set by applySnapshot post-join) AND the new value differs from\n// the server roster's view of us (avoids emitting on the join's own\n// snapshot-applied update). Idempotent server-side anyway.\nlet _lastReasserted = { name: '', sprite: '' as string | null };\nuseAppStore.subscribe((state, prev) => {\n if (state.name === prev.name && state.sprite === prev.sprite) return;\n if (identityEmitInHand) return;\n const myId = state.me.clientId;\n if (!myId) return;\n const myEntry = state.roster[myId];\n if (!myEntry) return; // not yet joined\n const wantsName = !!state.name\n && !isCharacterName(state.name)\n && state.name !== _lastReasserted.name\n && (isCharacterName(myEntry.name || '') || myEntry.name !== state.name);\n const wantsSprite = !!state.sprite\n && state.sprite !== _lastReasserted.sprite\n && myEntry.sprite !== state.sprite;\n if (!wantsName && !wantsSprite) return;\n const payload: { name?: string; sprite?: string } = {};\n if (wantsName) { payload.name = state.name; _lastReasserted.name = state.name; }\n if (wantsSprite) { payload.sprite = state.sprite!; _lastReasserted.sprite = state.sprite!; }\n debug('socket', 'Identity reassert (store change)', payload);\n if (socket?.connected) {\n socket.emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n } else {\n // Buffered emit fires once connected.\n getSocket().emit(CORE_EVENTS.IDENTITY_UPDATE, payload);\n }\n});\n\n/** Clear time-sync timers. Call on navigation/unmount to prevent leaked intervals. */\nfunction cleanupTimers() {\n if (timePingInterval) { clearInterval(timePingInterval); timePingInterval = null; }\n if (timePingTimeout) { clearTimeout(timePingTimeout); timePingTimeout = null; }\n timePingInflight = null;\n liveness.unansweredSince = null;\n liveness.lastTickAt = null;\n}\n\n/** Clear stored join payload. Call on intentional disconnect to prevent auto-rejoin. */\nfunction clearJoinState(): void {\n debug('socket', 'clearJoinState called', { hadPayload: lastJoinPayload !== null, room_key: lastJoinPayload?.room_key });\n lastJoinPayload = null;\n hasConnectedOnce = false;\n lastReadyMediaId = null;\n}\n\n/**\n * Leave the current room: clear join state, stop timers, disconnect the\n * socket, and reset room-session store state.\n *\n * The disconnect is the important part — without it the socket stays open,\n * the server keeps counting us in the room's presence, and the Home page\n * (which reads /api/rooms) shows our avatar until a manual refresh. Nulling\n * the socket + resetting `me`/`roster` also ensures a later re-join emits a\n * fresh `join` rather than tripping connectAndJoin's \"already joined\" guard.\n */\nexport function leaveRoom(): void {\n debug('socket', 'leaveRoom called', { room_key: lastJoinPayload?.room_key });\n clearJoinState();\n cleanupTimers();\n if (socket) {\n socket.disconnect();\n socket = null;\n }\n useAppStore.setState({\n me: { clientId: null, nextClientSeq: 1 },\n roster: {},\n snapshot: { roomId: null, mediaId: null, playheadMs: 0, playing: false, serverSeq: 0, leaderId: null },\n chat: [],\n playlist: null,\n activePlaylistIndex: null,\n myVotes: {},\n voteProgress: [],\n playlistResults: null,\n readiness: { mediaId: null, ready: false, readyCount: 0, total: 0 },\n readyCheck: { active: false, ready: [] },\n activeGameId: null,\n });\n}\n\n// ── Playlist outbound emitters ──────────────────────────────────────────────\n\nexport function emitPlaylistLoad(playlistId: string): void {\n debug('playlist', 'Emitting playlist_load', { playlistId });\n getSocket().emit(PLAYLIST_EVENTS.LOAD, { playlistId });\n}\n\nexport function emitPlaylistSelect(index: number): void {\n debug('playlist', 'Emitting playlist_select', { index });\n getSocket().emit(PLAYLIST_EVENTS.SELECT, { index });\n}\n\nexport function emitVoteUpdate(playlistId: string, mediaId: string, vote: TVoteValue): void {\n debug('playlist', 'Emitting playlist_vote_update', { playlistId, mediaId, vote });\n getSocket().emit(PLAYLIST_EVENTS.VOTE_UPDATE, { playlistId, mediaId, vote });\n}\n\n/**\n * Ask the server to broadcast the previous episode's recap to the room.\n *\n * The server owns the whole decision — which episode is \"previous\", whether a\n * recap was prebuilt, and the rendering — because it is the only side that can\n * see the media tree. The client just asks. Failures come back as a normal\n * socket `error` with a recap/* code.\n */\nexport function emitRecapRequest(): void {\n debug('chat', 'Emitting recap_request');\n getSocket().emit('recap_request');\n}\n\nexport function emitRevealRequest(): void {\n debug('playlist', 'Emitting playlist_reveal_request');\n getSocket().emit(PLAYLIST_EVENTS.REVEAL_REQUEST, {});\n}\n\n/** Leader: remove one voter (a key of the results' `votes`) from the reveal. */\nexport function emitVoteRemove(voterKey: string): void {\n debug('playlist', 'Emitting playlist_vote_remove', { voterKey });\n getSocket().emit(PLAYLIST_EVENTS.VOTE_REMOVE, { voterKey });\n}\n\n\n\n/**\n * Emit client_ready and remember the mediaId so it can be re-emitted on reconnect.\n * Call this instead of getSocket().emit('client_ready', ...) directly.\n * On reconnect, the server loses the ready state for the old socket ID.\n * Re-emitting ensures the readiness count is restored without requiring a full re-init.\n */\nexport function emitClientReady(mediaId: string): void {\n lastReadyMediaId = mediaId;\n getSocket().emit(CORE_EVENTS.CLIENT_READY, { media_id: mediaId });\n debug('socket', 'client_ready emitted', { mediaId });\n}\n","import { useEffect } from 'react';\nimport { listenForAvatarChanges, requestedSpriteOf } from '../lib/avatar';\nimport { sendAvatarChange } from '../lib/socket';\nimport { debug } from '../lib/debug';\n\n/**\n * Carry a settings-popout avatar change into the room live (character and\n * picture), for as long as the room is mounted. Used by both `Room` and the\n * embeddable `RoomChrome`; the listener is removed on unmount, which the\n * chrome's contract with its host requires (e2e/room-chrome.spec.ts).\n */\nexport function useAvatarChanges(): void {\n useEffect(() => listenForAvatarChanges((settings) => {\n const sprite = requestedSpriteOf(settings);\n debug('avatar', 'Avatar changed in the settings popout', { sprite, source: settings.avatar?.source });\n sendAvatarChange(sprite);\n }), []);\n}\n","import { useEffect, useSyncExternalStore } from 'react';\nimport { colorForSprite } from './characters';\nimport { debug } from './debug';\n\n/**\n * A person's colour — their chat name, their ring in the roster, the ready\n * check and the buffer rings. With an avatar picture it is the picture's\n * average colour; otherwise it is their character's colour, which was itself\n * picked off the portrait the same way.\n *\n * The picture is read through a canvas, so it is loaded once more with\n * `crossOrigin=\"anonymous\"`. Discord and Steam both answer\n * `Access-Control-Allow-Origin: *`; a host that does not taints the canvas,\n * and that person keeps their character's colour. One read per URL per page,\n * cached, failures included.\n */\n\n/** Lightness bounds: a black or white picture must still read as a name on a card. */\nconst MIN_LIGHTNESS = 0.3;\nconst MAX_LIGHTNESS = 0.82;\n/** The canvas the picture is averaged on. Small on purpose: an average needs no detail. */\nconst SAMPLE_SIZE = 24;\n/** Below this alpha a pixel is background, not picture. */\nconst MIN_ALPHA = 32;\n\n/**\n * The alpha-weighted mean of RGBA pixels as `#rrggbb`, lightness clamped to\n * the readable band. Null when no pixel is opaque enough to count.\n */\nexport function averageColor(pixels: ArrayLike<number>): string | null {\n let r = 0, g = 0, b = 0, weight = 0;\n for (let i = 0; i + 3 < pixels.length; i += 4) {\n const a = pixels[i + 3];\n if (a < MIN_ALPHA) continue;\n r += pixels[i] * a;\n g += pixels[i + 1] * a;\n b += pixels[i + 2] * a;\n weight += a;\n }\n if (weight === 0) return null;\n return clampLightness(r / weight, g / weight, b / weight);\n}\n\nfunction clampLightness(r: number, g: number, b: number): string {\n const [h, s, l] = rgbToHsl(r / 255, g / 255, b / 255);\n const clamped = Math.min(MAX_LIGHTNESS, Math.max(MIN_LIGHTNESS, l));\n const [r2, g2, b2] = hslToRgb(h, s, clamped);\n const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0');\n return `#${hex(r2)}${hex(g2)}${hex(b2)}`;\n}\n\nfunction rgbToHsl(r: number, g: number, b: number): [number, number, number] {\n const max = Math.max(r, g, b), min = Math.min(r, g, b);\n const l = (max + min) / 2;\n if (max === min) return [0, 0, l];\n const d = max - min;\n const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n let h: number;\n if (max === r) h = (g - b) / d + (g < b ? 6 : 0);\n else if (max === g) h = (b - r) / d + 2;\n else h = (r - g) / d + 4;\n return [h / 6, s, l];\n}\n\nfunction hslToRgb(h: number, s: number, l: number): [number, number, number] {\n if (s === 0) return [l, l, l];\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n const channel = (t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];\n}\n\n/** url → colour once read; null when it could not be (no CORS, failed load). */\nconst colors = new Map<string, string | null>();\nconst pending = new Set<string>();\nconst listeners = new Set<() => void>();\n\nfunction notify(): void {\n for (const l of listeners) l();\n}\n\nfunction readPicture(url: string): void {\n if (colors.has(url) || pending.has(url)) return;\n if (typeof Image === 'undefined' || typeof document === 'undefined') return;\n pending.add(url);\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.referrerPolicy = 'no-referrer';\n const settle = (color: string | null) => {\n pending.delete(url);\n colors.set(url, color);\n notify();\n };\n img.onload = () => {\n try {\n const canvas = document.createElement('canvas');\n canvas.width = SAMPLE_SIZE;\n canvas.height = SAMPLE_SIZE;\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n if (!ctx) { settle(null); return; }\n ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);\n settle(averageColor(ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data));\n } catch (err: unknown) {\n // A tainted canvas: the host sent no CORS header. The character's colour stands.\n debug('avatar', 'picture colour unreadable', { url, error: err instanceof Error ? err.message : String(err) });\n settle(null);\n }\n };\n img.onerror = () => settle(null);\n img.src = url;\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n return () => listeners.delete(listener);\n}\n\n/** The colour for someone with this character and (optional) picture, as far\n * as is known now: until the picture has been read, the character's. */\nexport function personColor(sprite: string | null | undefined, url: string | undefined): string {\n if (url) {\n const read = colors.get(url);\n if (read) return read;\n }\n return colorForSprite(sprite);\n}\n\n/** {@link personColor}, re-rendering once the picture's colour has been read. */\nexport function usePersonColor(sprite: string | null | undefined, url: string | undefined): string {\n useEffect(() => { if (url) readPicture(url); }, [url]);\n return useSyncExternalStore(subscribe, () => personColor(sprite, url), () => colorForSprite(sprite));\n}\n","import type { RosterUser } from './store';\n\n/**\n * Which picture a chat line shows. A message carries its sender's character\n * as of sending (`sprite`), but not their picture: that is looked up from the\n * roster by participant handle, so a line shows the sender's CURRENT picture\n * while they are here and falls back to the character once they have left.\n */\nexport function pictureForAuthor(roster: Record<string, RosterUser>, author: string | undefined): string | undefined {\n if (!author) return undefined;\n for (const u of Object.values(roster)) {\n if (u.author === author && u.avatarUrl) return u.avatarUrl;\n }\n return undefined;\n}\n","import { useState, type CSSProperties, type ReactNode } from 'react';\nimport { spriteUrlFor } from '../lib/characters';\n\ninterface Props {\n /** The person's character: the portrait, and the fallback for `url`. */\n sprite: string | null | undefined;\n /** Their avatar picture (presence `avatar_url`), preferred when present. */\n url?: string;\n alt?: string;\n className: string;\n style?: CSSProperties;\n /** Shown when there is neither a picture nor a character. */\n fallback: ReactNode;\n}\n\n/**\n * Who someone is, as a picture: their avatar picture (Discord, Steam, an\n * upload) when they have one, else their character portrait.\n *\n * The picture is a third-party URL loaded by the browser alone — no referrer,\n * no CORS mode (it is never read back, so it needs none) — and one that fails\n * falls back to the portrait for as long as that URL stays the same. Games\n * never use this: their piece is always the character.\n */\nexport default function AvatarImage({ sprite, url, alt = '', className, style, fallback }: Props) {\n const [failedUrl, setFailedUrl] = useState<string | null>(null);\n if (url && url !== failedUrl) {\n return (\n <img\n src={url}\n alt={alt}\n referrerPolicy=\"no-referrer\"\n className={className}\n style={style}\n draggable={false}\n onError={() => setFailedUrl(url)}\n />\n );\n }\n const portrait = spriteUrlFor(sprite);\n if (!portrait) return <>{fallback}</>;\n return <img src={portrait} alt={alt} crossOrigin=\"anonymous\" className={className} style={style} draggable={false} />;\n}\n","/**\n * What a click on a chat image means.\n *\n * The attachment stays a real `<a href>`, and that is the whole trick. Middle\n * click, ctrl/cmd+click, shift+click and the right-click context menu are\n * browser behaviours on anchors that no handler has to reimplement — and\n * reimplementing them is how you end up with a \"new tab\" that ignores the\n * user's modifier conventions. We only intercept the ONE gesture we want to\n * repurpose, the plain left click, and let the anchor keep the rest.\n *\n * Concretely, on an `<a>`:\n *\n * | gesture | fires | result |\n * | -------------------- | ------------ | ----------------------------- |\n * | left click | `click` | intercepted -> lightbox |\n * | middle click | `auxclick` | never reaches us -> new tab |\n * | ctrl / cmd + click | `click` | NOT intercepted -> new tab |\n * | shift + click | `click` | NOT intercepted -> new window |\n * | right click | `contextmenu`| never reaches us -> menu |\n *\n * Middle click is the case the user asked for explicitly, and it is the one\n * that needs no code at all — a `click` handler simply never sees it. The\n * modifier cases are here because intercepting them would silently break\n * habits people already have.\n */\n\n/** The parts of a mouse event this decision reads. */\nexport interface ClickIntent {\n /** 0 = primary/left. `auxclick` (middle) never reaches a `click` handler. */\n button: number;\n ctrlKey: boolean;\n metaKey: boolean;\n shiftKey: boolean;\n altKey: boolean;\n}\n\n/**\n * True when the click should open the in-page lightbox (and the anchor's\n * default navigation must be prevented).\n *\n * False means \"let the browser do its normal thing with this anchor\" — which\n * covers every modifier gesture above, so the fallback is always the\n * previously-shipped behaviour rather than nothing.\n */\nexport function shouldOpenLightbox(e: ClickIntent): boolean {\n if (e.button !== 0) return false;\n // Any modifier is a deliberate \"not the normal action\" signal.\n if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;\n return true;\n}\n","import React, { useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport { usePortalTarget } from '../lib/portalTarget';\n\n/**\n * Full-view for a chat image, Discord-style: the image pops out above the rest\n * of the site on a plain left click, and everything else about the anchor\n * (middle click, ctrl+click, right-click menu) keeps working — see\n * `lib/chatMedia/imageClickIntent.ts`.\n *\n * **Portals into the fullscreen element when there is one.** Fullscreen is\n * requested on the MediaPlayer root (`Room.tsx`), and only that element and its\n * descendants paint — so a lightbox appended to `document.body` while the video\n * is fullscreen renders to nothing at all. Chat is reachable in fullscreen via\n * `FloatingChat`, so a meme clicked there has to open somewhere visible. This\n * is the same problem `FullscreenPortal` solves for the overlays, but the\n * target is resolved per-open rather than by wrapping, because the lightbox is\n * mounted from a leaf component rather than a sibling of the player.\n *\n * Otherwise it goes to the `PortalTargetContext` target (the room chrome's own\n * root when embedded), falling back to `document.body`.\n *\n * `z-[10000]` is the top of this app's ladder: the point of the feature is to\n * sit above everything.\n */\n\nexport interface ChatImageLightboxProps {\n src: string;\n /** The library title when the image has one; used as the caption and alt. */\n title?: string;\n onClose: () => void;\n}\n\nexport default function ChatImageLightbox({ src, title, onClose }: ChatImageLightboxProps) {\n // Escape closes, and the listener is on `document` so it works no matter\n // where focus went — a click on the backdrop does not focus anything.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n // Stop the key reaching anything behind it. Escape is also fullscreen's\n // own exit gesture, so without this, closing the lightbox in fullscreen\n // drops the viewer out of fullscreen at the same time.\n e.stopPropagation();\n onClose();\n }\n };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, [onClose]);\n\n // The video keeps playing behind the lightbox, deliberately — this is a\n // watch party, and pausing the room because one person looked at a meme\n // would be a synchronized-playback event for everybody.\n const scoped = usePortalTarget();\n const target =\n typeof document === 'undefined' ? null : (document.fullscreenElement ?? scoped ?? document.body);\n if (!target) return null;\n\n return createPortal(\n <div\n className=\"fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-black/85 p-6\"\n onClick={onClose}\n data-testid=\"chat-image-lightbox\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={title || 'Image'}\n >\n <img\n src={src}\n alt={title ?? ''}\n // Clicking the image itself must NOT close: people click to zoom in on\n // detail, and a click-through-to-close makes the image feel like a\n // trap door.\n onClick={(e) => e.stopPropagation()}\n className=\"max-h-full max-w-full rounded object-contain shadow-2xl\"\n data-testid=\"chat-image-lightbox-img\"\n />\n {title && (\n <p className=\"mt-3 max-w-full truncate text-center text-sm text-white/80\">{title}</p>\n )}\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close image\"\n className=\"absolute right-4 top-4 rounded px-3 py-1 text-2xl leading-none text-white/70 hover:text-white focus:outline-none focus:ring-1 focus:ring-white/50\"\n >\n &times;\n </button>\n </div>,\n target,\n );\n}\n","import React, { useState } from 'react';\nimport { type TChatImage } from '@watchparty/shared';\nimport { resolveChatImageUrl } from '../lib/chatMedia';\nimport { shouldOpenLightbox } from '../lib/chatMedia/imageClickIntent';\nimport ChatImageLightbox from './ChatImageLightbox';\n\n/**\n * Renders a chat message's image attachments.\n *\n * ONE component for both chat surfaces (`ChatMessage` in the sidebar and\n * `FloatingMessage` in the fullscreen overlay). Those two already re-implement\n * the avatar/name/text row separately, and that duplication is exactly how a\n * fix lands in one and not the other — attachments do not repeat it.\n *\n * Two rules live here and nowhere else:\n *\n * 1. **Compose the src from the KEY.** The message carries a content-addressed\n * key; `chatImageUrl` re-validates its shape and returns `null` for\n * anything that does not match, so a hostile key renders nothing instead of\n * reaching an `<img src>`. Never interpolate `image.key` directly.\n *\n * 2. **Reserve the layout box before the bytes arrive.** The chat log sits\n * beside a synchronized video player, where a reflow on every image load is\n * very visible. `aspect-ratio` from the sender-measured `w`/`h` holds the\n * space from first paint. A sender that failed to measure sends 0×0; we\n * then fall back to natural flow rather than a collapsed box.\n */\n\n/** Cap the on-screen size so one tall paste can't push the room out of view. */\nconst MAX_H_PX = 240;\nconst MAX_W_PX = 320;\n\nfunction Attachment({ image }: { image: TChatImage }) {\n const [expanded, setExpanded] = useState(false);\n // The CDN base comes from the chat-media layer's own configuration rather\n // than the app's config context, so this component carries no watchparty\n // dependency and moves to the shared package unchanged.\n const src = resolveChatImageUrl(image.key);\n if (!src) return null;\n\n const hasDims = image.w > 0 && image.h > 0;\n // Fit inside the cap while preserving aspect, so the reserved box is the\n // size the image will actually paint at — reserving the intrinsic size\n // would leave a gap that collapses on load, which is the reflow again.\n const scale = hasDims ? Math.min(1, MAX_W_PX / image.w, MAX_H_PX / image.h) : 1;\n\n return (\n <>\n {/* Still a real anchor, deliberately. Middle click, ctrl/cmd+click and the\n right-click menu are then browser behaviours nothing has to\n reimplement — only the plain left click is intercepted, and the href\n remains the fallback for every gesture we do not claim. */}\n <a\n href={src}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className=\"block max-w-full cursor-zoom-in\"\n data-testid=\"chat-image\"\n onClick={(e) => {\n if (!shouldOpenLightbox(e)) return;\n e.preventDefault();\n setExpanded(true);\n }}\n >\n <img\n src={src}\n /* A meme carries the library's title, and it is frequently the ONLY\n text describing the image — a meme-heavy log is unusable with a\n screen reader without it. A pasted screenshot has no title, and an\n invented one would be worse than none, so it stays decorative. */\n alt={image.title ?? ''}\n loading=\"lazy\"\n decoding=\"async\"\n width={hasDims ? Math.round(image.w * scale) : undefined}\n height={hasDims ? Math.round(image.h * scale) : undefined}\n className=\"rounded border border-border-light object-contain bg-bg-alt/30\"\n style={\n hasDims\n ? { aspectRatio: `${image.w} / ${image.h}`, maxWidth: '100%', maxHeight: MAX_H_PX }\n : { maxWidth: '100%', maxHeight: MAX_H_PX }\n }\n />\n </a>\n {expanded && (\n <ChatImageLightbox\n src={src}\n title={image.title}\n onClose={() => setExpanded(false)}\n />\n )}\n </>\n );\n}\n\nexport default function ChatImageAttachments({ images }: { images?: TChatImage[] }) {\n if (!images?.length) return null;\n return (\n <div className=\"flex flex-wrap gap-1 mt-1\" data-testid=\"chat-images\">\n {/* Index in the key: the same image pasted twice is a legitimate\n message and would otherwise collide on a key-only React key. */}\n {images.map((im, i) => <Attachment key={`${im.key}-${i}`} image={im} />)}\n </div>\n );\n}\n","/**\n * Is this chat message mine?\n *\n * By AUTHOR (the sender's participant handle) when both sides have one, by\n * clientId only as a fallback. `clientId` is the socket id: it is reassigned on\n * every reconnect, so comparing on it alone made a viewer's own history render\n * as someone else's after any reload. The handle is derived from the person's\n * account (or device) and survives that.\n *\n * The fallback covers a server or stored history that predates `author`. It is\n * deliberately NOT applied when the message has an author and we know ours:\n * a mismatch there is a real \"not mine\", not missing data.\n */\nexport function isOwnMessage(\n message: { clientId: string; author?: string },\n me: { clientId: string | null; author?: string },\n): boolean {\n if (message.author && me.author) return message.author === me.author;\n return !!me.clientId && message.clientId === me.clientId;\n}\n","import React from 'react';\nimport type { ChatMessage as ChatMessageType} from '../lib/store';\nimport { useAppStore } from '../lib/store';\nimport { usePersonColor } from '../lib/pictureColor';\nimport { pictureForAuthor } from '../lib/avatarPictures';\nimport AvatarImage from './AvatarImage';\nimport ChatImageAttachments from './ChatImageAttachments';\nimport { isOwnMessage } from '../lib/chatOwnership';\n\ninterface Props {\n message: ChatMessageType;\n}\n\nfunction formatTime(ts: number): string {\n const d = new Date(ts);\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n}\n\nconst ChatMessage = React.memo(function ChatMessage({ message }: Props) {\n const myClientId = useAppStore(s => s.me.clientId);\n const myAuthor = useAppStore(s => (s.me.clientId ? s.roster[s.me.clientId]?.author : undefined));\n const isOwn = isOwnMessage(message, { clientId: myClientId, author: myAuthor });\n const pictureUrl = useAppStore(s => pictureForAuthor(s.roster, message.author));\n const nameColor = usePersonColor(message.sprite, pictureUrl);\n \n // A recap is CONTENT, not chrome. Every other system message is a one-line\n // status (\"Ada joined\", \"Now playing: X\") and the minimal styling below is\n // right for those — but a ~450-character recap paragraph rendered at 11px,\n // muted, italic and centred reads as noise and gets skipped over, which is\n // the whole point of having asked for it.\n if (message.isSystem && message.systemType === 'recap') {\n return (\n <div className=\"px-2 py-1.5\">\n <div className=\"border-l-2 border-border-light pl-2.5 py-0.5\">\n <div className=\"text-[10px] uppercase tracking-wide text-text-muted mb-0.5\">\n Recap\n <span className=\"ml-1.5 normal-case tracking-normal opacity-60\">\n {formatTime(message.ts)}\n </span>\n </div>\n <p className=\"text-[13px] text-text-secondary leading-relaxed whitespace-pre-wrap break-words\">\n {message.text}\n </p>\n </div>\n </div>\n );\n }\n\n // System messages render differently - minimal\n if (message.isSystem) {\n return (\n <div className=\"px-2 py-0.5 text-[11px] text-text-muted italic text-center leading-tight\">\n {message.text}\n <span className=\"ml-1 text-[10px] opacity-50\">{formatTime(message.ts)}</span>\n </div>\n );\n }\n\n return (\n <div className={`flex items-start gap-2 px-2 py-1 hover:bg-bg-card/20 ${isOwn ? 'bg-bg-card/30' : ''}`}>\n {/* Avatar */}\n <AvatarImage\n sprite={message.sprite}\n url={pictureUrl}\n alt={message.name}\n className=\"w-8 h-8 rounded-full object-cover flex-shrink-0\"\n fallback={<span className=\"w-8 h-8 bg-bg-alt/50 rounded-full flex-shrink-0\" />}\n />\n\n {/* Message content - inline layout like Discord */}\n <div className=\"flex-1 min-w-0 -mt-0.5\">\n <span className=\"font-medium text-[13px]\" style={{ color: nameColor }}>\n {message.name}\n </span>\n <span className=\"text-[10px] text-text-muted opacity-60 ml-1.5\">\n {formatTime(message.ts)}\n </span>\n {/* An image-only message has empty text; don't render an empty <p>. */}\n {message.text && (\n <p className=\"text-[13px] text-text break-words leading-snug m-0\">{message.text}</p>\n )}\n <ChatImageAttachments images={message.images} />\n </div>\n </div>\n );\n});\n\nexport default ChatMessage;\n","import React, { useRef, useEffect, useCallback } from 'react';\nimport { useAppStore } from '../lib/store';\nimport ChatMessage from './ChatMessage';\n\n/**\n * The scrolling message list: date breaks, stick-to-bottom unless the reader\n * scrolled up, and the empty state.\n *\n * ONE implementation for both chat columns — the app's `ChatPanel` and the\n * embeddable `RoomChrome`. The render path already has a second copy\n * (`FloatingChat`) and CLAUDE.md records what that costs; a third would be\n * the same drift with one more place for it.\n */\nexport default function ChatLog() {\n const chat = useAppStore(s => s.chat);\n const containerRef = useRef<HTMLDivElement>(null);\n const userScrolledUpRef = useRef(false);\n\n const handleScroll = useCallback(() => {\n const container = containerRef.current;\n if (!container) return;\n const { scrollHeight, scrollTop, clientHeight } = container;\n const distanceFromBottom = scrollHeight - scrollTop - clientHeight;\n userScrolledUpRef.current = distanceFromBottom > 40;\n }, []);\n\n // Scroll the LIST, never `scrollIntoView`: that scrolls every scrollable\n // ancestor too, and inside a host page (`RoomChrome`) each new message would\n // yank the host's own scroll position to the chat.\n useEffect(() => {\n const container = containerRef.current;\n if (container && !userScrolledUpRef.current) {\n container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });\n }\n }, [chat.length]);\n\n return (\n <div\n ref={containerRef}\n onScroll={handleScroll}\n className=\"flex-1 overflow-y-auto overflow-x-hidden chat-messages\"\n >\n {chat.length === 0 ? (\n <div className=\"p-4 text-xs text-text-muted text-center\">\n No messages yet. Be the first to say something!\n </div>\n ) : (\n <>\n {chat.map((msg, i) => {\n const prev = i > 0 ? chat[i - 1] : null;\n const showDateBreak = prev && (msg.ts - prev.ts) > 24 * 60 * 60 * 1000;\n return (\n <React.Fragment key={msg.id}>\n {showDateBreak && (\n <div className=\"flex items-center gap-2 px-4 py-2 my-1\">\n <div className=\"flex-1 h-px bg-border\" />\n <span className=\"text-[10px] text-text-muted whitespace-nowrap\">\n {new Date(msg.ts).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}\n </span>\n <div className=\"flex-1 h-px bg-border\" />\n </div>\n )}\n <ChatMessage message={msg} />\n </React.Fragment>\n );\n })}\n </>\n )}\n </div>\n );\n}\n","/**\n * Slash-command registry and input rules for the chat box.\n *\n * Everything here is pure so the behaviour can be tested without a DOM:\n * `apps/ui/vitest.config.ts` runs in a `node` environment and only picks up\n * `.ts` tests, so a rule that lives inside the React component is a rule that\n * never gets tested. ChatInput/SlashCommandPicker consume these functions and\n * own nothing but rendering and focus.\n */\n\n/** How a command's result reaches people. */\nexport type SlashScope =\n /** Result is rendered only for the person who ran it. */\n | 'local'\n /** Result is broadcast to everyone in the room. */\n | 'room';\n\nexport interface SlashCommand {\n /** Command word WITHOUT the leading slash, lowercase. */\n name: string;\n /** One-line help text shown in the picker. */\n description: string;\n /** Shown in the picker as a hint, e.g. '/recap'. */\n usage: string;\n scope: SlashScope;\n /**\n * Command is only meaningful while media is loaded. The picker still lists\n * it (so it is discoverable), but greys it out and refuses to run it — a\n * silently-missing command is worse than a disabled one that explains itself.\n */\n requiresMedia?: boolean;\n}\n\nexport const SLASH_COMMANDS: SlashCommand[] = [\n {\n name: 'recap',\n description: 'Summarise the previous episode of what is loaded',\n usage: '/recap',\n scope: 'room',\n requiresMedia: true,\n },\n {\n name: 'tierlist',\n description: 'Open the tierlist',\n usage: '/tierlist',\n scope: 'local',\n },\n {\n name: 'help',\n description: 'List the available commands',\n usage: '/help',\n scope: 'local',\n },\n];\n\n/** Marker character. Kept as a constant so tests and UI cannot disagree. */\nexport const SLASH = '/';\n\n/**\n * The token the picker should filter on, or null when the picker must stay\n * closed.\n *\n * Open only while the caret is still inside the COMMAND WORD:\n * '' -> null (empty input)\n * 'hello' -> null (ordinary message)\n * 'a /recap' -> null (slash must be the first character; otherwise every\n * URL — \"https://x\" — and every \"and/or\" would pop\n * the picker mid-sentence)\n * '/' -> '' (show the whole list)\n * '/rec' -> 'rec'\n * '/recap ' -> null (a space closes the word: the user is typing\n * arguments now, and a picker covering the input at\n * that point is pure obstruction)\n *\n * Leading whitespace is NOT trimmed first — ' /recap' is a message that\n * happens to contain a slash, and treating it as a command would make the\n * behaviour depend on invisible characters.\n */\nexport function getPickerQuery(text: string): string | null {\n if (!text.startsWith(SLASH)) return null;\n const rest = text.slice(SLASH.length);\n if (/\\s/.test(rest)) return null;\n return rest.toLowerCase();\n}\n\n/**\n * Commands matching `query`, best first.\n *\n * Prefix matches outrank substring matches so typing 'he' puts '/help' at the\n * top rather than whatever merely contains 'he'. Ties break alphabetically so\n * the order is stable and does not depend on registry order.\n */\nexport function filterCommands(\n query: string,\n commands: SlashCommand[] = SLASH_COMMANDS,\n): SlashCommand[] {\n const q = query.toLowerCase();\n if (!q) return [...commands].sort((a, b) => a.name.localeCompare(b.name));\n const scored: Array<{ cmd: SlashCommand; rank: number }> = [];\n for (const cmd of commands) {\n const name = cmd.name.toLowerCase();\n if (name.startsWith(q)) scored.push({ cmd, rank: 0 });\n else if (name.includes(q)) scored.push({ cmd, rank: 1 });\n }\n return scored\n .sort((a, b) => a.rank - b.rank || a.cmd.name.localeCompare(b.cmd.name))\n .map((s) => s.cmd);\n}\n\n/**\n * Input text after completing to `cmd` (Tab, click, or Enter on a highlight).\n *\n * The trailing space matters twice over: it lets the user type arguments\n * immediately, and it makes `getPickerQuery` return null, which is what\n * actually dismisses the picker. Without it the picker stays open over a\n * completed command and Enter would re-complete instead of submitting.\n */\nexport function applyCompletion(cmd: SlashCommand): string {\n return `${SLASH}${cmd.name} `;\n}\n\n/** Wrap an index within [0, len). Returns 0 for an empty list. */\nexport function wrapIndex(index: number, len: number): number {\n if (len <= 0) return 0;\n return ((index % len) + len) % len;\n}\n\nexport type Submission =\n | { kind: 'command'; command: SlashCommand; args: string }\n | { kind: 'chat'; text: string };\n\n/**\n * Decide what pressing Enter means.\n *\n * An UNKNOWN slash word is deliberately sent as an ordinary message rather\n * than rejected. Refusing it would need a whole error-surface of its own, and\n * a typo'd '/recp' landing in chat is self-explanatory and harmless — whereas\n * swallowing input silently is the one outcome a chat box must never have.\n */\nexport function resolveSubmission(\n text: string,\n commands: SlashCommand[] = SLASH_COMMANDS,\n): Submission | null {\n const trimmed = text.trim();\n if (!trimmed) return null;\n if (!trimmed.startsWith(SLASH)) return { kind: 'chat', text: trimmed };\n\n const body = trimmed.slice(SLASH.length);\n const spaceAt = body.search(/\\s/);\n const word = (spaceAt === -1 ? body : body.slice(0, spaceAt)).toLowerCase();\n const args = spaceAt === -1 ? '' : body.slice(spaceAt).trim();\n\n const command = commands.find((c) => c.name.toLowerCase() === word);\n if (!command) return { kind: 'chat', text: trimmed };\n return { kind: 'command', command, args };\n}\n\n/**\n * Why a command cannot run right now, or null when it can.\n * Returned as a message so the caller can show it verbatim.\n */\nexport function commandBlockedReason(\n cmd: SlashCommand,\n ctx: { hasMedia: boolean },\n): string | null {\n if (cmd.requiresMedia && !ctx.hasMedia) {\n return `${SLASH}${cmd.name} needs an episode loaded.`;\n }\n return null;\n}\n","import type { SlashCommand } from '../lib/slashCommands';\n\ninterface Props {\n commands: SlashCommand[];\n activeIndex: number;\n /** Commands needing media are listed but disabled while nothing is loaded. */\n hasMedia: boolean;\n onPick: (cmd: SlashCommand) => void;\n /** Keeps the highlight in sync when the pointer moves over a row. */\n onHover: (index: number) => void;\n listboxId: string;\n}\n\n/**\n * The command list that pops up while the user is typing a slash command.\n *\n * Rendering only — every rule about what to show and in what order lives in\n * `lib/slashCommands.ts` so it can be tested without a DOM.\n *\n * Positioned `bottom-full` (above the input) because the chat box sits at the\n * bottom of the sidebar; a downward menu would open off-screen.\n */\nexport default function SlashCommandPicker({\n commands, activeIndex, hasMedia, onPick, onHover, listboxId,\n}: Props) {\n if (commands.length === 0) return null;\n\n return (\n <ul\n id={listboxId}\n role=\"listbox\"\n aria-label=\"Slash commands\"\n // `list-none m-0 p-0` is load-bearing, not tidiness: styles.css imports\n // Tailwind v4 UTILITIES ONLY, with no preflight/base, so the browser's\n // UA stylesheet still applies `padding-inline-start: 40px` and\n // `margin-block: 1em` to every <ul>. That indented all the rows by 40px.\n // The bullets were invisible (a `display: flex` list item drops its\n // marker box), so it read as a mysterious gap rather than a stray list.\n // `py-1`/`mb-1` follow the resets and win — longhand beats shorthand in\n // Tailwind's generated order.\n className=\"absolute bottom-full left-0 right-0 z-30 max-h-56 overflow-y-auto\n bg-bg-card border border-border-light rounded shadow-lg\n list-none m-0 p-0 py-1 mb-1\"\n >\n {commands.map((cmd, i) => {\n const blocked = Boolean(cmd.requiresMedia) && !hasMedia;\n const active = i === activeIndex;\n return (\n <li\n key={cmd.name}\n id={`${listboxId}-opt-${cmd.name}`}\n role=\"option\"\n aria-selected={active}\n aria-disabled={blocked}\n // onMouseDown, not onClick: the input's blur would otherwise fire\n // first and close the picker before the click ever lands.\n onMouseDown={(e) => { e.preventDefault(); onPick(cmd); }}\n onMouseEnter={() => onHover(i)}\n className={`px-3 py-1.5 cursor-pointer flex items-baseline gap-2 ${\n active ? 'bg-bg-alt' : ''\n } ${blocked ? 'opacity-50' : ''}`}\n >\n <span className=\"text-sm font-semibold text-text shrink-0\">\n {cmd.usage}\n </span>\n <span className=\"text-xs text-text-secondary truncate\">\n {blocked ? 'needs an episode loaded' : cmd.description}\n </span>\n </li>\n );\n })}\n </ul>\n );\n}\n","import React from 'react';\nimport type { PendingImage } from '../lib/chatMedia';\n\n/**\n * The row of not-yet-sent attachments above a chat input.\n *\n * Renders from the local object URL, so the image is visible the instant it is\n * pasted — the upload only happens on submit. Shared by both chat inputs for\n * the same reason `ChatImageAttachments` is: two copies drift.\n */\nexport default function ChatAttachmentStrip({\n pending,\n uploading,\n onRemove,\n}: {\n pending: readonly PendingImage[];\n uploading: boolean;\n onRemove: (id: string) => void;\n}) {\n if (!pending.length) return null;\n return (\n <div className=\"flex flex-wrap gap-1.5 pb-1.5\" data-testid=\"chat-attachment-strip\">\n {pending.map((p) => (\n <div key={p.id} className=\"relative\">\n <img\n src={p.previewUrl}\n alt=\"\"\n className={`h-14 w-14 rounded border border-border-light object-cover ${\n uploading ? 'opacity-50' : ''\n }`}\n />\n <button\n type=\"button\"\n onClick={() => onRemove(p.id)}\n disabled={uploading}\n aria-label=\"Remove attachment\"\n className=\"absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-bg-card border border-border-light text-[10px] text-text-secondary hover:bg-danger-bg hover:text-on-danger-bg disabled:opacity-50\"\n >\n ×\n </button>\n </div>\n ))}\n </div>\n );\n}\n","import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { ClipboardEvent as ReactClipboardEvent, DragEvent as ReactDragEvent } from 'react';\nimport { DEFAULT_CHAT_MAX_IMAGES, type TChatImage } from '@watchparty/shared';\nimport {\n getCdnClient,\n imageFilesFromClipboard,\n imageFilesFromDrop,\n measureImage,\n messageForUploadFailure,\n ownsObjectUrl,\n preflightMessage,\n resolveChatImageUrl,\n uploadAll,\n uploadErrorFields,\n type CatalogueItem,\n type PendingImage,\n} from '../lib/chatMedia';\nimport { MEME_CATALOGUE_PATH } from '../lib/chatMedia/react/MemePicker';\nimport { useAppStore } from '../lib/store';\nimport { warn } from '../lib/debug';\n\n/**\n * Paste/drop image attachments for a chat input.\n *\n * There are TWO chat inputs — the sidebar `ChatInput` and the inline one in\n * `ControlBar` (shown when the sidebar is collapsed and floating chat is on).\n * They already keep separate text/cooldown state, and attaching this behaviour\n * to only one of them is how the two silently diverge. Both consume this hook.\n *\n * Uploads happen on SUBMIT, not on paste. Pasting is cheap and often\n * reconsidered; uploading on paste would push bytes at a CDN with no rate\n * limiting for images the user then deletes. The local preview is an object\n * URL, so the user sees the image immediately either way.\n *\n * Meme picks land in the SAME pending list (`addMeme`). They are already on\n * the CDN, so they cost no upload on submit — but routing them through one\n * list is what makes the cap, the remove button, the disabled-while-uploading\n * state and the send path work identically for both without a second copy of\n * any of it.\n */\nexport function useChatImageAttachments() {\n const [pending, setPending] = useState<PendingImage[]>([]);\n const [uploading, setUploading] = useState(false);\n const pushToast = useAppStore((s) => s.pushToast);\n // Object URLs are revoked on removal and on unmount; without this every\n // pasted image leaks its blob for the lifetime of the tab.\n const liveUrls = useRef(new Set<string>());\n\n useEffect(() => () => {\n for (const url of liveUrls.current) URL.revokeObjectURL(url);\n liveUrls.current.clear();\n }, []);\n\n /**\n * Drop an attachment's preview.\n *\n * Guarded on `ownsObjectUrl` because a meme's `previewUrl` is an ordinary\n * CDN URL, not a blob. Revoking it is a no-op in every browser, but doing so\n * would mean this function had stopped tracking what it actually owns — and\n * the day something here starts depending on that set being accurate is the\n * day it breaks quietly.\n */\n const releasePreview = useCallback((p: PendingImage) => {\n if (!ownsObjectUrl(p)) return;\n URL.revokeObjectURL(p.previewUrl);\n liveUrls.current.delete(p.previewUrl);\n }, []);\n\n const addFiles = useCallback(async (files: File[]) => {\n if (!files.length) return;\n // Read the count from state at apply time, not from the closure, so two\n // fast pastes cannot both see the old length and blow past the cap.\n const accepted: PendingImage[] = [];\n for (const file of files) {\n const rejection = preflightMessage(file);\n if (rejection) {\n pushToast('error', rejection);\n continue;\n }\n const { w, h } = await measureImage(file);\n const previewUrl = URL.createObjectURL(file);\n liveUrls.current.add(previewUrl);\n accepted.push({\n kind: 'file',\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n file, previewUrl, w, h,\n });\n }\n if (!accepted.length) return;\n setPending((prev) => {\n const room = DEFAULT_CHAT_MAX_IMAGES - prev.length;\n if (room <= 0) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n for (const p of accepted) releasePreview(p);\n return prev;\n }\n if (accepted.length > room) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n for (const p of accepted.slice(room)) releasePreview(p);\n }\n return [...prev, ...accepted.slice(0, room)];\n });\n }, [pushToast, releasePreview]);\n\n /**\n * Attach a meme from the shared library.\n *\n * The pick is recorded HERE rather than on send, deliberately: that call is\n * what makes the library's default view popularity-ordered, but it must\n * never be able to delay or fail a message. Keeping it at pick time keeps it\n * out of the send path entirely, instead of relying on a caller remembering\n * not to await it.\n */\n const addMeme = useCallback((item: CatalogueItem) => {\n const previewUrl = resolveChatImageUrl(item.key);\n // `null` means the key failed the content-addressed shape check. A row the\n // renderer would refuse to display must not become an attachment either.\n if (!previewUrl) return;\n setPending((prev) => {\n if (prev.length >= DEFAULT_CHAT_MAX_IMAGES) {\n pushToast('info', `Up to ${DEFAULT_CHAT_MAX_IMAGES} images per message`);\n return prev;\n }\n return [...prev, {\n kind: 'remote',\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n key: item.key,\n previewUrl,\n // The catalogue spells \"unknown\" as null; the wire schema spells it\n // as 0, and the renderer falls back to natural flow on that.\n w: item.width ?? 0,\n h: item.height ?? 0,\n title: item.title || undefined,\n }];\n });\n // Fire-and-forget by construction: `recordUse` returns void and swallows\n // everything, so it cannot be awaited and cannot be why a send fails.\n getCdnClient().catalogue(MEME_CATALOGUE_PATH).recordUse(item.key);\n }, [pushToast]);\n\n const remove = useCallback((id: string) => {\n setPending((prev) => {\n const hit = prev.find((p) => p.id === id);\n if (hit) releasePreview(hit);\n return prev.filter((p) => p.id !== id);\n });\n }, [releasePreview]);\n\n const clear = useCallback(() => {\n setPending((prev) => {\n for (const p of prev) releasePreview(p);\n return [];\n });\n }, [releasePreview]);\n\n /** Paste and drop are the same path — both carry a `DataTransfer`. */\n const onPaste = useCallback((e: ReactClipboardEvent) => {\n const files = imageFilesFromClipboard(e.clipboardData);\n if (!files.length) return; // ordinary text paste — leave it alone\n e.preventDefault();\n void addFiles(files);\n }, [addFiles]);\n\n const onDrop = useCallback((e: ReactDragEvent) => {\n const files = imageFilesFromDrop(e.dataTransfer);\n if (!files.length) return;\n e.preventDefault();\n void addFiles(files);\n }, [addFiles]);\n\n const onDragOver = useCallback((e: ReactDragEvent) => {\n if (e.dataTransfer?.types?.includes('Files')) e.preventDefault();\n }, []);\n\n /**\n * Upload everything and hand back the wire attachments.\n *\n * Returns `null` when an upload failed — the caller must then NOT send the\n * message and must NOT clear the input, so the user keeps their text and can\n * retry rather than losing it to a 401.\n */\n const uploadPending = useCallback(async (): Promise<TChatImage[] | null> => {\n if (!pending.length) return [];\n setUploading(true);\n try {\n return await uploadAll(pending);\n } catch (err: unknown) {\n warn('chat', 'Image upload failed', uploadErrorFields(err));\n // `CdnError.message` is developer-facing and not fit to show verbatim —\n // the reason is what maps to wording a user can act on.\n pushToast('error', messageForUploadFailure(err));\n return null;\n } finally {\n setUploading(false);\n }\n }, [pending, pushToast]);\n\n return {\n pending, uploading, addFiles, addMeme, remove, clear, uploadPending,\n onPaste, onDrop, onDragOver,\n };\n}\n","import React, { useState, useRef, useCallback, useEffect, useMemo, useId } from 'react';\nimport { DEFAULT_CHAT_MAX_LENGTH, DEFAULT_CHAT_COOLDOWN_MS } from '@watchparty/shared';\nimport { sendChat, emitRecapRequest } from '../lib/socket';\nimport { useAppStore } from '../lib/store';\nimport { warmTierlistPosters } from '../lib/tierlistApi';\nimport {\n SLASH_COMMANDS,\n getPickerQuery,\n filterCommands,\n applyCompletion,\n wrapIndex,\n resolveSubmission,\n commandBlockedReason,\n type SlashCommand,\n} from '../lib/slashCommands';\nimport { Icon } from '@wolffm/themes';\nimport SlashCommandPicker from './SlashCommandPicker';\nimport MemePicker from '../lib/chatMedia/react/MemePicker';\nimport ChatAttachmentStrip from './ChatAttachmentStrip';\nimport { useChatImageAttachments } from '../hooks/useChatImageAttachments';\n\nconst MAX_LENGTH = DEFAULT_CHAT_MAX_LENGTH;\nconst COOLDOWN_MS = DEFAULT_CHAT_COOLDOWN_MS;\n\ninterface ChatInputProps {\n /** Slash commands this input offers. Every command is a watchparty-product\n * action (`/recap` reads an episode's prebuilt recap, `/tierlist` opens\n * watchparty's global tierlist), so the embeddable\n * room chrome passes none and a leading `/` is just text. */\n commands?: SlashCommand[];\n}\n\nexport default function ChatInput({ commands = SLASH_COMMANDS }: ChatInputProps) {\n const [text, setText] = useState('');\n const [cooldown, setCooldown] = useState(false);\n // Selection is stored WITH the query it belongs to, so a narrowing query\n // resets the highlight by derivation instead of via an effect that would\n // setState during render and cascade (react-hooks/set-state-in-effect).\n const [selection, setSelection] = useState<{ q: string | null; i: number }>({ q: null, i: 0 });\n // Escape closes the picker without clearing the input. Sticky until the\n // command word changes again, so Escape is not undone by the next keystroke.\n const [dismissed, setDismissed] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n const listboxId = useId();\n const [memeOpen, setMemeOpen] = useState(false);\n // Sticky for the session. The meme library is a separate service from the\n // CDN, so it going down must cost the meme button and nothing else — paste\n // and drop keep working through the exact same composer.\n const [memeUnavailable, setMemeUnavailable] = useState(false);\n\n const attach = useChatImageAttachments();\n\n const mediaId = useAppStore((s) => s.snapshot.mediaId);\n const pushToast = useAppStore((s) => s.pushToast);\n const setTierlistOpen = useAppStore((s) => s.setTierlistOpen);\n const hasMedia = Boolean(mediaId);\n\n const query = getPickerQuery(text);\n const matches = useMemo(\n () => (query === null ? [] : filterCommands(query, commands)),\n [query, commands],\n );\n const pickerOpen = query !== null && !dismissed && matches.length > 0;\n const activeIndex = selection.q === query ? wrapIndex(selection.i, matches.length) : 0;\n const setActiveIndex = useCallback(\n (i: number) => setSelection({ q: query, i }),\n [query],\n );\n\n const runCommand = useCallback((cmd: SlashCommand, args: string) => {\n const blocked = commandBlockedReason(cmd, { hasMedia });\n if (blocked) {\n pushToast('info', blocked);\n return;\n }\n // Execution is intentionally a single seam: commands are dispatched by\n // name here rather than each carrying a closure, so the registry stays a\n // plain data table that tests can import without pulling in the socket.\n switch (cmd.name) {\n case 'help':\n pushToast('info', commands.map((c) => c.usage).join(' '));\n break;\n case 'recap':\n // Fire-and-forget: the server resolves the previous episode, reads the\n // prebuilt recap and broadcasts it to the room as a system message.\n // Anything that goes wrong arrives as a socket error with a recap/*\n // code, which the existing error toast already surfaces — so there is\n // deliberately no local \"loading\" or success state to get out of sync.\n emitRecapRequest();\n break;\n case 'tierlist':\n warmTierlistPosters();\n setTierlistOpen(true);\n break;\n default:\n pushToast('info', `${cmd.usage} is not implemented yet.`);\n }\n void args;\n }, [hasMedia, pushToast, commands, setTierlistOpen]);\n\n const complete = useCallback((cmd: SlashCommand) => {\n setText(applyCompletion(cmd));\n setDismissed(false);\n inputRef.current?.focus();\n }, []);\n\n const handleSubmit = useCallback(async (e: React.FormEvent) => {\n e.preventDefault();\n if (cooldown || attach.uploading) return;\n\n const resolved = resolveSubmission(text, commands);\n // `resolveSubmission` returns null for empty text — which is a legitimate\n // message when images are attached, and the common case for a paste.\n if (!resolved && !attach.pending.length) return;\n if (resolved?.kind === 'command' && attach.pending.length) {\n pushToast('info', 'Send images on their own, not with a command.');\n return;\n }\n\n if (resolved?.kind === 'command') {\n runCommand(resolved.command, resolved.args);\n setText('');\n // No cooldown for commands: the chat rate limit exists to stop message\n // spam, and a local command sends nothing to the room.\n if (!document.hidden && document.hasFocus()) inputRef.current?.focus();\n return;\n }\n\n // Upload attachments BEFORE clearing the input: a failed upload must\n // leave the user's text and images exactly where they were, so a 401 or a\n // flaky network costs a retry rather than the message.\n const images = await attach.uploadPending();\n if (images === null) return;\n\n sendChat(resolved?.text ?? '', images);\n setText('');\n attach.clear();\n setCooldown(true);\n setTimeout(() => setCooldown(false), COOLDOWN_MS);\n\n // Re-focus input only when watchparty actually has focus. If the user\n // hit Enter then alt-tabbed before the setTimeout fired, a late focus()\n // could yank attention back (the focus-trap guard pattern).\n if (!document.hidden && document.hasFocus()) {\n inputRef.current?.focus();\n }\n }, [text, cooldown, runCommand, attach, pushToast, commands]);\n\n const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Escape' && pickerOpen) {\n e.preventDefault();\n setDismissed(true);\n return;\n }\n if (!pickerOpen) return;\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n setActiveIndex(wrapIndex(activeIndex + 1, matches.length));\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n setActiveIndex(wrapIndex(activeIndex - 1, matches.length));\n } else if (e.key === 'Tab') {\n // Tab completes rather than moving focus — the picker is the focus\n // context while it is open.\n e.preventDefault();\n const cmd = matches[activeIndex];\n if (cmd) complete(cmd);\n } else if (e.key === 'Enter') {\n // Enter picks the highlight instead of submitting a half-typed word.\n // The form's onSubmit still runs on the NEXT Enter, once the picker has\n // closed (applyCompletion's trailing space is what closes it).\n e.preventDefault();\n const cmd = matches[activeIndex];\n if (cmd) complete(cmd);\n }\n }, [pickerOpen, matches, activeIndex, complete, setActiveIndex]);\n\n // Pop focus to the chat input from anywhere on the page via a custom event.\n // The Bomberman bundle fires this when the user presses Enter while its\n // canvas had keyboard focus (hadoku-bomberman `FOCUS_CHAT_EVENT`).\n useEffect(() => {\n const onFocusRequest = () => {\n if (document.hidden || !document.hasFocus()) return;\n inputRef.current?.focus();\n inputRef.current?.scrollIntoView({ block: 'nearest' });\n };\n window.addEventListener('watchparty:focus-chat-input', onFocusRequest);\n return () => window.removeEventListener('watchparty:focus-chat-input', onFocusRequest);\n }, []);\n\n const remaining = MAX_LENGTH - text.length;\n // The counter only renders near the limit; reserving its gutter always\n // stopped the text ~48px short of the field's right edge.\n const showCounter = remaining <= 100;\n const activeCmd = pickerOpen ? matches[activeIndex] : undefined;\n\n return (\n <form\n onSubmit={(e) => { void handleSubmit(e); }}\n onPaste={attach.onPaste}\n onDrop={attach.onDrop}\n onDragOver={attach.onDragOver}\n className=\"p-2 border-t border-border\"\n >\n <ChatAttachmentStrip pending={attach.pending} uploading={attach.uploading} onRemove={attach.remove} />\n <div className=\"relative\">\n {pickerOpen && (\n <SlashCommandPicker\n commands={matches}\n activeIndex={activeIndex}\n hasMedia={hasMedia}\n onPick={complete}\n onHover={setActiveIndex}\n listboxId={listboxId}\n />\n )}\n <MemePicker\n open={memeOpen}\n onClose={() => setMemeOpen(false)}\n onPick={attach.addMeme}\n onUnavailable={() => { setMemeUnavailable(true); setMemeOpen(false); }}\n />\n <div className=\"flex items-center gap-1.5\">\n <div className=\"relative flex-1\">\n <input\n ref={inputRef}\n type=\"text\"\n value={text}\n onChange={(e) => { setText(e.target.value); setDismissed(false); }}\n onKeyDown={handleKeyDown}\n maxLength={MAX_LENGTH}\n placeholder=\"Send a message\"\n role=\"combobox\"\n aria-expanded={pickerOpen}\n aria-controls={pickerOpen ? listboxId : undefined}\n aria-activedescendant={activeCmd ? `${listboxId}-opt-${activeCmd.name}` : undefined}\n aria-autocomplete=\"list\"\n className={`w-full bg-bg-card border border-border-light rounded px-3 py-1.5 text-sm focus:outline-none focus:border-text-muted ${showCounter ? 'pr-12' : ''}`}\n />\n {/* Character counter */}\n <span className={`absolute right-2 top-1/2 -translate-y-1/2 text-[10px] ${\n remaining <= 50\n ? remaining <= 0\n ? 'bg-danger-bg text-on-danger-bg px-1 rounded'\n : 'bg-primary-bg text-on-primary-bg px-1 rounded'\n : 'text-text-muted'\n }`}>\n {showCounter && remaining}\n </span>\n </div>\n {!memeUnavailable && (\n <button\n type=\"button\"\n onClick={() => setMemeOpen((v) => !v)}\n aria-label=\"Add a meme\"\n aria-expanded={memeOpen}\n title=\"Add a meme\"\n data-testid=\"meme-button\"\n className={`shrink-0 rounded border border-border-light px-2 py-1.5 text-sm hover:border-primary ${\n memeOpen ? 'text-text border-primary' : 'text-text-secondary'\n }`}\n >\n <Icon name=\"image\" />\n </button>\n )}\n </div>\n </div>\n </form>\n );\n}\n","import { useAppStore, type RosterUser } from '../lib/store';\nimport { usePersonColor } from '../lib/pictureColor';\nimport AvatarImage from './AvatarImage';\n\n/**\n * Simple \"who's online\" panel. Ready-check moved out to ClientStatusOverlay\n * (rendered inside the video container) so it survives chat-closed and\n * fullscreen modes — see apps/ui/src/components/ClientStatusOverlay.tsx.\n */\nexport default function Roster() {\n const roster = useAppStore(s => s.roster);\n const users = Object.values(roster);\n\n if (!users.length) {\n return <div className=\"text-xs text-text-muted\">No users online</div>;\n }\n\n return (\n <div className=\"flex flex-col gap-1\">\n {users.map(u => <RosterRow key={u.clientId} u={u} />)}\n </div>\n );\n}\n\nfunction RosterRow({ u }: { u: RosterUser }) {\n const color = usePersonColor(u.sprite, u.avatarUrl);\n return (\n <div className=\"flex items-center gap-2 py-0.5\">\n <AvatarImage\n sprite={u.sprite}\n url={u.avatarUrl}\n alt={u.name || 'Anon'}\n className=\"w-6 h-6 rounded-full object-cover flex-shrink-0\"\n fallback={<span className=\"w-6 h-6 bg-bg-alt rounded-full flex-shrink-0\" />}\n />\n <span className=\"text-xs font-medium truncate\" style={{ color }}>\n {u.name || 'Anon'}\n </span>\n {u.isLeader && (\n <span className=\"ml-auto px-1.5 py-0.5 rounded text-[9px] font-medium bg-success/20 text-success\">\n Leader\n </span>\n )}\n </div>\n );\n}\n","import { useEffect, useRef, useState } from 'react';\nimport { useAppStore } from '../lib/store';\nimport { debug } from '../lib/debug';\nimport Roster from './Roster';\n\n/**\n * The \"who's here\" toolbar button: an icon with the head count, opening the\n * roster as a popover. Shared by the app's `ChatPanel` and the embeddable\n * `RoomChrome`.\n */\nexport default function RosterButton() {\n const userCount = useAppStore(s => Object.keys(s.roster).length);\n const [open, setOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!open) return;\n function handleClickOutside(e: MouseEvent) {\n if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);\n }\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [open]);\n\n return (\n <div className=\"relative\" ref={ref}>\n <button\n onClick={() => {\n debug('chat', 'User list toggled', { open: !open, userCount });\n setOpen(!open);\n }}\n className=\"p-1.5 rounded text-text-secondary hover:text-white hover:bg-bg-alt transition-colors flex items-center gap-1 cursor-pointer\"\n title=\"Users\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z\" />\n </svg>\n <span className=\"text-xs\">{userCount}</span>\n </button>\n\n {/* Anchored right-0, NOT left-0. The chat column is a 320px `w-80` aside\n pinned to the right edge, and Room.tsx wraps the layout in\n `overflow-hidden` — so anything extending past the column's right\n edge is clipped away and simply never appears. A left-anchored card\n opened from this button (~168px into the toolbar for a leader, who\n also gets the Select Media and Games buttons) would end at 348-388px\n and be invisible, not merely cut off. Opening leftward can overhang\n the centre instead, which is fine: the aside is z-10 over it, so the\n card paints on top and stays inside the clipping wrapper. */}\n {open && (\n <div className=\"absolute top-full right-0 mt-1 bg-bg-card rounded-lg shadow-xl p-3 max-h-64 overflow-y-auto z-50 w-max min-w-[180px] max-w-[220px]\">\n <div className=\"text-[10px] uppercase tracking-wide text-text-muted mb-2\">\n {userCount} online\n </div>\n <Roster />\n </div>\n )}\n </div>\n );\n}\n","import { useEffect } from 'react';\nimport { useAppStore } from '../lib/store';\n\n/** `className` positions the stack. The app pins it to the viewport; the\n * embeddable room chrome keeps it inside its own column rather than over the\n * host's page. */\nexport default function Toasts({ className = 'fixed bottom-4 left-1/2 -translate-x-1/2' }: { className?: string }) {\n const toasts = useAppStore(s => s.toasts);\n const pop = useAppStore(s => s.popToast);\n useEffect(() => {\n const timers = toasts.map(t => setTimeout(() => pop(t.id), 4000));\n return () => { timers.forEach(clearTimeout); };\n }, [toasts, pop]);\n return (\n <div className={`${className} flex flex-col gap-2 z-50`}>\n {toasts.map(t => (\n <div key={t.id} className={`px-3 py-2 rounded text-sm shadow font-medium border ${t.kind==='error' ? 'bg-danger-bg text-on-danger-bg border-danger' : 'bg-bg-card border-border-light text-text'}`}>{t.msg}</div>\n ))}\n </div>\n );\n}\n","import { THEMES, type Theme } from '@wolffm/themes';\n\nconst isTheme = (t: string | null | undefined): t is Theme =>\n typeof t === 'string' && (THEMES as readonly string[]).includes(t);\n\n/**\n * Which `@wolffm/themes` theme the chrome paints, highest precedence first:\n *\n * 1. the host's explicit `theme` prop;\n * 2. the AMBIENT theme — what the host's own `<HadokuThemeRoot>` provides. This\n * is what keeps chrome and host agreeing when the host is not dark;\n * 3. the viewer's saved hadoku theme (our own store), a fallback for a host\n * with no provider;\n * 4. `dark`.\n *\n * An ambient or saved value outside `THEMES` falls through rather than being\n * painted: `data-theme=\"<unknown>\"` matches no stylesheet and renders unthemed.\n * (`HadokuThemeValue.theme` is typed `string`, so the type does not rule it out.)\n */\nexport function resolveChromeTheme(\n explicit: Theme | undefined,\n ambient: string | null | undefined,\n saved: string | null | undefined,\n): Theme {\n if (explicit) return explicit;\n if (isTheme(ambient)) return ambient;\n if (isTheme(saved)) return saved;\n return 'dark';\n}\n","/**\n * Report an element's width now and whenever it changes; returns the cleanup.\n *\n * `ResizeObserver` is NOT assumed. jsdom and happy-dom do not implement it, so\n * an unguarded `new ResizeObserver` in a mount effect throws an UNCAUGHT\n * `ReferenceError` from React's commit phase — vitest reports that as an\n * unhandled error and fails the consumer's whole run, not one test. That shipped\n * in 0.40.5 and turned hadoku-cardtable's CI red on every push (#148), with\n * nothing in cardtable changed. SSR and older embedded webviews lack it too.\n *\n * Without an observer the chrome still gets one correct measurement at mount;\n * it simply does not follow later resizes, which is the right degradation for an\n * environment that cannot report them. The first measurement is taken\n * explicitly rather than relying on the observer's initial callback, so both\n * paths start from the same number.\n */\nexport function observeWidth(\n el: Pick<HTMLElement, 'clientWidth'>,\n onWidth: (width: number) => void,\n): () => void {\n onWidth(el.clientWidth);\n if (typeof ResizeObserver === 'undefined') return () => {};\n const ro = new ResizeObserver(() => onWidth(el.clientWidth));\n ro.observe(el as Element);\n return () => ro.disconnect();\n}\n","import { useAppStore } from '../lib/store';\n\n/**\n * Which name the chrome joins with: the host's `name` prop, trimmed, if\n * non-empty — applied as a SESSION identity, never saved — else auto identity\n * (`ensureAutoIdentity`).\n *\n * This is only what the client OFFERS. For an account the server ignores it\n * and uses the key-registry name, which presence then hands back\n * (`registry_name`), so a host that passes its own registry name and one that\n * passes nothing land on the same name. There is no stored watchparty name to\n * weigh against it any more: watchparty's own `name` pref is what made\n * cardtable and watchparty disagree (2026-10-08).\n */\nexport type JoinNameDecision =\n | { source: 'host'; name: string }\n | { source: 'auto' };\n\nexport function resolveJoinName(hostName: string | null | undefined): JoinNameDecision {\n const host = hostName?.trim() ?? '';\n return host ? { source: 'host', name: host } : { source: 'auto' };\n}\n\n/** Put a resolved join name into the store. A host-supplied name is a SESSION\n * identity: it must not follow the viewer into every other room and host. */\nexport function applyHostName(decision: JoinNameDecision): void {\n if (decision.source !== 'host') return;\n const { setSessionIdentity, sprite } = useAppStore.getState();\n setSessionIdentity(decision.name, sprite);\n}\n","\ninterface IconProps {\n className?: string;\n}\n\nexport function StarIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} fill=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z\" />\n </svg>\n );\n}\n\nexport function PlayIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M8 5v14l11-7z\" />\n </svg>\n );\n}\n\nexport function PauseIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M6 19h4V5H6v14zm8-14v14h4V5h-4z\" />\n </svg>\n );\n}\n\nexport function VolumeMutedIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2\" />\n </svg>\n );\n}\n\nexport function VolumeLowIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.536 8.464a5 5 0 010 7.072\" />\n </svg>\n );\n}\n\nexport function VolumeHighIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728\" />\n </svg>\n );\n}\n\nexport function FullscreenIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\" />\n </svg>\n );\n}\n\nexport function ExitFullscreenIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 9V4H4m0 0l5 5M9 15v5H4m0 0l5-5M15 9V4h5m0 0l-5 5M15 15v5h5m0 0l-5-5\" />\n </svg>\n );\n}\n\nexport function ChevronLeftIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 19l-7-7 7-7\" />\n </svg>\n );\n}\n\nexport function ChevronRightIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n </svg>\n );\n}\n\nexport function SidebarCloseIcon({ className = 'w-4 h-4' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 4v16\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12l-5 4v-8l5 4z\" />\n </svg>\n );\n}\n\nexport function SidebarOpenIcon({ className = 'w-4 h-4' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M20 4v16\" />\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 12l5 4v-8l-5 4z\" />\n </svg>\n );\n}\n\n/** A cross — stops the running game (RoomCornerControls). */\nexport function CloseIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 6l12 12M18 6L6 18\" />\n </svg>\n );\n}\n\nexport function EjectIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <path d=\"M12 5l8 11H4z\" />\n <rect x=\"4\" y=\"17\" width=\"16\" height=\"3\" rx=\"0.5\" />\n </svg>\n );\n}\n\n\n/** Film strip — the media catalog. Same glyph the chat toolbar's\n * \"Select Media\" button uses, so both entry points read as one action. */\nexport function FilmIcon({ className = 'w-5 h-5' }: IconProps) {\n return (\n <svg className={className} fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 4h16a1 1 0 011 1v14a1 1 0 01-1 1H4a1 1 0 01-1-1V5a1 1 0 011-1z\" />\n </svg>\n );\n}\n","import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';\nimport type { CSSProperties, PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';\nimport { useHadokuThemeOptional, type Theme } from '@wolffm/themes';\nimport { ConfigContext, DEFAULT_CONFIG, setRuntimeConfig, type WatchpartyConfig } from '../lib/config';\nimport { PortalTargetContext } from '../lib/portalTarget';\nimport { setAuth } from '../lib/auth';\nimport { useAppStore } from '../lib/store';\nimport { connectAndJoin, leaveRoom, sendAvatarChange } from '../lib/socket';\nimport { loadDisplayNames } from '../lib/characters';\nimport { loadPreferences, syncFromServerIfFirstLoad } from '../lib/preferences';\nimport { fetchRequestedSprite } from '../lib/avatar';\nimport { useAvatarChanges } from '../hooks/useAvatarChanges';\nimport { primeDeviceId } from '../lib/deviceId';\nimport { exposeDebugGlobals } from '../lib/debugGlobals';\nimport { debug, warn, errorFields } from '../lib/debug';\nimport ChatLog from '../components/ChatLog';\nimport ChatInput from '../components/ChatInput';\nimport RosterButton from '../components/RosterButton';\nimport Toasts from '../components/Toasts';\nimport { resolveChromeTheme } from './hostTheme';\nimport { observeWidth } from './observeWidth';\nimport { resolveJoinName, applyHostName } from './hostIdentity';\nimport { SidebarCloseIcon, SidebarOpenIcon } from '../components/controls/Icons';\nimport {\n clampChatWidth, widthFromDrag, widthFromKey, chatLayoutState, type ChatLayoutState,\n CHAT_WIDTH_MIN_PX, CHAT_WIDTH_MAX_PX,\n} from './chatLayout';\n\nexport interface RoomChromeProps {\n /** The watchparty room to join. Keys spelled `<app-id>-<[a-z0-9-]{1,40}>`\n * need no provisioning: the room is created on first join by a friend-tier\n * user, is never listed, and is collected once empty.\n *\n * `<app-id>` must be one watchparty ADMITS — the namespace is an enumerated\n * list (`EPHEMERAL_ROOM_NAMESPACES`), not a shape, so that a mistyped or\n * retired key still fails as `auth/invalid_key` rather than minting a silent\n * empty room. Admitting a new app is one line in hadoku_site's pm2 wrapper\n * and a restart; an unadmitted key is refused and the chrome shows the\n * error, while the host's content (a sibling, not a child) keeps rendering.\n * Do NOT borrow another app's id to get around it: the room id IS the key,\n * so two apps spelling `cardtable-lobby` land in one room and read each\n * other's chat.\n *\n * **Live unprovisioned rooms share ONE cap across every app**\n * (`MAX_EPHEMERAL_ROOMS`, 800), so an app that mints rooms in a loop can\n * refuse its neighbours' joins with `room/full`. Here rather than only in\n * watchparty's ops docs because it is a shared fate an app author should\n * meet while wiring, not during someone else's incident. The server logs\n * `room.full` with a per-namespace breakdown, so the cause is one grep. */\n roomKey: string;\n /** Watchparty's server, e.g. \"https://hadoku.me/watchparty\". Every HTTP call\n * and the socket derive from it. */\n serverOrigin: string;\n /** The same identity `mount()` takes. `userId` must be the viewer's REGISTRY\n * userId (what `@wolffm/prefs-client` whoami returns): the chrome keys the\n * viewer's saved preferences on it, since it has no session to resolve\n * the account from. */\n userType?: 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';\n userId?: string | null;\n /** A `@wolffm/themes` theme name for the chrome. Wins over everything.\n * Without it the chrome follows the host's ambient `<HadokuThemeRoot>`\n * (and re-renders when the host changes theme), then the viewer's saved\n * hadoku theme, then `dark`. Applied to the chrome's own root, never to the\n * host document. Mount a `<HadokuThemeRoot>` above the chrome or pass this:\n * with neither, dev builds warn once. */\n theme?: Theme;\n /** A name to OFFER at join, for a viewer with no key-registry name. A\n * signed-in viewer is always shown under their registry name — the server\n * resolves it from whoami and ignores this — so a host need not pass it.\n * Otherwise: this > an auto-assigned character name, as a SESSION identity\n * never saved to the viewer's preferences. */\n name?: string;\n /** Renders a Leave button that leaves the room and then calls this. Without\n * it there is no button: unmounting the component is how a host leaves. */\n onLeave?: () => void;\n /** Publish watchparty's `window.__*` debug handles (`__SOCKET__`,\n * `__ZUSTAND_STORE__`, …). Off by default; nothing is written to `window`. */\n debug?: boolean;\n /** Called with the chat column's state on mount and whenever the viewer\n * collapses, reopens or resizes it — including on every frame of a drag.\n * `width` is the column's rendered width in CSS px (the narrow rail's while\n * collapsed), i.e. how much of the frame the host's content does NOT get.\n * Only for a host that must re-measure its own layout; collapsing and\n * resizing work without it, and are remembered per viewer. */\n onLayoutChange?: (state: ChatLayoutState) => void;\n /** `false` keeps the chat column open: no Hide chat button, and a viewer's\n * saved collapse is not applied — though it stays saved, so a screen that\n * allows collapsing still opens the way they left it. For a host screen\n * where the chat IS the point (a lobby). Resizing still works. Default true. */\n collapsible?: boolean;\n /** The host's content, rendered where the app renders its player. */\n children: React.ReactNode;\n}\n\n/** How long the join waits for display names, cross-device prefs and the\n * requested character before going ahead with what it has — the same cap\n * `Room.tsx` uses, for the same reason: the first presence broadcast should\n * carry the chosen identity, but a broken prefs server must never block\n * joining. */\nconst BOOT_WAIT_TIMEOUT_MS = 1500;\n\nlet warnedNoThemeSource = false;\n\n/**\n * Watchparty's room — chat, roster, presence and the viewer's own identity —\n * around a host's content, as a component.\n *\n * It is `mount()`'s room with the product removed and the page left alone:\n * no router, no player, no drawers or games, nothing written to `<html>`,\n * `<head>` or `window`, and no navigation. It connects to watchparty's\n * server, not the host's; a host's own socket stays its own.\n *\n * Unmounting leaves the room and closes the socket. The store and socket are\n * module singletons, so ONE chrome per page — a second instance would share,\n * and then tear down, the first one's connection.\n */\nexport function RoomChrome({\n roomKey, serverOrigin, userType, userId, theme, name: hostName, onLeave, onLayoutChange, collapsible = true, debug: debugHandles, children,\n}: RoomChromeProps) {\n const [root, setRoot] = useState<HTMLElement | null>(null);\n const { setFrame, ...layout } = useChatColumnLayout(onLayoutChange, collapsible);\n // Read at join time only: changing the name later must not leave and rejoin.\n const readHostName = useEffectEvent(() => hostName);\n // Identity first, during render: the saved theme is read on this very\n // render, and before `setAuth` the prefs key is unknowable\n // (getPrefsCacheUserId), so the read would come back empty. setAuth only\n // sets module state, so a StrictMode double render is harmless.\n const savedTheme = useMemo(() => {\n setAuth({ userType, userId: userId ?? null });\n return loadPreferences().theme;\n }, [userType, userId]);\n const ambient = useHadokuThemeOptional();\n const ambientTheme = ambient?.theme;\n const resolvedTheme = useMemo(\n () => resolveChromeTheme(theme, ambientTheme, savedTheme),\n [theme, ambientTheme, savedTheme],\n );\n const hasAmbient = ambient !== null;\n useEffect(() => {\n if (import.meta.env.DEV && !warnedNoThemeSource && !theme && !hasAmbient) {\n warnedNoThemeSource = true;\n warn('room-chrome', 'No <HadokuThemeRoot> above RoomChrome and no `theme` prop: the chrome falls back to its own saved theme or dark, which may not match the host.');\n }\n }, [theme, hasAmbient]);\n const config = useMemo<WatchpartyConfig>(\n () => ({ ...DEFAULT_CONFIG, serverOrigin, defaultRoomKey: roomKey }),\n [serverOrigin, roomKey],\n );\n\n useEffect(() => {\n if (debugHandles) exposeDebugGlobals();\n }, [debugHandles]);\n\n // A character picked in the fleet settings popout reaches the room live.\n useAvatarChanges();\n\n // One effect owns the whole connection, so its cleanup is the single place\n // the room is left. Everything non-component code reads (runtime config,\n // auth) is set here, before the socket exists.\n useEffect(() => {\n setRuntimeConfig({ serverOrigin, defaultRoomKey: roomKey });\n // The host resolved the account (whoami) and setAuth took it during\n // render; re-read the prefs under it before joining.\n useAppStore.getState().rehydrateFromPrefs();\n primeDeviceId();\n\n let cancelled = false;\n const namesLoaded = loadDisplayNames();\n void (async () => {\n try {\n await Promise.race([\n Promise.all([\n namesLoaded,\n syncFromServerIfFirstLoad().then((merged) => {\n if (merged) useAppStore.getState().rehydrateFromPrefs();\n }),\n // The character chosen in the settings popout (lib/avatar.ts).\n fetchRequestedSprite().then(sendAvatarChange),\n ]),\n new Promise<void>(r => setTimeout(r, BOOT_WAIT_TIMEOUT_MS)),\n ]);\n } catch (e: unknown) {\n warn('room-chrome', 'pre-join wait threw, proceeding anyway', errorFields(e));\n }\n // StrictMode runs this effect twice; the first pass is cancelled before\n // its wait resolves and must not join.\n if (cancelled) return;\n const decision = resolveJoinName(readHostName());\n useAppStore.getState().ensureAutoIdentity();\n applyHostName(decision);\n const st = useAppStore.getState();\n debug('room-chrome', 'Joining room', { roomKey, name: st.name, sprite: st.sprite });\n connectAndJoin({ room_key: roomKey, name: st.name || undefined, sprite: st.sprite || undefined });\n })();\n\n return () => {\n cancelled = true;\n leaveRoom();\n };\n }, [roomKey, serverOrigin, userType, userId]);\n\n // The host's content is a SIBLING of the chrome column, not a child: the\n // column's stylesheet is scoped to `.wp-room-chrome`, and keeping the host\n // out of that subtree also keeps out what scoping cannot stop — inherited\n // font, colour and `color-scheme`.\n return (\n <ConfigContext.Provider value={config}>\n <PortalTargetContext.Provider value={root}>\n <div ref={setFrame} className=\"wp-room-chrome-frame\">\n <div className=\"wp-room-chrome-frame__content\">{children}</div>\n <aside\n ref={setRoot}\n className=\"wp-room-chrome\"\n data-theme={resolvedTheme}\n data-collapsed={layout.collapsed ? '1' : '0'}\n style={{ '--wp-room-chrome-width': `${layout.state.width}px` } as CSSProperties}\n >\n {layout.collapsed ? (\n <>\n <button\n onClick={layout.toggleCollapsed}\n className=\"wp-room-chrome__icon-btn\"\n title=\"Show chat\"\n aria-label=\"Show chat\"\n aria-expanded={false}\n >\n <SidebarOpenIcon />\n </button>\n {/* Beside the rail, over the host's edge: a refusal (\"Sign in\n to join this room.\") must still be seen with chat hidden. */}\n <Toasts className=\"absolute bottom-2 right-full mr-2 w-72\" />\n </>\n ) : (\n <>\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label=\"Resize chat\"\n aria-valuemin={CHAT_WIDTH_MIN_PX}\n aria-valuemax={CHAT_WIDTH_MAX_PX}\n aria-valuenow={layout.state.width}\n tabIndex={0}\n className=\"wp-room-chrome__resize\"\n data-dragging={layout.dragging ? '1' : '0'}\n onPointerDown={layout.onPointerDown}\n onPointerMove={layout.onPointerMove}\n onPointerUp={layout.onPointerEnd}\n onPointerCancel={layout.onPointerEnd}\n onKeyDown={layout.onKeyDown}\n />\n <div className=\"flex items-center gap-1 px-2 py-1.5 border-b border-border\">\n <RosterButton />\n <div className=\"flex-1\" />\n {onLeave && (\n <button\n onClick={() => { leaveRoom(); onLeave(); }}\n className=\"wp-room-chrome__leave\"\n title=\"Leave room\"\n aria-label=\"Leave room\"\n >\n Leave\n </button>\n )}\n {collapsible && (\n <button\n onClick={layout.toggleCollapsed}\n className=\"wp-room-chrome__icon-btn\"\n title=\"Hide chat\"\n aria-label=\"Hide chat\"\n aria-expanded\n >\n <SidebarCloseIcon />\n </button>\n )}\n </div>\n <ChatLog />\n <ChatInput commands={[]} />\n <Toasts className=\"absolute bottom-14 left-2 right-2\" />\n </>\n )}\n </aside>\n </div>\n </PortalTargetContext.Provider>\n </ConfigContext.Provider>\n );\n}\n\n/**\n * The chat column's collapse and width. Both live in the store, saved per\n * viewer (see chatLayout.ts for why under their own keys); a drag in progress\n * is local state and is saved once, on release, rather than once per frame.\n *\n * Nothing here listens on `window` or `document` — the chrome's contract with\n * its host forbids it (e2e/room-chrome.spec.ts counts them). The drag uses\n * pointer capture on the handle, so moves and the release reach the handle\n * even when the pointer leaves it, and the frame is measured with a\n * ResizeObserver so a narrowing host still keeps `HOST_MIN_PX`.\n */\nfunction useChatColumnLayout(onLayoutChange: RoomChromeProps['onLayoutChange'], collapsible: boolean) {\n const collapsed = useAppStore(s => s.chromeChatCollapsed) && collapsible;\n const savedWidth = useAppStore(s => s.chromeChatWidth);\n const toggleCollapsed = useAppStore(s => s.toggleChromeChatCollapsed);\n const setSavedWidth = useAppStore(s => s.setChromeChatWidth);\n const [frame, setFrame] = useState<HTMLDivElement | null>(null);\n const [frameWidth, setFrameWidth] = useState<number | undefined>(undefined);\n // The drag in progress, held as state rather than a ref: it is read while\n // rendering (`width` follows it), and the release saves `current` once.\n const [drag, setDrag] = useState<{ pointerId: number; startX: number; startWidth: number; current: number } | null>(null);\n\n useEffect(() => {\n if (!frame) return;\n return observeWidth(frame, setFrameWidth);\n }, [frame]);\n\n const width = clampChatWidth(drag?.current ?? savedWidth, frameWidth);\n const state = useMemo(() => chatLayoutState(collapsed, width), [collapsed, width]);\n\n // An effect event, so a host passing a fresh arrow on every render is not\n // told about a layout that did not change.\n const reportLayout = useEffectEvent((s: ChatLayoutState) => onLayoutChange?.(s));\n useEffect(() => { reportLayout(state); }, [state]);\n\n const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (e.button !== 0) return;\n e.preventDefault();\n e.currentTarget.setPointerCapture(e.pointerId);\n setDrag({ pointerId: e.pointerId, startX: e.clientX, startWidth: width, current: width });\n };\n const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag || drag.pointerId !== e.pointerId) return;\n setDrag({ ...drag, current: widthFromDrag(drag.startWidth, drag.startX, e.clientX, frameWidth) });\n };\n const onPointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {\n if (!drag || drag.pointerId !== e.pointerId) return;\n const final = widthFromDrag(drag.startWidth, drag.startX, e.clientX, frameWidth);\n setDrag(null);\n if (final !== savedWidth) setSavedWidth(final);\n };\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const next = widthFromKey(width, e.key, frameWidth);\n if (next === null) return;\n e.preventDefault();\n if (next !== savedWidth) setSavedWidth(next);\n };\n\n return {\n setFrame, collapsed, state, dragging: drag !== null,\n toggleCollapsed, onPointerDown, onPointerMove, onPointerEnd, onKeyDown,\n };\n}\n"],"names":["DEFAULT_CDN_BASE","CHAT_IMAGE_KEY_RE","isChatImageKey","value","DEFAULT_HTTP_PORT","DEFAULT_UI_PORT","DEFAULT_WS_PATH","DEFAULT_CHAT_MAX_LENGTH","DEFAULT_CHAT_COOLDOWN_MS","DEFAULT_CHAT_MAX_IMAGES","_env","Tier","z","MAX_IMAGE_DIMENSION","MAX_IMAGE_TITLE_LENGTH","CORE_EVENTS","ControlKind","EmbedContext","ChatImage","d","SystemMessageType","HealthReport","ClientHealth","BufferClient","VoteValue","PlaylistVideo","PlaylistType","PlaylistConfig","PlaylistVoteProgressItem","PLAYLIST_EVENTS","TierlistItem","TierlistBoard","TierlistPlacedOut","TIERLIST_EVENTS","READY_EVENTS","GAME_EVENTS","CHARACTER_SETS","ENTRIES","s","CHARACTER_ORDER","c","CHARACTER_COLORS","CHARACTER_ART_REV","portraitPath","id","rev","isCharacterId","sprite","DEFAULT_CLIENT_TIME_PING_INTERVAL_MS","DEFAULT_FRAGMENT_DELIVERY_FLAG","FRAGMENT_DELIVERY_MODES","isFragmentDeliveryMode","v","normalizeFragmentDeliveryFlag","raw","o","mode","n","percent","hashClientId","clientId","h","i","resolveDeliveryArm","flag","override","f","SubmissionKind","SubmissionState","SUBMISSION_TRANSITIONS","Submission","SUBMISSION_SOURCE_MAX_CHARS","SUBMISSION_TITLE_MAX_CHARS","SUBMISSION_SERIES_MAX_CHARS","GAME_ID_RE","PROBE_ORIGIN","isSameOriginPath","SameOriginPath","GameEntryBase","GameEntrySchema","games","g","ShowSource","ShowResult","VideoResult","cached","cdnBase","setChatMediaConfig","config","getChatMediaCdnBase","getCdnClient","base","createCdnClient","resolveChatImageUrl","key","ownsObjectUrl","p","ChatImageUploadError","message","uploadErrorMessage","reason","messageForUploadFailure","err","CdnError","preflightMessage","file","type","kindOfType","MAX_BYTES","imageFilesFromClipboard","dt","filesFromClipboard","imageFilesFromDrop","filesFromDrop","measureImage","dims","measureImageBytes","uploadChatImage","opts","result","uploadAll","pending","out","uploadErrorFields","PREVIEW_PX","PREVIEW_GAP","PREVIEW_CHROME_PX","PREVIEW_MAX_IMG_PX","placePreview","tile","panel","viewport","left","boxHeight","top","MEME_CATALOGUE_PATH","HOVER_INTENT_MS","MemePickerPanel","onClose","onPick","onUnavailable","query","setQuery","useState","panelRef","useRef","inputRef","client","items","loading","error","hasMore","loadMore","useCatalogueSearch","rows","useMemo","seen","item","preview","setPreview","hoverTimer","cancelHover","useCallback","hidePreview","canHover","showPreview","immediate","place","useEffect","onKey","e","onDown","pick","isEmpty","jsxs","jsx","src","MemePicker","open","rest","DEFAULT_CONFIG","runtimeConfig","setRuntimeConfig","filtered","_","getRuntimeConfig","ConfigContext","createContext","getServerOrigin","getRuntimeServerOrigin","PortalTargetContext","usePortalTarget","useContext","STORAGE_KEYS","owner","playlistId","mediaId","storage","readStored","writeStored","storedKeys","keys","k","removeStored","readStoredFlag","registry","exposed","registerDebugGlobal","name","publish","exposeDebugGlobals","logHistory","MAX_HISTORY","logQueue","logFlushTimeout","LOG_FLUSH_DELAY","LOG_FLUSH_DELAY_FAST","currentRoomContext","setLogRoomContext","ctx","isReportAllEnabled","isDebugEnabled","stored","getAllowedCategories","categories","formatTime","addToHistory","entry","styles","safeStringify","data","_log","level","consoleFn","alwaysReport","alwaysShow","category","queueLogForServer","allowed","time","suffix","debug","warn","getLogHistory","clearLogHistory","enableDebug","disableDebug","MAX_DATA_SIZE","MAX_BATCH_SIZE","truncateData","json","truncated","vStr","flushLogsToServer","batch","origin","loc","url","prepared","chunk","chunkSize","entrySize","errorFields","delay","_state","getPrefsCacheUserId","setAuth","input","getUserId","voteOwnerScope","colorForSprite","spriteUrlFor","displayNames","loadPromise","loadDisplayNames","res","displayNameFor","isCharacterName","lower","dn","WatchpartyPrefsSchema","watchpartyPrefs","createPrefsClient","THEME_KEYS","sdkCacheKey","appId","uid","identityPendingUnresolved","readSdkCache","stripRetiredNameFromCaches","rewritten","sdkRow","legacyRow","LEGACY_PREFIX","parsed","blob","_retired","next","latestThemeBlob","latestAppBlob","legacyMergedFor","loadPreferences","cacheTheme","cacheApp","themeBlob","appBlob","legacy","readLegacy","merged","anonWritesThisLoad","isAnonymousNow","savePref","themePrefs","syncInFlight","subscribedOnce","syncFromServerIfFirstLoad","writeThrough","rehydrate","themeFromServer","appFromServer","useAppStore","store","themeKeyPost","appKeyPost","sdkThemePost","sdkAppPost","migrateLegacyIfNeeded","legacyKey","deleteLegacy","sdkHasTheme","sdkHasApp","themePatch","appPatch","themeKeys","appKeys","writeFailureWarned","env","CHAT_WIDTH_DEFAULT_PX","CHAT_WIDTH_MIN_PX","CHAT_WIDTH_MAX_PX","HOST_MIN_PX","COLLAPSED_RAIL_PX","KEYBOARD_STEP_PX","clampChatWidth","px","frameWidth","max","widthFromDrag","startWidth","startX","currentX","widthFromKey","width","chatLayoutState","collapsed","savedPrefs","create","set","get","usedSprites","r","snap","rosterUpdates","leaderId","newPlayheadMs","u","copy","m","messages","newVal","delta","rtt","serverTime","clientSendTs","rtts","offsetMs","clientMid","kind","msg","t","state","volume","muted","lang","enabled","style","snapshot","status","progress","results","saving","adoptDraft","prev","itemId","tier","index","row","without","at","gameId","mount","probed","prefs","tab","resolveMediaAsset","relPath","resolveCardImage","thumbnail","path","refreshTierlist","warmed","warmTierlistPosters","img","retryTakenSprite","st","used","free","keepName","isStaleSpriteRefusal","myClientId","seat","AVATAR_EVENT","requestedSpriteOf","settings","fetchRequestedSprite","baseUrl","createAvatarClient","AvatarError","adoptRequestedSprite","listenForAvatarChanges","onChange","target","handler","detail","MAX_FRAME_DEPTH","describeEmbedding","win","depth","w","FRAGMENT_DELIVERY_FLAG","readDeliveryOverride","q","serverFlag","resolvedArm","DELIVERY_CLIENT_ID_KEY","deliveryClientId","existing","fresh","resolveSessionArm","currentDeliveryArm","storedDeviceId","DEVICE_ID_KEY","primeDeviceId","getDeviceId","cachedDeviceId","clearStoredVotes","playlist","removed","video","PONG_DEAD_AFTER_MS","SLEEP_GAP_MS","createLiveness","notePong","l","now","tick","connected","slept","pongAge","socket","timePingInflight","timePingInterval","timePingTimeout","lastJoinPayload","TAB_ID","makeTabId","liveness","lastDisconnect","pendingDisconnectCause","reconnectCount","joinedIdentity","identityEmitInHand","spriteHeldByOwnOtherConnection","hasConnectedOnce","lastReadyMediaId","connectAndJoin","getSocket","payload","sanitizeJoin","bytes","b","socketOrigin","io","livenessTick","initiateTimePing","sid","rejoinPayload","readyMediaId","resolved","onSnapshot","wireCoreHandlers","isServerPlaying","normalizeChatMessage","seqFallback","im","wireSync","stAfter","known","idResult","resolveIdentityReassert","idPayload","newMediaId","ack","readyCount","total","ready","wirePresence","ev","roster","myId","mine","serverName","serverSprite","hasCustomName","nameToUse","stFinal","st3","wireChat","chatMsg","wirePlaylist","wireTierlist","wireReadyCheck","x","wireGame","wireHealth","code","retry","sendChat","text","images","sendAvatarChange","before","after","_lastReasserted","myEntry","wantsName","wantsSprite","cleanupTimers","clearJoinState","leaveRoom","emitRecapRequest","useAvatarChanges","MIN_LIGHTNESS","MAX_LIGHTNESS","SAMPLE_SIZE","MIN_ALPHA","averageColor","pixels","weight","a","clampLightness","rgbToHsl","clamped","r2","g2","b2","hslToRgb","hex","min","channel","colors","listeners","notify","readPicture","settle","color","canvas","subscribe","listener","personColor","read","usePersonColor","useSyncExternalStore","pictureForAuthor","author","AvatarImage","alt","className","fallback","failedUrl","setFailedUrl","portrait","Fragment","shouldOpenLightbox","ChatImageLightbox","title","scoped","createPortal","MAX_H_PX","MAX_W_PX","Attachment","image","expanded","setExpanded","hasDims","scale","ChatImageAttachments","isOwnMessage","me","ts","ChatMessage","React","myAuthor","isOwn","pictureUrl","nameColor","ChatLog","chat","containerRef","userScrolledUpRef","handleScroll","container","scrollHeight","scrollTop","clientHeight","distanceFromBottom","showDateBreak","SLASH_COMMANDS","SLASH","getPickerQuery","filterCommands","commands","scored","cmd","applyCompletion","wrapIndex","len","resolveSubmission","trimmed","body","spaceAt","word","args","command","commandBlockedReason","SlashCommandPicker","activeIndex","hasMedia","onHover","listboxId","blocked","active","ChatAttachmentStrip","uploading","onRemove","useChatImageAttachments","setPending","setUploading","pushToast","liveUrls","releasePreview","addFiles","files","accepted","rejection","previewUrl","room","addMeme","remove","hit","clear","onPaste","onDrop","onDragOver","uploadPending","MAX_LENGTH","COOLDOWN_MS","ChatInput","setText","cooldown","setCooldown","selection","setSelection","dismissed","setDismissed","useId","memeOpen","setMemeOpen","memeUnavailable","setMemeUnavailable","attach","setTierlistOpen","matches","pickerOpen","setActiveIndex","runCommand","complete","handleSubmit","handleKeyDown","onFocusRequest","remaining","showCounter","activeCmd","Icon","Roster","users","RosterRow","RosterButton","userCount","setOpen","ref","handleClickOutside","Toasts","toasts","pop","timers","isTheme","THEMES","resolveChromeTheme","explicit","ambient","saved","observeWidth","el","onWidth","ro","resolveJoinName","hostName","host","applyHostName","decision","setSessionIdentity","SidebarCloseIcon","SidebarOpenIcon","BOOT_WAIT_TIMEOUT_MS","RoomChrome","roomKey","serverOrigin","userType","userId","theme","onLeave","onLayoutChange","collapsible","debugHandles","children","root","setRoot","setFrame","layout","useChatColumnLayout","readHostName","useEffectEvent","savedTheme","useHadokuThemeOptional","ambientTheme","resolvedTheme","cancelled","namesLoaded","savedWidth","toggleCollapsed","setSavedWidth","frame","setFrameWidth","drag","setDrag","reportLayout","final"],"mappings":"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"}