chore(backlog): add epic TASK-118 — multiplayer everything + pull rApplet to rSpace

Epic with 14 sub-tasks across 5 tiers:
- Tier 1: Pull-to-rSpace for 12 existing multiplayer modules
- Tier 2: Automerge sync for rchoices, rswag, rwallet, rschedule, rnetwork
- Tier 3: Lightweight sync for rdata, rphotos, rtube, rpubs
- Tier 4: Space-scoped linking for rdesign, rdocs, rmeets
- Tier 5: Persistent annotations for rmaps, provision sync for rforum
- Shared folk-applet-catalog.ts component (high priority)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:07:24 -07:00
parent 03de21ddd5
commit 8cdaf77e9a
15 changed files with 673 additions and 0 deletions

View File

@ -0,0 +1,77 @@
---
id: TASK-118
title: 'Epic: Make all rApps multiplayer with "Pull rApplet to rSpace"'
status: To Do
assignee: []
created_date: '2026-03-16 00:05'
labels:
- epic
- multiplayer
- architecture
milestone: Multiplayer Everything
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Ensure every rApp module has:
1. **Multiplayer real-time sync** via existing Automerge/local-first stack — see other participants' changes live
2. **"Pull rApplet to rSpace" button** — a standard UI pattern letting space owners pull/enable an rApp module into their space from a global catalog
## Current State (27 modules)
- **12 already have local-first/Automerge**: rbooks, rcal, rcart, rfiles, rflows, rinbox, rnotes, rsocials, rsplat, rtasks, rtrips, rvote
- **2 use ephemeral WebSocket sync** (no Automerge): rmaps, rnetwork
- **13 have NO real-time sync**: rchoices, rdata, rdesign, rdocs, rforum, rmeets, rphotos, rpubs, rswag, rtube, rwallet, rspace, rschedule
## "Pull rApplet to rSpace" Pattern
A standardized UI component (`folk-applet-pull.ts`) that:
- Shows available rApps as cards in a global catalog
- Space owners can enable/disable modules per-space via PATCH `/:space/modules`
- Each module card shows: name, icon, description, sync status, scope (space/global)
- Enabled modules appear in the space's app switcher
- Uses existing `enabledModules` API in `server/spaces.ts`
## Multiplayer Tiers
### Tier 1 — Already multiplayer (12 modules) — just need "Pull to rSpace" button
rbooks, rcal, rcart, rfiles, rflows, rinbox, rnotes, rsocials, rsplat, rtasks, rtrips, rvote
### Tier 2 — Near-multiplayer, need Automerge integration (5 modules)
- **rchoices**: Add schema + local-first-client for voting sessions, live vote tallies
- **rswag**: Add schema for shared design state, collaborative editing
- **rwallet**: Add schema for shared wallet watchlist, collaborative treasury view
- **rschedule**: Already has schemas, needs local-first-client.ts + component sync
- **rnetwork**: Already has WebSocket, add Automerge doc for CRM data persistence
### Tier 3 — UI-only wrappers, add lightweight sync (4 modules)
- **rdata**: Sync dashboard config/filters across participants
- **rphotos**: Sync album curation, shared selections
- **rtube**: Sync playlists, watch parties, queue state
- **rpubs**: Sync publication drafts, collaborative editing queue
### Tier 4 — External service wrappers, iframe-based (3 modules)
- **rdesign** (Affine): Add space-scoped project linking, cannot sync internal state
- **rdocs** (Docmost): Add space-scoped doc linking
- **rmeets** (Jitsi): Add meeting history/scheduling sync
### Tier 5 — Infrastructure, minimal sync needed (3 modules)
- **rforum**: Provision state only, sync forum URL/status per space
- **rmaps**: Already has ephemeral WebSocket rooms — add persistent map annotations via Automerge
- **rspace**: Core module — canvas state already synced via Automerge in host app
## Architecture Decisions
- All new local-first clients follow the established pattern: `local-first-client.ts` + `schemas.ts` per module
- Document ID format: `{space}:{module}:{collection}`
- "Pull to rSpace" UI reuses existing `PATCH /:space/modules` API
- Shared `folk-applet-catalog.ts` component renders the catalog modal
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Every rApp module has real-time multiplayer sync or a clear reason why not (external iframe wrappers)
- [ ] #2 Standard 'Pull rApplet to rSpace' UI exists in space settings and is accessible from app switcher
- [ ] #3 Space owners can enable/disable any module via the catalog UI
- [ ] #4 All new sync follows established local-first-client.ts + schemas.ts pattern
- [ ] #5 Demo/unauthenticated mode still works as local-only fallback for all modules
<!-- AC:END -->

