Preset Slots + iCloud Sync — Design¶
Date: 2026-06-18 Status: Approved (design), ready for planning Scope (Strand ①): Reorganize presets into fixed slots (save-game model), sync them + the pin set across the user's devices via iCloud key-value store, make save = pick a slot, and make pinning select the ATV's lean-back rotation. Plus a Reset all Presets → factory escape hatch. Out of scope (Strand ②, separate): designing a fresh, more varied starter-preset set (content/art). Audio (T2b) remains a later increment. Engine/WGSL untouched.
Motivation¶
T2a turned the iPhone/iPad into a live editor-remote for the Apple TV. The natural core identity of the
peripheral is a preset editor — but today custom presets live only on the device that made them
(PresetStore JSON in App-Support; only manual JSON export/import crosses devices), and the built-in vs.
custom split is rigid. Johannes wants: presets that sync across all his devices, a curated set he
pins to drive the ATV's lean-back rotation, and a save-game-slot model where everything is just a
"Preset" he can edit — with the best edits harvested into shipped defaults at update time.
Goal¶
A fixed array of slots replaces the built-in/custom split. Some slots ship pre-filled (the bundled factory set); all are editable. Saving picks a slot (overwrite or fill an empty one). Slots + the pin set sync via iCloud key-value store, so edits made on the phone appear on the iPad and the ATV. Pinned slots form the ATV's auto-cycle rotation (nothing pinned → cycle all). A factory-reset button restores defaults.
Non-Goals (explicit)¶
- Strand ② — the starter-preset content (prettier / more varied factory presets). Separate workstream; this spec ships with the current presets (or a quick first cut) as placeholders in the seeded slots.
- Audio (T2b) — phone-mic → reactivity. Later increment.
- CloudKit / Core Data. Deliberately using iCloud KVS: the fixed-slot model bounds the data (each slot ≈ one small key), so KVS's 1 MB / 1024-key budget is comfortable and the implementation stays tiny.
- Per-device pinned rotations. One shared pin set for now (a future extension if rooms need to differ).
Model¶
A slot is a position 0…N-1 (working N = 24) that is either empty or holds one Preset
(name + PresetParams + tagSignal). Slots replace the built-in/custom collection; AppState.presets
becomes "the filled slots, in slot order".
- Stable slot IDs
slot-00 … slot-23. A filled slot'sPreset.idis its slot id. SoselectedIDstays aString,apply(.jumpTo(id))and the T2a remote protocol (RemoteState.selectedID) are unaffected — selection still travels as a slot-id string. userOwnedper slot. False while a slot tracks the shipped default; set true the moment the user saves into it. This is what makes "your edits are never overwritten by an update" work.- Pin set = a set of slot ids (the curated rotation), shared/global.
- Thumbnails are NOT synced (too big for the KVS budget) — each device renders a slot's 256² thumbnail
locally from its params via the existing
ThumbnailRendererwhen the slot's content arrives/changes.
Storage — iCloud key-value store (NSUbiquitousKeyValueStore)¶
Keys in the default ubiquitous KVS:
slot.NN(NN = 00…23) → JSON{ name, params, tagSignal, userOwned }, or absent = empty slot.pinned→ JSON array of slot ids (the rotation).seedVersion→ Int; the factory version last seeded on this account.
A change made on any device fires NSUbiquitousKeyValueStore.didChangeExternallyNotification; the store
reloads the changed keys into the local model (on main), which re-renders the library and, on the ATV,
re-derives the rotation. synchronize() is called after local writes. The local in-memory model is the
working source of truth; KVS is the cross-device mirror (offline edits sync when iCloud reconnects).
Local fallback: if iCloud is unavailable / the user isn't signed in, the same model persists to a local JSON file (so the app is fully usable offline and on accounts without iCloud); KVS mirrors it when available.
Seeding, updates, and reset¶
- Bundled factory set: a map
slotIndex → Presetfor the pre-filled slots, plus afactoryVersion. (Which slots, and how many, is finalized by Strand ②; the mechanism here is content-agnostic.) - First launch (empty KVS): seed every factory slot (
userOwned = false),seedVersion = factoryVersion. - App update with a newer
factoryVersion: for each factory slot, write the new default only if the local slot is empty oruserOwned == false; leave user-owned slots untouched. Then bumpseedVersion. → New factory presets reach untouched/empty slots automatically; your edits are sacrosanct. - Save into slot N: write the preset, set
userOwned = true. - Reset all Presets → factory (destructive, confirmation
"Are you sure? This will reset all your presets to factory settings."): set every slot to its factory default (or empty where none),userOwned = false, pin set → factory pins,seedVersion = factoryVersion. This propagates via iCloud (a deliberate factory reset everywhere) — the confirmation covers the cross-device intent. Reset is the explicit way to take the latest factory presets when all slots are already user-owned.
Pinning → ATV rotation¶
The pin set selects the ATV's auto-cycle playlist:
- Rotation = the pinned filled slots, in slot order.
- Nothing pinned → cycle all filled slots (the natural default).
- All pinned → cycles all (same as nothing pinned).
PlaylistDirector is already index-based; AmbientController derives its playlist from the pinned subset
(or all filled when empty). The phone toggles a slot's pin (synced via pinned), and the ATV's rotation
updates on the next KVS change.
Save flow & UI¶
- Save → Slot picker: a grid of all slots (filled = thumbnail + name, empty = "+"). Pick a slot → overwrite (confirm if filled) or fill an empty one. Replaces the current Save-as-new / overwrite dialog.
- Library = slot grid (no built-in/custom lists). Tapping a filled slot loads it; a pin toggle (star) sits on each slot. The built-in/custom distinction disappears from the UI.
- Reset all Presets lives as a destructive action in the library/settings overflow.
- The live editor is largely unchanged: select slot → load into
edited; edit; Save → pick slot.
Components (small, focused)¶
SlotStore(shared, host-testable): the fixed[Slot]model — fill / overwrite / clear / pin / unpin / reset, the seeding+update rule (userOwned/seedVersion), and the migration of existing customs. Pure logic over an injectable persistence backend; no iCloud dependency in the core.KVSPresetBackend(device-empirical): wrapsNSUbiquitousKeyValueStore— read/write theslot.NN/pinned/seedVersionkeys, observe external changes, hop to main. Plus the local-JSON fallback backend.PresetMapping(pure, host-testable):Preset ⇄ KVS valueJSON encode/decode (reusesPresetParamsCodable), and the factory-set decode.- Rotation source in
AmbientController/PlaylistDirector: derive the playlist from the pinned subset. - AppState integration:
presets← filled slots;select(id:)by slot id; save targets a slot; pin/unpin; reset. Theedited/baselineworking state is unchanged.
Migration¶
On first launch of the slot model: seed factory slots, then place any existing custom presets into the
remaining empty slots (userOwned = true), up to capacity. Overflow (more customs than free slots — unlikely
for a personal collection) is logged and the extras are kept in a one-off legacy export file rather than lost.
Concurrency¶
KVS notifications and all model mutation hop to main (the model is main-only, like the tick/editor path). Last-writer-wins per key is acceptable for personal slots (simultaneous edits of the same slot on two devices are rare; different slots are independent keys).
Testing & Verification¶
- Host-unit (macOS, TDD):
SlotStore(fill/overwrite/clear; seeding on empty vs. update with a newer factoryVersion respectinguserOwned; reset; migration of customs into empty slots; overflow); pin logic (pinned subset, empty → all-filled fallback, all-pinned); rotation derivation;PresetMappinground-trip; factory-set decode. These carry the coverage, like T2a's protocol/apply/throttle core. - Device-empirical (sign-off, real iCloud account + ≥ 2 devices): edit/save a slot on the phone → it appears on the iPad and the ATV; pin a slot → the ATV rotation changes; Reset all → factory everywhere; works offline then syncs. KVS propagation can't be unit-tested.
Risks¶
- iCloud on the Developer-ID / notarized macOS app (not App Store): iCloud KVS needs an embedded provisioning profile carrying the iCloud (ubiquity-kvstore) entitlement — automatic on iOS/tvOS, an extra signing step on macOS. If it fights the notarized build, ship iPhone/iPad/ATV sync first and land macOS sync as a fast-follow. (Same constraint would apply to CloudKit; not a reason to change course.)
- KVS budget: bounded by design (24 small slots), but the spec keeps a guard — refuse to write a slot whose JSON would blow a per-key cap, surfacing it rather than silently truncating.
- Reset is destructive across devices — mitigated by the explicit confirmation; intent is documented.
- Migration overflow if a user has many customs — logged + exported, not silently dropped.
- Content placeholder: seeded slots ship with current/quick presets until Strand ② lands; flagged so the factory set isn't mistaken for final.
Self-Review¶
Spec coverage: slot model + IDs (Model); KVS keys + change handling + offline fallback (Storage);
seed/update/reset rules (Seeding); pinning→rotation (Pinning); save/library/reset UI (Save flow & UI);
component split (Components); migration; host-vs-device test split (Testing). Placeholders: N = 24
and the count of pre-filled slots are deliberate, user-confirmed working values (24) / Strand-②-owned
(pre-fill count) — not vague TODOs. Consistency: selectedID stays a String slot-id, so the T2a remote
is untouched — stated in Model and consistent with Components. Scope: focused on the slot/sync foundation;
the content redesign (②) and audio (T2b) are explicitly separate.