Zum Inhalt

Preset Slots + iCloud Sync — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax. Pure-logic tasks (PresetSlots model, factory seeding, pin/rotation, KVS value mapping, migration) are full TDD on the macOS host; the iCloud-KVS backend + entitlements + UI are build-verified and confirmed by device sign-off (cross-device iCloud propagation can't be unit-tested).

Goal: Reorganize presets into a fixed array of editable slots (save-game model) that sync across the user's devices via iCloud key-value store, make save = pick a slot, and make pinned slots drive the ATV's lean-back rotation — with a Reset all → factory escape hatch.

Architecture: A pure PresetSlots value type holds the fixed slot array + pin set + seed version + per-slot userOwned flag, with all logic (fill/overwrite/clear/pin/rotation/seed/reset/migrate) host-testable. A SlotBackend protocol persists it — KVSSlotBackend (iCloud, device-empirical) and a FileSlotBackend offline fallback, with an InMemorySlotBackend for unit tests. SlotStore (@Observable) wires model + backend + external-change observation. AppState swaps its builtIns + customs for the slot model; the library becomes a slot grid, save a slot picker; AmbientController cycles the pinned subset.

Tech Stack: Swift 5, NSUbiquitousKeyValueStore (iCloud KVS), SwiftUI, XCTest (macOS host), XcodeGen.

Spec: docs/superpowers/specs/2026-06-18-preset-slots-icloud-sync-design.md

File map

  • Create Sources/Model/PresetSlots.swift — pure slot model + all logic (shared, host-tested).
  • Create Sources/Model/FactorySlots.swift — loads the bundled factory manifest (shared, host-tested).
  • Create Resources/Factory/factory.json — bundled ordered factory presets (placeholder content; Strand ② curates).
  • Create Sources/Model/SlotBackend.swift — SlotBackend protocol + SlotPayload Codable + InMemorySlotBackend (shared/test).
  • Create Sources/Model/KVSSlotBackend.swift — NSUbiquitousKeyValueStore backend (device-empirical).
  • Create Sources/Model/FileSlotBackend.swift — local JSON fallback backend (host-testable).
  • Create Sources/Model/SlotStore.swift — @Observable store: model + backend + observation (apply logic host-tested).
  • Modify Sources/Model/AppState.swift — slots replace builtIns + customs; select/save/pin/reset by slot; migration.
  • Modify Sources/Ambient/AmbientController.swift — playlist = pinned rotation (derived), not an init snapshot.
  • Modify Sources/Library/LibraryView.swift — slot grid + per-slot pin toggle + Reset-all action (read current file first).
  • Modify Sources/Library/SaveDialog.swift — save → slot picker (read current file first).
  • Modify apple/PhysarumApp/project.yml — per-SDK CODE_SIGN_ENTITLEMENTS; add iCloud-KVS entitlement files.
  • Create Physarum.iOS.entitlements, Physarum.tvOS.entitlements — iCloud ubiquity-kvstore entitlement.
  • Tests: Tests/PresetSlotsTests.swift, Tests/FactorySlotsTests.swift, Tests/SlotBackendTests.swift, Tests/SlotStoreApplyTests.swift, Tests/SlotMigrationTests.swift.

Slot identity convention (used throughout): slot index 0…23; slot id = String(format: "slot-%02d", index) → slot-00 … slot-23. A filled slot's Preset.id is its slot id. AppState.selectedID stays a String (a slot id), so the T2a remote protocol (RemoteState.selectedID) is unaffected.


Task 1: PresetSlots model — fill / overwrite / clear / userOwned

Files: Create Sources/Model/PresetSlots.swift; Test Tests/PresetSlotsTests.swift.

  • [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum

final class PresetSlotsTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
    }

    func testFillAssignsSlotIdAndIsNotUserOwned() {
        var s = PresetSlots()
        XCTAssertEqual(s.count, 24)
        s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
        XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
        XCTAssertEqual(s.preset(at: 3)?.name, "Aurora")
        XCTAssertFalse(s.isUserOwned(3))
        XCTAssertEqual(s.filled.map { $0.id }, ["slot-03"])
    }

    func testOverwriteMarksUserOwnedAndKeepsSlotId() {
        var s = PresetSlots()
        s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
        s.overwrite(slotID: "slot-03", preset: preset("Mine"))
        XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
        XCTAssertEqual(s.preset(at: 3)?.name, "Mine")
        XCTAssertTrue(s.isUserOwned(3))
    }

    func testClearEmptiesSlot() {
        var s = PresetSlots()
        s.fill(index: 3, preset: preset("Aurora"), userOwned: true)
        s.clear(slotID: "slot-03")
        XCTAssertNil(s.preset(at: 3))
        XCTAssertFalse(s.isUserOwned(3))
        XCTAssertTrue(s.filled.isEmpty)
    }

    func testFirstEmptyIndexFindsLowestGap() {
        var s = PresetSlots()
        s.fill(index: 0, preset: preset("A"), userOwned: false)
        s.fill(index: 1, preset: preset("B"), userOwned: false)
        XCTAssertEqual(s.firstEmptyIndex, 2)
    }
}
  • [ ] Step 2: Run → fails (PresetSlots undefined). xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/PresetSlotsTests.

  • [ ] Step 3: Implement PresetSlots.swift