View File

@ -0,0 +1,47 @@
---
id: TASK-118.1
title: Build shared folk-applet-catalog.ts component
status: To Do
assignee: []
created_date: '2026-03-16 00:05'
labels:
- multiplayer
- ui
- shared
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create a reusable web component that renders the "Pull rApplet to rSpace" catalog modal.
## Component: `lib/folk-applet-catalog.ts`
- Fetches module list from `GET /:space/modules` API
- Renders cards grid: icon, name, description, enabled toggle, scope badge
- Toggle calls `PATCH /:space/modules` with updated `enabledModules` array
- Accessible from space settings and a "+" button in the app switcher
- Shows sync status indicator (multiplayer/local-only/external)
- Requires space owner authentication to toggle; read-only for members
## Shell integration: `server/shell.ts`
- Add "+" button to app switcher nav that opens the catalog modal
- Only visible to space owners (check `ownerDID` from space meta)
## Files to create/modify:
- `lib/folk-applet-catalog.ts` (new)
- `server/shell.ts` (add catalog trigger button)
- `server/index.ts` (register the new component JS)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Catalog modal shows all registered modules with icon, name, description
- [ ] #2 Space owners can toggle modules on/off with immediate effect
- [ ] #3 Non-owners see read-only view of enabled modules
- [ ] #4 App switcher updates when modules are toggled
- [ ] #5 Works in demo mode with local-only toggle (no API call)
<!-- AC:END -->

View File

@ -0,0 +1,42 @@
---
id: TASK-118.10
title: Add lightweight sync to rpubs (collaborative publication queue)
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-3
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rpubs compiles markdown to print-ready pocket books via Typst. Add Automerge sync for shared publication drafts and editorial queue.
## New files:
- `modules/rpubs/schemas.ts` — PubsDoc with publications, editorialQueue, comments
- `modules/rpubs/local-first-client.ts` — CRUD: saveDraft, addToQueue, addComment
## Schema:
```
PubsDoc {
meta: { module: 'pubs', collection: 'editorial', version: 1 }
publications: Record<string, { id, title, markdownContent, status, authorDid, updatedAt }>
editorialQueue: string[]
comments: Record<string, { pubId, authorDid, text, createdAt }[]>
}
```
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Publication drafts sync between editors in real-time
- [ ] #2 Editorial queue shared across space members
- [ ] #3 Comments visible to all members
- [ ] #4 Demo mode works locally
<!-- AC:END -->

View File

@ -0,0 +1,42 @@
---
id: TASK-118.11
title: 'Add space-scoped linking for external wrappers (rdesign, rdocs, rmeets)'
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-4
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
These 3 modules wrap external services (Affine, Docmost, Jitsi) via iframes. We can't sync their internal state, but we can add Automerge docs for space-scoped metadata: which projects/docs/rooms are linked to this space, access history, and meeting scheduling.
## rdesign (Affine)
- Schema: `DesignDoc { linkedProjects: Record<id, { url, name, addedBy }> }`
- Component: Show linked Affine projects, allow adding/removing
## rdocs (Docmost)
- Schema: `DocsDoc { linkedDocuments: Record<id, { url, title, addedBy }> }`
- Component: Show linked Docmost docs, allow adding/removing
## rmeets (Jitsi)
- Schema: `MeetsDoc { meetings: Record<id, { roomName, title, scheduledAt, hostDid, participants[] }>, meetingHistory[] }`
- Component: Schedule meetings, show history, quick-join links
Each needs: schemas.ts, local-first-client.ts, component integration.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Linked external projects/docs/rooms sync across space members
- [ ] #2 Meeting scheduling syncs in real-time
- [ ] #3 Adding/removing links requires authentication
- [ ] #4 Demo mode shows placeholder data
<!-- AC:END -->

View File

@ -0,0 +1,44 @@
---
id: TASK-118.12
title: Add persistent map annotations to rmaps via Automerge
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-5
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rmaps already has ephemeral WebSocket rooms for live location sharing. Add an Automerge doc layer for persistent map annotations (pins, notes, routes, areas) that survive room disconnection.
## New files:
- `modules/rmaps/schemas.ts` — MapsDoc with annotations, savedRoutes, meetingPoints
- `modules/rmaps/local-first-client.ts` — CRUD: addAnnotation, saveRoute, setMeetingPoint
## Schema:
```
MapsDoc {
meta: { module: 'maps', collection: 'annotations', version: 1 }
annotations: Record<string, { id, type: 'pin'|'note'|'area', lat, lng, label, authorDid, createdAt }>
savedRoutes: Record<string, { id, name, waypoints[], authorDid }>
savedMeetingPoints: Record<string, { id, name, lat, lng, setBy }>
}
```
Ephemeral room sync (live location) remains unchanged.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Persistent annotations survive room disconnection
- [ ] #2 Saved routes and meeting points sync via Automerge
- [ ] #3 Ephemeral live location sharing still works unchanged
- [ ] #4 Demo mode works locally
<!-- AC:END -->

View File

@ -0,0 +1,39 @@
---
id: TASK-118.13
title: Add forum provision state sync to rforum
status: To Do
assignee: []
created_date: '2026-03-16 00:07'
labels:
- multiplayer
- tier-5
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rforum provisions Discourse instances on Hetzner. Add minimal Automerge sync for forum provisioning state per space (URL, status, admin info).
## New files:
- `modules/rforum/local-first-client.ts` — wraps existing schemas
## Schema (extend existing):
```
ForumDoc {
meta: { module: 'forum', collection: 'provision', version: 1 }
forums: Record<string, { url, status: 'provisioning'|'active'|'suspended', adminDid, createdAt }>
}
```
Minimal — just syncs which forum is linked to which space.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Forum provision state syncs across space members
- [ ] #2 All members can see forum URL and status
<!-- AC:END -->

View File

@ -0,0 +1,35 @@
---
id: TASK-118.14
title: Add "Pull to rSpace" button to all 12 existing multiplayer modules
status: To Do
assignee: []
created_date: '2026-03-16 00:07'
labels:
- multiplayer
- tier-1
milestone: Multiplayer Everything
dependencies:
- TASK-118.1
parent_task_id: TASK-118
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The 12 modules that already have local-first/Automerge sync (rbooks, rcal, rcart, rfiles, rflows, rinbox, rnotes, rsocials, rsplat, rtasks, rtrips, rvote) need the standardized "Pull rApplet to rSpace" integration.
## What to do:
- Ensure each module's component checks `enabledModules` from space meta
- Add graceful "not enabled" state when module is disabled for a space
- Each module's landing/nav shows correctly in the folk-applet-catalog
This task depends on TASK-118.1 (the catalog component) being built first.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All 12 modules show 'not enabled' state when disabled for a space
- [ ] #2 All 12 modules appear correctly in the applet catalog
- [ ] #3 Enabling/disabling a module immediately updates the app switcher
<!-- AC:END -->

View File

@ -0,0 +1,46 @@
---
id: TASK-118.2
title: Add multiplayer sync to rchoices (voting/ranking sessions)
status: To Do
assignee: []
created_date: '2026-03-16 00:05'
labels:
- multiplayer
- tier-2
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rchoices is currently a stateless voting UI. Add Automerge-backed real-time sync for live collaborative voting sessions.
## New files:
- `modules/rchoices/schemas.ts` — ChoicesDoc with votingSessions, votes, rankings
- `modules/rchoices/local-first-client.ts` — CRUD: createSession, castVote, updateRanking
## Schema design:
```
ChoicesDoc {
meta: { module: 'choices', collection: 'sessions', version: 1 }
sessions: Record<string, { id, title, type: 'vote'|'rank'|'score', options: [], createdBy, createdAt }>
votes: Record<string, { sessionId, participantDid, choices: Record<optionId, number>, updatedAt }>
}
```
## Component updates (`folk-choices-*.ts`):
- Init local-first client, subscribe to doc changes
- Real-time vote tally updates as participants vote
- Show participant count and live results
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Voting sessions sync in real-time between participants
- [ ] #2 Vote tallies update live as votes come in
- [ ] #3 Session creator can configure vote type (single/multi/ranked)
- [ ] #4 Demo mode works with local-only state
<!-- AC:END -->