import PhysarumEngine

/// The fixed, save-game-style preset store: 24 slots, each empty or holding one Preset, plus the pinned
/// set, the seed version, and a per-slot `userOwned` flag (false while a slot tracks the shipped default,
/// true once the user has saved into it — that flag is what makes "an update never overwrites your edits"
/// work). Pure value type: no persistence, no iCloud — `SlotStore` adds those. Host-testable.
struct PresetSlots: Equatable {
    static let count = 24
    static func slotID(_ index: Int) -> String { String(format: "slot-%02d", index) }
    static func index(of slotID: String) -> Int? { Int(slotID.dropFirst("slot-".count)) }

    private struct Slot: Equatable { var preset: Preset?; var userOwned: Bool }
    private var slots: [Slot]
    private(set) var pinned: Set<String> = []
    private(set) var seedVersion: Int = 0

    init() { slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count) }

    var count: Int { slots.count }
    func preset(at index: Int) -> Preset? { slots.indices.contains(index) ? slots[index].preset : nil }
    func isUserOwned(_ index: Int) -> Bool { slots.indices.contains(index) ? slots[index].userOwned : false }

    /// The filled presets, in slot order (this is what the library shows and selection resolves against).
    var filled: [Preset] { slots.compactMap { $0.preset } }

    var firstEmptyIndex: Int? { slots.firstIndex { $0.preset == nil } }

    mutating func fill(index: Int, preset: Preset, userOwned: Bool) {
        guard slots.indices.contains(index) else { return }
        var p = preset; p.id = Self.slotID(index)   // a slot's preset id IS its slot id (stable identity)
        slots[index] = Slot(preset: p, userOwned: userOwned)
    }

    mutating func overwrite(slotID: String, preset: Preset) {
        guard let i = Self.index(of: slotID) else { return }
        fill(index: i, preset: preset, userOwned: true)   // a user save always marks the slot owned
    }

    mutating func clear(slotID: String) {
        guard let i = Self.index(of: slotID), slots.indices.contains(i) else { return }
        slots[i] = Slot(preset: nil, userOwned: false)
        pinned.remove(slotID)
    }
}
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): PresetSlots model — fill/overwrite/clear with userOwned.

Task 2: Pinning + rotation

Files: Modify Sources/Model/PresetSlots.swift; Test add to Tests/PresetSlotsTests.swift.

  • [ ] Step 1: Failing test (append)
extension PresetSlotsTests {
    func testPinUnpinAndRotation() {
        var s = PresetSlots()
        s.fill(index: 0, preset: preset("A"), userOwned: false)
        s.fill(index: 1, preset: preset("B"), userOwned: false)
        s.fill(index: 2, preset: preset("C"), userOwned: false)
        XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-01", "slot-02"])  // nothing pinned → all filled

        s.pin("slot-02"); s.pin("slot-00")
        XCTAssertTrue(s.isPinned("slot-00"))
        XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-02"])  // pinned subset, in slot order

        s.unpin("slot-00")
        XCTAssertEqual(s.rotation.map { $0.id }, ["slot-02"])
    }

    func testPinIsIdempotentAndClearingRemovesPin() {
        var s = PresetSlots()
        s.fill(index: 0, preset: preset("A"), userOwned: true)
        s.pin("slot-00"); s.pin("slot-00")
        XCTAssertEqual(s.pinned.count, 1)
        s.clear(slotID: "slot-00")
        XCTAssertFalse(s.isPinned("slot-00"))     // clearing a slot drops its pin
        XCTAssertTrue(s.rotation.isEmpty)
    }
}
  • [ ] Step 2: Run → fails (pin/rotation undefined).

  • [ ] Step 3: Implement (add to PresetSlots)

    func isPinned(_ slotID: String) -> Bool { pinned.contains(slotID) }
    mutating func pin(_ slotID: String) { if preset(atSlotID: slotID) != nil { pinned.insert(slotID) } }
    mutating func unpin(_ slotID: String) { pinned.remove(slotID) }

    private func preset(atSlotID id: String) -> Preset? { Self.index(of: id).flatMap { preset(at: $0) } }

    /// The ATV lean-back rotation: the pinned filled slots in slot order; nothing pinned → all filled.
    var rotation: [Preset] {
        let pins = slots.compactMap { $0.preset }.filter { pinned.contains($0.id) }
        return pins.isEmpty ? filled : pins
    }
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): pinning + ATV rotation (pinned subset, empty → all).

Task 3: Seeding, version-gated updates, and factory reset