View File

@ -0,0 +1,46 @@
---
id: TASK-118.3
title: Add multiplayer sync to rswag (collaborative swag design)
status: To Do
assignee: []
created_date: '2026-03-16 00:05'
labels:
- multiplayer
- tier-2
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rswag is a client-side design canvas. Add Automerge sync so multiple space members can collaborate on swag designs.
## New files:
- `modules/rswag/schemas.ts` — SwagDoc with designs, assets, selectedTemplate
- `modules/rswag/local-first-client.ts` — CRUD: saveDesign, updateCanvas, addAsset
## Schema design:
```
SwagDoc {
meta: { module: 'swag', collection: 'designs', version: 1 }
designs: Record<string, { id, name, templateId, canvasState: string, createdBy, updatedAt }>
activeDesignId: string
}
```
## Component updates:
- Init local-first client on connectedCallback
- Debounced save of canvas state changes
- Live cursor/selection indicators for collaborators (stretch)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Design state syncs between participants in real-time
- [ ] #2 Canvas changes debounced and saved via Automerge
- [ ] #3 Design list shared across space members
- [ ] #4 Demo mode works locally
<!-- AC:END -->

View File

@ -0,0 +1,47 @@
---
id: TASK-118.4
title: Add multiplayer sync to rwallet (shared treasury view)
status: To Do
assignee: []
created_date: '2026-03-16 00:05'
labels:
- multiplayer
- tier-2
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rwallet currently renders client-side-only wallet data from Safe Global API. Add Automerge sync for shared watchlists and treasury annotations.
## New files:
- `modules/rwallet/schemas.ts` — WalletDoc with watchedAddresses, annotations, dashboardConfig
- `modules/rwallet/local-first-client.ts` — CRUD: addWatchAddress, setAnnotation, updateConfig
## Schema:
```
WalletDoc {
meta: { module: 'wallet', collection: 'treasury', version: 1 }
watchedAddresses: Record<string, { address, chain, label, addedBy, addedAt }>
annotations: Record<string, { txHash, note, authorDid, createdAt }>
dashboardConfig: { defaultChain, displayCurrency, layout }
}
```
## Component updates (`folk-wallet-viewer.ts`):
- Shared watchlist syncs across space members
- Transaction annotations visible to all
- Dashboard layout preferences synced
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Watched wallet addresses sync across space members
- [ ] #2 Transaction annotations visible to all space members
- [ ] #3 Dashboard config shared (chain, currency, layout)
- [ ] #4 Demo mode works with local-only state
<!-- AC:END -->

View File

@ -0,0 +1,34 @@
---
id: TASK-118.5
title: Add local-first-client to rschedule
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-2
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rschedule already has Automerge schemas but lacks a local-first-client.ts for client-side sync. Add the client and wire it into the 3 components (automation-canvas, reminders-widget, schedule-app).
## New file:
- `modules/rschedule/local-first-client.ts` — wraps existing schemas with sync methods
## Component updates:
- All 3 components init the client, subscribe, and react to remote changes
- Scheduled jobs, reminders, and automations sync in real-time between space members
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 local-first-client.ts created following established pattern
- [ ] #2 All 3 components sync via Automerge
- [ ] #3 Reminders and scheduled jobs visible to all space members in real-time
<!-- AC:END -->

View File

@ -0,0 +1,44 @@
---
id: TASK-118.6
title: Add Automerge persistence to rnetwork CRM data
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-2
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rnetwork currently uses server-stored CRM data with WebSocket visualization. Add Automerge doc for persistent CRM relationship data that syncs via local-first stack alongside the existing WebSocket graph updates.
## New files:
- `modules/rnetwork/schemas.ts` — NetworkDoc with contacts, relationships, delegations
- `modules/rnetwork/local-first-client.ts` — CRUD for CRM data
## Schema:
```
NetworkDoc {
meta: { module: 'network', collection: 'crm', version: 1 }
contacts: Record<string, { did, name, role, tags[], addedBy, addedAt }>
relationships: Record<string, { fromDid, toDid, type, weight, note }>
graphLayout: { positions: Record<did, {x,y}>, zoom, pan }
}
```
Note: Delegations already in PostgreSQL (trust-engine) — this is for CRM metadata only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 CRM contact metadata syncs via Automerge between space members
- [ ] #2 Graph layout positions persist and sync
- [ ] #3 Existing WebSocket delegation UI still works unchanged
- [ ] #4 Demo mode works with local-only data
<!-- AC:END -->

View File

@ -0,0 +1,46 @@
---
id: TASK-118.7
title: Add lightweight sync to rdata (shared analytics dashboard)
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-3
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rdata is a privacy-first analytics dashboard. Add Automerge sync so space members share dashboard configuration and filter state.
## New files:
- `modules/rdata/schemas.ts` — DataDoc with dashboardConfig, savedViews, filterPresets
- `modules/rdata/local-first-client.ts` — CRUD: saveView, updateFilters, setConfig
## Schema:
```
DataDoc {
meta: { module: 'data', collection: 'dashboard', version: 1 }
savedViews: Record<string, { id, name, filters, dateRange, metrics[], createdBy }>
activeViewId: string
sharedFilters: { dateRange, granularity, segments[] }
}
```
## Component updates:
- Dashboard filter changes sync between viewers
- Saved views shared across space members
- "Follow" mode: one member's view reflected to all
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Saved dashboard views sync across space members
- [ ] #2 Filter changes can optionally sync in real-time
- [ ] #3 Demo mode works with local-only state
<!-- AC:END -->

View File

@ -0,0 +1,43 @@
---
id: TASK-118.8
title: Add lightweight sync to rphotos (shared album curation)
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-3
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rphotos wraps Immich for photo display. Add Automerge sync for shared album curation and selections.
## New files:
- `modules/rphotos/schemas.ts` — PhotosDoc with albums, selections, annotations
- `modules/rphotos/local-first-client.ts` — CRUD: createAlbum, addToAlbum, annotatePhoto
## Schema:
```
PhotosDoc {
meta: { module: 'photos', collection: 'curation', version: 1 }
albums: Record<string, { id, name, photoIds[], createdBy, updatedAt }>
selections: Record<string, { photoId, selectedBy[], note }>
activeAlbumId: string
}
```
Photo IDs reference the external Immich instance — this syncs curation metadata only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Shared albums sync across space members
- [ ] #2 Photo selections and annotations visible to all
- [ ] #3 Demo mode works with local-only state
<!-- AC:END -->

View File

@ -0,0 +1,41 @@
---
id: TASK-118.9
title: Add lightweight sync to rtube (shared playlists/watch parties)
status: To Do
assignee: []
created_date: '2026-03-16 00:06'
labels:
- multiplayer
- tier-3
milestone: Multiplayer Everything
dependencies: []
parent_task_id: TASK-118
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
rtube is a community video hosting UI. Add Automerge sync for shared playlists and watch party queue state.
## New files:
- `modules/rtube/schemas.ts` — TubeDoc with playlists, watchParty, queue
- `modules/rtube/local-first-client.ts` — CRUD: createPlaylist, addToPlaylist, updateQueue
## Schema:
```
TubeDoc {
meta: { module: 'tube', collection: 'playlists', version: 1 }
playlists: Record<string, { id, name, videoIds[], createdBy, updatedAt }>
watchParty: { active: boolean, currentVideoId, position, hostDid, participants[] }
queue: string[]
}
```
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Playlists sync across space members
- [ ] #2 Watch party state (current video, position) syncs in real-time
- [ ] #3 Demo mode works with local-only state
<!-- AC:END -->