Files: Modify Sources/Model/PresetSlots.swift; Test add to Tests/PresetSlotsTests.swift.

Factory is the seed input: an ordered list of presets (slot 0..k-1) + a version + the factory pin ids.

  • [ ] Step 1: Failing test (append)
extension PresetSlotsTests {
    private func factory(_ names: [String], version: Int, pins: Set<String> = []) -> PresetSlots.Factory {
        .init(presets: names.map { preset($0) }, version: version, pinnedSlotIDs: pins)
    }

    func testSeedFillsEmptyOnFirstRun() {
        var s = PresetSlots()
        s.seed(factory(["A", "B"], version: 1))
        XCTAssertEqual(s.filled.map { $0.name }, ["A", "B"])
        XCTAssertEqual(s.seedVersion, 1)
        XCTAssertFalse(s.isUserOwned(0))   // seeded slots track the default
    }

    func testSeedUpdateSkipsUserOwnedButFillsUntouched() {
        var s = PresetSlots()
        s.seed(factory(["A", "B"], version: 1))
        s.overwrite(slotID: "slot-00", preset: preset("Mine"))   // user edits slot 0 → owned
        s.seed(factory(["A2", "B2", "C2"], version: 2))          // newer factory
        XCTAssertEqual(s.preset(at: 0)?.name, "Mine")            // user-owned slot untouched
        XCTAssertEqual(s.preset(at: 1)?.name, "B2")              // untouched slot takes new default
        XCTAssertEqual(s.preset(at: 2)?.name, "C2")              // new default fills empty slot
        XCTAssertEqual(s.seedVersion, 2)
    }

    func testSeedNoOpWhenVersionNotNewer() {
        var s = PresetSlots()
        s.seed(factory(["A"], version: 2))
        s.seed(factory(["X"], version: 2))   // same version → no change
        XCTAssertEqual(s.preset(at: 0)?.name, "A")
    }

    func testResetToFactoryClearsUserOwnedAndPins() {
        var s = PresetSlots()
        s.seed(factory(["A", "B"], version: 1, pins: ["slot-00"]))
        s.overwrite(slotID: "slot-00", preset: preset("Mine"))
        s.pin("slot-00")
        s.resetToFactory(factory(["A", "B"], version: 1, pins: ["slot-01"]))
        XCTAssertEqual(s.preset(at: 0)?.name, "A")          // back to factory
        XCTAssertFalse(s.isUserOwned(0))                    // ownership cleared
        XCTAssertEqual(s.pinned, ["slot-01"])               // pins reset to factory pins
    }
}
  • [ ] Step 2: Run → fails (Factory/seed/resetToFactory undefined).

  • [ ] Step 3: Implement (add to PresetSlots)

    struct Factory: Equatable {
        var presets: [Preset]            // slot 0..presets.count-1
        var version: Int
        var pinnedSlotIDs: Set<String> = []
    }

    /// First run (empty) or an update with a newer factory version: write each factory preset into its
    /// slot ONLY if that slot is empty or not user-owned. User-owned slots are sacrosanct. Pins are only
    /// applied on the very first seed (when seedVersion was 0), so an update never re-pins behind the user.
    mutating func seed(_ factory: Factory) {
        guard factory.version > seedVersion else { return }
        let firstSeed = seedVersion == 0
        for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
            if slots[i].preset == nil || !slots[i].userOwned {
                fill(index: i, preset: preset, userOwned: false)
            }
        }
        if firstSeed { pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil } }
        seedVersion = factory.version
    }

    /// Deliberate factory reset (the "Reset all Presets" button): every slot back to its factory default
    /// (or empty), ownership cleared, pins back to factory pins.
    mutating func resetToFactory(_ factory: Factory) {
        slots = Array(repeating: Slot(preset: nil, userOwned: false), count: Self.count)
        for (i, preset) in factory.presets.enumerated() where slots.indices.contains(i) {
            fill(index: i, preset: preset, userOwned: false)
        }
        pinned = factory.pinnedSlotIDs.filter { preset(atSlotID: $0) != nil }
        seedVersion = factory.version
    }
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): version-gated seeding + factory reset (userOwned-respecting).

Task 4: Migrate existing custom presets into empty slots

Files: Modify Sources/Model/PresetSlots.swift; Test Tests/SlotMigrationTests.swift.

  • [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum

final class SlotMigrationTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
    }

    func testMigrateFillsEmptySlotsAndMarksUserOwned() {
        var s = PresetSlots()
        s.seed(.init(presets: [preset("Factory0"), preset("Factory1")], version: 1))
        let overflow = s.migrate(customs: [preset("Old A"), preset("Old B")])
        XCTAssertEqual(s.preset(at: 2)?.name, "Old A")   // first empty after the 2 factory slots
        XCTAssertEqual(s.preset(at: 3)?.name, "Old B")
        XCTAssertTrue(s.isUserOwned(2))                  // migrated customs are the user's
        XCTAssertTrue(overflow.isEmpty)
    }

    func testMigrateReturnsOverflowWhenNoRoom() {
        var s = PresetSlots()
        // Fill all 24 slots so there is no room.
        for i in 0..<PresetSlots.count { s.fill(index: i, preset: preset("F\(i)"), userOwned: false) }
        let overflow = s.migrate(customs: [preset("Homeless")])
        XCTAssertEqual(overflow.map { $0.name }, ["Homeless"])   // returned, not lost
    }
}
  • [ ] Step 2: Run → fails (migrate undefined).

  • [ ] Step 3: Implement (add to PresetSlots)

    /// One-shot migration of the pre-slot custom presets into the lowest empty slots (marked userOwned so
    /// an update never clobbers them). Returns any that didn't fit — the caller exports them rather than
    /// dropping the user's work silently.
    mutating func migrate(customs: [Preset]) -> [Preset] {
        var overflow: [Preset] = []
        for custom in customs {
            if let i = firstEmptyIndex { fill(index: i, preset: custom, userOwned: true) }
            else { overflow.append(custom) }
        }
        return overflow
    }
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): migrate existing customs into empty slots (overflow returned).

Task 5: Factory manifest + FactorySlots loader

Files: Create Resources/Factory/factory.json, Sources/Model/FactorySlots.swift; Test Tests/FactorySlotsTests.swift. Modify project.yml (bundle the new resource folder).

The factory manifest is a bundled, ordered list of presets + a version. Placeholder content for Strand ①: reuse the current bundled presets (a chosen 24-cut, in a chosen order); Strand ② replaces the contents. The loader is content-agnostic.

  • [ ] Step 1: Author Resources/Factory/factory.json as { "version": 1, "pinnedSlotIDs": [], "presets": [ <up to 24 Preset JSON objects, in slot order> ] }. For the placeholder, copy the JSON bodies of the 24 current Resources/Presets/*.json you judge best/most varied into presets (Strand ② curates later). Each entry is a full Preset JSON (the same schema PresetCatalog decodes).

  • [ ] Step 2: Failing test

import XCTest
import PhysarumEngine
@testable import Physarum

final class FactorySlotsTests: XCTestCase {
    func testLoadsBundledFactoryManifest() {
        let f = FactorySlots.load()
        XCTAssertGreaterThan(f.version, 0)
        XCTAssertFalse(f.presets.isEmpty)
        XCTAssertLessThanOrEqual(f.presets.count, PresetSlots.count)   // never more presets than slots
    }
}
  • [ ] Step 3: Implement FactorySlots.swift
import Foundation
import PhysarumEngine

/// Loads the bundled factory manifest (`Resources/Factory/factory.json`) into a `PresetSlots.Factory`.
/// Content-agnostic: Strand ② edits the manifest, not this loader. Falls back to an empty (version-0)
/// factory if the resource is missing, so the app still launches.
enum FactorySlots {
    private struct Manifest: Decodable { var version: Int; var pinnedSlotIDs: [String]; var presets: [Preset] }

    static func load(bundle: Bundle = .main) -> PresetSlots.Factory {
        guard let url = bundle.url(forResource: "factory", withExtension: "json", subdirectory: "Factory")
                ?? bundle.url(forResource: "factory", withExtension: "json"),
              let data = try? Data(contentsOf: url),
              let m = try? JSONDecoder().decode(Manifest.self, from: data)
        else { return .init(presets: [], version: 0) }
        let presets = Array(m.presets.prefix(PresetSlots.count))   // never exceed the slot count
        return .init(presets: presets, version: m.version, pinnedSlotIDs: Set(m.pinnedSlotIDs))
    }
}
  • [ ] Step 4: Add the resource to project.yml targets.Physarum.sources (folder reference, like Resources/Presets): - path: Resources/Factory with type: folder. Run xcodegen generate.

  • [ ] Step 5: Run → passes. Commit feat(slots): bundled factory manifest + FactorySlots loader.


Task 6: SlotBackend protocol + payload mapping + InMemorySlotBackend

Files: Create Sources/Model/SlotBackend.swift; Test Tests/SlotBackendTests.swift.

The backend persists a flat SlotPayload (per-slot JSON + pins + seedVersion). PresetSlots ⇄ SlotPayload is pure and host-tested; InMemorySlotBackend lets SlotStore be unit-tested without iCloud.

  • [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum

final class SlotBackendTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
    }

    func testPayloadRoundTripPreservesSlotsPinsVersion() {
        var s = PresetSlots()
        s.seed(.init(presets: [preset("A"), preset("B")], version: 3, pinnedSlotIDs: ["slot-01"]))
        s.overwrite(slotID: "slot-00", preset: preset("Mine"))
        let restored = PresetSlots(payload: s.payload)
        XCTAssertEqual(restored, s)
    }

    func testInMemoryBackendPersists() {
        let backend = InMemorySlotBackend()
        var s = PresetSlots()
        s.fill(index: 5, preset: preset("E"), userOwned: true)
        backend.write(s.payload)
        XCTAssertEqual(PresetSlots(payload: backend.read()), s)
    }
}
  • [ ] Step 2: Run → fails (payload/SlotPayload/InMemorySlotBackend undefined).

  • [ ] Step 3: Implement SlotBackend.swift

import PhysarumEngine

/// The flat, codable mirror of a `PresetSlots` — one entry per filled slot, plus pins and seed version.
/// This is exactly what a backend writes/reads (each backend maps it to its medium: KVS keys, a JSON file).
struct SlotPayload: Codable, Equatable {
    struct Entry: Codable, Equatable { var index: Int; var preset: Preset; var userOwned: Bool }
    var entries: [Entry] = []
    var pinned: [String] = []
    var seedVersion: Int = 0
}

extension PresetSlots {
    var payload: SlotPayload {
        var entries: [SlotPayload.Entry] = []
        for i in 0..<count where preset(at: i) != nil {
            entries.append(.init(index: i, preset: preset(at: i)!, userOwned: isUserOwned(i)))
        }
        return SlotPayload(entries: entries, pinned: pinned.sorted(), seedVersion: seedVersion)
    }

    init(payload: SlotPayload) {
        self.init()
        for e in payload.entries { fill(index: e.index, preset: e.preset, userOwned: e.userOwned) }
        seedVersion = payload.seedVersion
        for id in payload.pinned { pin(id) }
    }
}

/// Persistence boundary. `SlotStore` talks only to this; tests inject `InMemorySlotBackend`, the app uses
/// `KVSSlotBackend` (iCloud) with a `FileSlotBackend` fallback. `onExternalChange` fires when another
/// device updates the store (KVS notification); it is a no-op for the in-memory/file backends.
protocol SlotBackend: AnyObject {
    func read() -> SlotPayload
    func write(_ payload: SlotPayload)
    var onExternalChange: (() -> Void)? { get set }
}

final class InMemorySlotBackend: SlotBackend {
    private var stored = SlotPayload()
    var onExternalChange: (() -> Void)?
    func read() -> SlotPayload { stored }
    func write(_ payload: SlotPayload) { stored = payload }
}
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): SlotPayload mapping + SlotBackend protocol + in-memory backend.

Task 7: SlotStore — model + backend + external-change reconcile

Files: Create Sources/Model/SlotStore.swift; Test Tests/SlotStoreApplyTests.swift.

SlotStore owns a PresetSlots, persists via a SlotBackend, seeds from the factory on init, and reloads on an external change. The mutating ops + the seed-on-init are host-tested with InMemorySlotBackend.

  • [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum

final class SlotStoreApplyTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
    }
    private func factory(_ names: [String], _ v: Int) -> PresetSlots.Factory {
        .init(presets: names.map { preset($0) }, version: v)
    }

    func testInitSeedsFactoryIntoBackend() {
        let backend = InMemorySlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
        XCTAssertEqual(store.slots.filled.map { $0.name }, ["A", "B"])
        XCTAssertEqual(PresetSlots(payload: backend.read()).filled.count, 2)   // persisted
    }

    func testSaveOverwritePersistsAndMarksOwned() {
        let backend = InMemorySlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
        store.save(preset("Mine"), toIndex: 0)
        XCTAssertEqual(store.slots.preset(at: 0)?.name, "Mine")
        XCTAssertTrue(store.slots.isUserOwned(0))
        XCTAssertEqual(PresetSlots(payload: backend.read()).preset(at: 0)?.name, "Mine")
    }

    func testExternalChangeReloadsFromBackend() {
        let backend = InMemorySlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A"], 1))
        // Simulate another device writing a new slot, then the KVS notification firing.
        var other = PresetSlots(payload: backend.read())
        other.fill(index: 5, preset: preset("FromPhone"), userOwned: true)
        backend.write(other.payload)
        store.reloadFromBackend()
        XCTAssertEqual(store.slots.preset(at: 5)?.name, "FromPhone")
    }

    func testResetToFactory() {
        let backend = InMemorySlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
        store.save(preset("Mine"), toIndex: 0)
        store.resetToFactory()
        XCTAssertEqual(store.slots.preset(at: 0)?.name, "A")
        XCTAssertFalse(store.slots.isUserOwned(0))
    }
}
  • [ ] Step 2: Run → fails (SlotStore undefined).

  • [ ] Step 3: Implement SlotStore.swift

import SwiftUI
import PhysarumEngine

/// Owns the live `PresetSlots`, persists every mutation through a `SlotBackend`, seeds from the factory on
/// first run, and reloads when another device changes the store. `@Observable` so the library + ATV react.
/// Main-only (like the rest of the model); the KVS backend hops its notification to main before calling back.
@Observable
final class SlotStore {
    private(set) var slots: PresetSlots
    private let backend: SlotBackend
    private let factory: PresetSlots.Factory

    init(backend: SlotBackend, factory: PresetSlots.Factory) {
        self.backend = backend
        self.factory = factory
        var model = PresetSlots(payload: backend.read())
        model.seed(factory)                 // first run fills empty; updates fill untouched; no-op if not newer
        self.slots = model
        backend.write(model.payload)
        backend.onExternalChange = { [weak self] in self?.reloadFromBackend() }
    }

    func reloadFromBackend() { slots = PresetSlots(payload: backend.read()); persist() }

    func save(_ preset: Preset, toIndex index: Int) {
        slots.fill(index: index, preset: preset, userOwned: true); persist()
    }
    func clear(slotID: String) { slots.clear(slotID: slotID); persist() }
    func pin(_ slotID: String) { slots.pin(slotID); persist() }
    func unpin(_ slotID: String) { slots.unpin(slotID); persist() }
    func togglePin(_ slotID: String) { slots.isPinned(slotID) ? slots.unpin(slotID) : slots.pin(slotID); persist() }
    func resetToFactory() { slots.resetToFactory(factory); persist() }

    /// Returns overflow customs that didn't fit (caller exports them).
    @discardableResult func migrate(customs: [Preset]) -> [Preset] {
        let overflow = slots.migrate(customs: customs); persist(); return overflow
    }

    private func persist() { backend.write(slots.payload) }
}
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(slots): SlotStore — model + backend + external-change reconcile.

Task 8: iCloud KVS backend + local file fallback (device-empirical; build-verified)

Files: Create Sources/Model/KVSSlotBackend.swift, Sources/Model/FileSlotBackend.swift. Test (file backend only) add to Tests/SlotBackendTests.swift.

The KVS path needs a real iCloud account → no unit test; gate = it compiles on all SDKs + Task 12 sign-off. The file backend IS host-testable (temp dir).

  • [ ] Step 1: Failing test (file backend round-trip, temp dir)
extension SlotBackendTests {
    func testFileBackendRoundTrips() {
        let url = URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("slots-\(UUID().uuidString).json")
        let backend = FileSlotBackend(url: url)
        var s = PresetSlots(); s.fill(index: 2, preset: preset("F"), userOwned: true)
        backend.write(s.payload)
        XCTAssertEqual(PresetSlots(payload: FileSlotBackend(url: url).read()), s)
    }
}
  • [ ] Step 2: Run → fails.

  • [ ] Step 3: Implement FileSlotBackend.swift (local JSON, the offline fallback)

import Foundation

/// Local JSON persistence — the fallback when iCloud is unavailable / the user isn't signed in. No
/// external-change source (single-device), so `onExternalChange` is never called.
final class FileSlotBackend: SlotBackend {
    private let url: URL
    var onExternalChange: (() -> Void)?
    init(url: URL) { self.url = url }
    func read() -> SlotPayload {
        (try? JSONDecoder().decode(SlotPayload.self, from: Data(contentsOf: url))) ?? SlotPayload()
    }
    func write(_ payload: SlotPayload) { try? JSONEncoder().encode(payload).write(to: url) }
}
  • [ ] Step 4: Implement KVSSlotBackend.swift (iCloud; build-verified)
import Foundation

/// iCloud key-value backend. Each filled slot → `slot.NN`, the pin list → `pinned`, the version →
/// `seedVersion`. Observes `didChangeExternallyNotification` (another device wrote) and calls
/// `onExternalChange` on main. Falls under the per-key (1 MB) KVS budget by design (24 small slots).
final class KVSSlotBackend: SlotBackend {
    private let kv = NSUbiquitousKeyValueStore.default
    var onExternalChange: (() -> Void)?

    init() {
        NotificationCenter.default.addObserver(
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv, queue: .main
        ) { [weak self] _ in self?.onExternalChange?() }
        kv.synchronize()
    }

    func read() -> SlotPayload {
        var entries: [SlotPayload.Entry] = []
        for i in 0..<PresetSlots.count {
            guard let data = kv.data(forKey: "slot.\(String(format: "%02d", i))"),
                  let e = try? JSONDecoder().decode(SlotPayload.Entry.self, from: data) else { continue }
            entries.append(e)
        }
        let pinned = (kv.array(forKey: "pinned") as? [String]) ?? []
        return SlotPayload(entries: entries, pinned: pinned, seedVersion: Int(kv.longLong(forKey: "seedVersion")))
    }

    func write(_ payload: SlotPayload) {
        let byIndex = Dictionary(uniqueKeysWithValues: payload.entries.map { ($0.index, $0) })
        for i in 0..<PresetSlots.count {
            let key = "slot.\(String(format: "%02d", i))"
            if let e = byIndex[i], let data = try? JSONEncoder().encode(e) { kv.set(data, forKey: key) }
            else { kv.removeObject(forKey: key) }
        }
        kv.set(payload.pinned, forKey: "pinned")
        kv.set(Int64(payload.seedVersion), forKey: "seedVersion")
        kv.synchronize()
    }
}
  • [ ] Step 5: macOS host suite green (file backend test passes); the KVS backend compiles. Commit feat(slots): iCloud KVS backend + local file fallback.

Task 9: Wire SlotStore into AppState (slots replace builtIns + customs)

Files: Modify Sources/Model/AppState.swift; update Tests/PresetStoreTests.swift / Tests/AppStateResetTests.swift as needed. Read AppState.swift in full first.

This is the integration. Replace builtIns + customs with the slot model and route save/select/pin/reset through SlotStore. Keep edited/baseline/undo/redo/thumbnails behavior.

  • [ ] Step 1: Add an injectable SlotStore to AppState.init (default: real backend; tests inject InMemorySlotBackend):
init(slotStore: SlotStore? = nil, store: PresetStore = PresetStore(), audioStore: AudioPresetStore = AudioPresetStore()) {
    self.store = store
    self.audioStore = audioStore
    let factory = FactorySlots.load()
    let backend: SlotBackend = slotStore == nil ? KVSSlotBackend() : InMemorySlotBackend()
    self.slotStore = slotStore ?? SlotStore(backend: backend, factory: factory)
    // … existing audio loads …
    let first = self.slotStore.slots.filled.first ?? PresetCatalog.fallbackPreset
    selectedID = first.id; edited = first.params; baseline = first.params
    migrateLegacyCustomsIfNeeded()   // Task 10
}
  • [ ] Step 2: Replace presets, select, save/pin/reset to use slotStore:
var presets: [Preset] { slotStore.slots.filled }                       // filled slots, in order
var pinned: Set<String> { slotStore.slots.pinned }
func isPinned(_ id: String) -> Bool { slotStore.slots.isPinned(id) }
func togglePin(_ id: String) { slotStore.togglePin(id) }
func resetAllToFactory() { slotStore.resetToFactory(); if let f = presets.first { select(id: f.id) } }

func saveToSlot(index: Int, name: String, tags: [String], tagSignal: SignalName) {
    var p = selectedPreset                  // start from the live preset shell
    p.name = name.isEmpty ? "Untitled" : name
    p.author = nil; p.builtIn = false; p.tags = tags; p.tagSignal = tagSignal
    p.thumbnail = nil; p.params = edited
    slotStore.save(p, toIndex: index)       // SlotStore stamps p.id = slot id
    let id = PresetSlots.slotID(index)
    selectedID = id; baseline = edited
    generateThumbnail(for: id, params: edited)
}

select(id:) is unchanged (it resolves against presets, which is now the filled slots). advance(by:) is unchanged (wraps over presets). Delete builtIns, customs, saveAsNew, saveOverwrite, deleteCustom, rename, moveCustom, importPreset, selectedIsCustom — replaced by slot operations (saveToSlot, slotStore.clear, togglePin, resetAllToFactory). Keep paramsEqual, isModified, generateThumbnail/backfillThumbnails (now keyed by slot id; thumbnails stay device-local).

  • [ ] Step 3: Fix the test suite: change AppState(store: …) factories to AppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: …)); replace saveAsNew/deleteCustom assertions with saveToSlot/clear. Run the full macOS suite.

  • [ ] Step 4: macOS build + full suite green. Commit feat(slots): AppState uses the slot model (save/select/pin/reset by slot).


Task 10: Legacy custom-preset migration on first launch

Files: Modify Sources/Model/AppState.swift; covered by Tests/SlotMigrationTests.swift (model-level) — add an AppState-level guard test.

  • [ ] Step 1: Implement migrateLegacyCustomsIfNeeded() — once (guard a UserDefaults flag slotMigrationDone), load the old store.loadCustom(), slotStore.migrate(customs:), write overflow to a LegacyPresets-export.json in App-Support, set the flag.
private func migrateLegacyCustomsIfNeeded() {
    let key = "slotMigrationDone"
    guard !UserDefaults.standard.bool(forKey: key) else { return }
    let legacy = store.loadCustom()
    if !legacy.isEmpty {
        let overflow = slotStore.migrate(customs: legacy)
        if !overflow.isEmpty, let data = try? JSONEncoder().encode(overflow) {
            try? data.write(to: store.legacyExportURL)   // add this URL to PresetStore
        }
    }
    UserDefaults.standard.set(true, forKey: key)
}
  • [ ] Step 2: Add PresetStore.legacyExportURL (App-Support/Physarum/LegacyPresets-export.json). Run the suite.
  • [ ] Step 3: Commit feat(slots): one-shot migration of legacy customs into slots (overflow exported).

Task 11: ATV rotation = pinned subset; UI (slot grid, slot picker, reset); entitlements

Files: Modify Sources/Ambient/AmbientController.swift, Sources/TV/TVRootView.swift, Sources/App/RootView.swift, Sources/Library/LibraryView.swift, Sources/Library/SaveDialog.swift, project.yml; create Physarum.iOS.entitlements, Physarum.tvOS.entitlements. Read each UI file in full before editing.

UI + entitlements are build-verified across iOS/tvOS/macOS sims; behavior is Task 12 sign-off.

  • [ ] Step 1: Rotation = pinned subset. AmbientController.playlist currently a captured [Preset]. Change it to derive from the model so pinning takes effect live: store the AppState (already held) and make playlist a computed appState.slotStore.slots.rotation (pinned filled, empty → all). currentIndex and the PlaylistDirector.nextIndex(count:) calls then operate on the rotation. Update TVRootView.init to no longer pass a snapshot (AmbientController(appState:)), and the iOS RootView.startCycle/AppState.advance(by:) to cycle slotStore.slots.rotation rather than the full presets. Add AmbientControllerTests/PlaylistDirectorTests coverage for "rotation follows pins" (host-testable: build a model, pin a subset, assert the controller advances only over pinned).

  • [ ] Step 2: Library = slot grid + pin toggle + Reset. In LibraryView, render the 24 slots as a grid: filled = PresetCard (thumbnail via PresetCatalog.thumbnailURL(for: id, store:), name) with a pin star (isPinned(id) → togglePin(id)); empty = a "+ Empty" cell. Tapping a filled slot → state.select(id:). Add a destructive "Reset all Presets" button → confirmation alert ("Are you sure? This will reset all your presets to factory settings.") → state.resetAllToFactory(). Remove the built-in/custom sections + the rename/reorder/delete context menu (replaced by: clear = long-press/Delete on a slot → slotStore.clear(slotID:); reorder is gone — slots are positional).

  • [ ] Step 3: Save → slot picker. In SaveDialog, replace save-as-new/overwrite with a slot grid: pick a slot (filled shows its name + an overwrite confirmation, empty fills) → state.saveToSlot(index:name:tags:tagSignal:).

  • [ ] Step 4: Entitlements. Create Physarum.iOS.entitlements + Physarum.tvOS.entitlements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>com.apple.developer.ubiquity-kvstore-identifier</key>
  <string>$(TeamIdentifierPrefix)de.jkaindl.physarum</string>
</dict></plist>

In project.yml targets.Physarum.settings.base add per-SDK:

        "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Physarum.iOS.entitlements
        "CODE_SIGN_ENTITLEMENTS[sdk=appletvos*]": Physarum.tvOS.entitlements

(macOS keeps Physarum.macOS.entitlements; do not add the kvstore key there yet — the Developer-ID/notarized Mac build needs an embedded iCloud provisioning profile, deferred per the spec. iPhone/iPad/ATV sync ships first.) Regenerate.

  • [ ] Step 5: iOS-sim + tvOS-sim + macOS builds compile; macOS host suite green. Commit feat(slots): pinned rotation + slot-grid library + slot-picker save + Reset + iCloud entitlements.

Task 12: Gates + device sign-off

  • [ ] Full macOS host suite green (-destination 'platform=macOS'); all new SlotStore/PresetSlots/migration tests pass.
  • [ ] iOS-sim + tvOS-sim + macOS builds green.
  • [ ] Release deploy to an iPhone + iPad + the ATV (Wohnzimmer 00008110-000170C80A30A01E), all signed into the same iCloud account.
  • [ ] Device sign-off (Johannes, same iCloud account): save a preset into a slot on the phone → it appears on the iPad + ATV; pin a slot → the ATV's lean-back rotation changes to the pinned set (nothing pinned → cycles all); edit a slot → the edit syncs; Reset all Presets → factory restores defaults on every device; works offline then syncs on reconnect. (KVS propagation is the device-only validation; the model/seeding/pin/migration logic is covered by host tests.)

Self-Review

Spec coverage: slot model + ids → T1; pinning → T2; seed/update/reset → T3; migration → T4/T10; factory manifest → T5; payload mapping + backend protocol → T6; SlotStore reconcile → T7; KVS backend + file fallback → T8; AppState integration → T9; rotation + UI + entitlements → T11; gates + sign-off → T12. All spec sections covered. Placeholders: the factory.json content is an explicit Strand-② placeholder (mechanism is complete); the macOS-iCloud deferral is a spec-sanctioned decision, not a vague TODO. Type consistency: PresetSlots.slotID/index, Factory, payload/init(payload:), SlotBackend.read/write/onExternalChange, SlotStore.save(_:toIndex:)/togglePin/resetToFactory/migrate, AppState.saveToSlot/togglePin/resetAllToFactory are defined where first used and reused consistently. Device-empirical tasks (T8 KVS, T11 entitlements/UI) are intentionally build-verified; the pure core (T1–T7, T10 model) carries the host coverage — the same split that worked for T2a.