diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml
new file mode 100644
index 000000000..b0b95ca19
--- /dev/null
+++ b/.github/workflows/ci-scripts.yml
@@ -0,0 +1,53 @@
+name: CI scripts
+
+on:
+ pull_request:
+ paths:
+ - 'scripts/ci/**'
+ - 'scripts/sync-apiv2-fixtures*.sh'
+ - '.github/workflows/ci-scripts.yml'
+ - 'iosApp/iosApp/**'
+ - 'iosApp/TopShelf/**'
+ - 'iosApp/NotificationService/**'
+ - 'iosApp/DownloadsActivity/**'
+ - 'iosApp/Tests/**'
+ push:
+ branches: [main]
+ paths:
+ - 'scripts/ci/**'
+ - 'scripts/sync-apiv2-fixtures*.sh'
+ - '.github/workflows/ci-scripts.yml'
+ - 'iosApp/iosApp/**'
+ - 'iosApp/TopShelf/**'
+ - 'iosApp/NotificationService/**'
+ - 'iosApp/DownloadsActivity/**'
+ - 'iosApp/Tests/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Test release input parsing
+ run: |
+ bash scripts/ci/resolve-marketing-version.test.sh
+ bash scripts/ci/resolve-platform.test.sh
+ - name: Test release source archive
+ run: python3 scripts/ci/package-release-source.test.py
+ - name: Test subtitle packaging validation
+ run: python3 scripts/ci/validate-subtitle-packaging.test.py
+ - name: Test API v2 fixture sync
+ run: bash scripts/sync-apiv2-fixtures.test.sh
+ - name: Test the /api/v1 path guard
+ run: bash scripts/ci/check-no-api-v1.test.sh
+ - name: Block new /api/v1 paths in app sources
+ run: bash scripts/ci/check-no-api-v1.sh
diff --git a/.github/workflows/player-regression.yml b/.github/workflows/player-regression.yml
new file mode 100644
index 000000000..8c988dfc8
--- /dev/null
+++ b/.github/workflows/player-regression.yml
@@ -0,0 +1,202 @@
+name: Apple regression
+
+on:
+ pull_request:
+ paths:
+ - 'iosApp/**'
+ - '.github/workflows/player-regression.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'iosApp/**'
+ - '.github/workflows/player-regression.yml'
+ workflow_dispatch:
+ inputs:
+ baseline_ref:
+ description: Optional unchanged upstream commit for a comparative iOS run (the tvOS test target may not exist there)
+ type: string
+ required: false
+ record_preview:
+ description: Also record a synthetic playback clip after a manually requested build
+ type: boolean
+ default: false
+ ios_only:
+ description: Build and validate only the iOS simulator app for an iOS-only change
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+concurrency:
+ group: player-regression-${{ github.ref }}-${{ inputs.baseline_ref || 'head' }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ runs-on: macos-15
+ timeout-minutes: 40
+ strategy:
+ fail-fast: false
+ matrix:
+ include: >-
+ ${{ fromJSON((inputs.ios_only || inputs.baseline_ref) &&
+ '[{"scheme":"Prairie","destination":"platform=iOS Simulator,name=iPhone 17 Pro","action":"test","platform":"iOS","test_target":"PrairieTests"}]' ||
+ '[{"scheme":"Prairie","destination":"platform=iOS Simulator,name=iPhone 17 Pro","action":"test","platform":"iOS","test_target":"PrairieTests"},
+ {"scheme":"PrairieTV","destination":"platform=tvOS Simulator,name=Apple TV 4K (3rd generation)","action":"test","platform":"tvOS","test_target":"PrairieTVTests"},
+ {"scheme":"PrairieMac","destination":"generic/platform=macOS","action":"build"}]') }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ ref: ${{ inputs.baseline_ref || github.sha }}
+ - uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: '26.3'
+ - name: Prepare simulator
+ if: matrix.action == 'test'
+ timeout-minutes: 10
+ shell: bash
+ env:
+ PLATFORM: ${{ matrix.platform }}
+ run: |
+ set -euo pipefail
+ # Hosted images do not always have a usable pre-created device.
+ # Match the selected Xcode SDK and reuse one explicit destination.
+ # iOS and tvOS differ only in SDK name, runtime prefix, and device.
+ case "$PLATFORM" in
+ iOS)
+ sdk=iphonesimulator
+ runtime_prefix=com.apple.CoreSimulator.SimRuntime.iOS-
+ device_type=com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro
+ destination_platform="iOS Simulator"
+ ;;
+ tvOS)
+ sdk=appletvsimulator
+ runtime_prefix=com.apple.CoreSimulator.SimRuntime.tvOS-
+ device_type=com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K
+ destination_platform="tvOS Simulator"
+ ;;
+ *)
+ echo "::error::Unknown simulator platform $PLATFORM"
+ exit 1
+ ;;
+ esac
+ runtime_version=$(xcrun --sdk "$sdk" --show-sdk-version)
+ find_runtime() {
+ xcrun simctl list runtimes --json | jq -r --arg version "$runtime_version" --arg prefix "$runtime_prefix" \
+ '[.runtimes[] | select(.isAvailable and .version == $version and
+ (.identifier | startswith($prefix)))][0].identifier // empty'
+ }
+ runtime_id=$(find_runtime)
+ if [ -z "$runtime_id" ]; then
+ xcodebuild -downloadPlatform "$PLATFORM" -buildVersion "$runtime_version"
+ runtime_id=$(find_runtime)
+ fi
+ if [ -z "$runtime_id" ]; then
+ echo "::error::No available $PLATFORM $runtime_version simulator runtime"
+ exit 1
+ fi
+ simulator_id=$(xcrun simctl create "Prairie-Player-Regression-$PLATFORM" \
+ "$device_type" "$runtime_id")
+ echo "PRAIRIE_SIMULATOR_ID=$simulator_id" >> "$GITHUB_ENV"
+ echo "PRAIRIE_TEST_DESTINATION=platform=$destination_platform,id=$simulator_id" >> "$GITHUB_ENV"
+ xcrun simctl boot "$simulator_id"
+ xcrun simctl bootstatus "$simulator_id" -b
+ - name: Generate Xcode project
+ run: |
+ set -euo pipefail
+ curl -fsSL -o /tmp/xcodegen.zip https://github.com/yonaskolb/XcodeGen/releases/download/2.43.0/xcodegen.zip
+ # Pin the official 2.43.0 release asset before unpacking/executing it.
+ echo "a4847ed77d3341a4d24049bc4424a3babca4c94ff1dcaaee923eaca2b32c678f /tmp/xcodegen.zip" | shasum -a 256 -c -
+ unzip -q /tmp/xcodegen.zip -d /tmp/xcodegen-dist
+ /tmp/xcodegen-dist/xcodegen/bin/xcodegen generate --spec iosApp/project.yml
+ - name: Build player
+ id: build
+ env:
+ SCHEME: ${{ matrix.scheme }}
+ DESTINATION: ${{ matrix.destination }}
+ ACTION: ${{ matrix.action }}
+ run: |
+ set -o pipefail
+ if [ "$ACTION" = test ]; then
+ # Simulator-only ad-hoc signing supplies the application identifier
+ # required by Keychain. No certificate or release secret is used.
+ xcodebuild build-for-testing -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \
+ -destination "$PRAIRIE_TEST_DESTINATION" -derivedDataPath /tmp/prairie-derived \
+ CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- | tee /tmp/player-build.log
+ else
+ xcodebuild build -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \
+ -destination "$DESTINATION" -derivedDataPath /tmp/prairie-derived \
+ -resultBundlePath /tmp/player-validation.xcresult \
+ CODE_SIGNING_ALLOWED=NO | tee /tmp/player-validation.log
+ fi
+ - name: Package simulator tests for focused visual verification
+ if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref
+ run: ditto -c -k --keepParent /tmp/prairie-derived/Build/Products /tmp/Prairie-simulator-tests.zip
+ - name: Upload compiled simulator tests
+ if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref
+ uses: actions/upload-artifact@v4
+ with:
+ name: Prairie-simulator-tests
+ path: /tmp/Prairie-simulator-tests.zip
+ retention-days: 3
+ - name: Run complete simulator suite
+ if: matrix.action == 'test'
+ timeout-minutes: 12
+ env:
+ SCHEME: ${{ matrix.scheme }}
+ TEST_TARGET: ${{ matrix.test_target }}
+ run: |
+ set -o pipefail
+ xcodebuild test-without-building -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \
+ -destination "$PRAIRIE_TEST_DESTINATION" -derivedDataPath /tmp/prairie-derived \
+ -only-testing:"$TEST_TARGET" \
+ -parallel-testing-enabled NO \
+ -test-timeouts-enabled YES -maximum-test-execution-time-allowance 60 \
+ -resultBundlePath /tmp/player-validation.xcresult \
+ CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- | tee /tmp/player-validation.log
+ - name: Record synthetic preview expansion and successor playback
+ if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref && steps.build.outcome == 'success' && !cancelled() && github.event_name == 'workflow_dispatch' && inputs.record_preview
+ timeout-minutes: 10
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Reuse the compiled test bundle. This records only the bounded
+ # synthetic-video test, not a second build or the full test suite.
+ # XCTest can shut down the simulator after the full suite. Boot the
+ # exact recording destination before starting the recorder.
+ simulator_id="$PRAIRIE_SIMULATOR_ID"
+ simulator_state=$(xcrun simctl list devices available --json | jq -r \
+ --arg id "$simulator_id" '.devices[][] | select(.udid == $id) | .state')
+ if [ "$simulator_state" != Booted ]; then
+ xcrun simctl boot "$simulator_id"
+ fi
+ xcrun simctl bootstatus "$simulator_id" -b
+ xcrun simctl io "$simulator_id" recordVideo /tmp/preview-transition.mp4 &
+ recorder_pid=$!
+ trap 'kill -INT "$recorder_pid" 2>/dev/null || true; wait "$recorder_pid" || true' EXIT
+ xcodebuild test-without-building -project iosApp/Prairie.xcodeproj -scheme Prairie \
+ -destination "platform=iOS Simulator,id=$simulator_id" \
+ -derivedDataPath /tmp/prairie-derived \
+ -parallel-testing-enabled NO \
+ -destination-timeout 60 \
+ -test-timeouts-enabled YES -maximum-test-execution-time-allowance 60 \
+ -only-testing:PrairieTests/PlayerSurfaceLayoutTests/testPlayingPreviewExpandsWithoutReplacingItemOrLosingItsLayer \
+ -resultBundlePath /tmp/preview-recording.xcresult CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-
+ kill -INT "$recorder_pid"
+ wait "$recorder_pid"
+ trap - EXIT
+ test -s /tmp/preview-transition.mp4
+ - name: Upload validation evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: player-validation-${{ matrix.scheme }}
+ path: |
+ /tmp/player-build.log
+ /tmp/player-validation.log
+ /tmp/player-validation.xcresult
+ /tmp/preview-transition.mp4
+ /tmp/preview-recording.xcresult
+ retention-days: 7
diff --git a/.github/workflows/release-source.yml b/.github/workflows/release-source.yml
new file mode 100644
index 000000000..0ead6c001
--- /dev/null
+++ b/.github/workflows/release-source.yml
@@ -0,0 +1,60 @@
+name: Publish release source
+
+on:
+ workflow_call:
+ inputs:
+ release_tag:
+ required: true
+ type: string
+ outputs:
+ source_url:
+ value: ${{ jobs.source.outputs.source_url }}
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-source-${{ inputs.release_tag }}
+ cancel-in-progress: false
+
+jobs:
+ source:
+ runs-on: ubuntu-latest
+ outputs:
+ source_url: ${{ steps.publish.outputs.source_url }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Package exact app and library sources
+ run: python3 scripts/ci/package-release-source.py "$RUNNER_TEMP/release-source"
+ - name: Publish source before distributing binaries
+ id: publish
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ run: |
+ set -euo pipefail
+ if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
+ # The sideload workflow may create the same tag's release first.
+ FLAGS=()
+ if [[ "$RELEASE_TAG" == source-* ]]; then FLAGS+=(--latest=false); fi
+ gh release create "$RELEASE_TAG" --target "$GITHUB_SHA" \
+ --title "$RELEASE_TAG" \
+ "${FLAGS[@]}" \
+ --notes "Release source and rebuild instructions are in the Prairie-source archive." \
+ || gh release view "$RELEASE_TAG" >/dev/null
+ fi
+ IS_DRAFT="$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')"
+ if [[ "$IS_DRAFT" == "true" ]]; then
+ echo "::error::Source release $RELEASE_TAG is a draft. Publish it before distributing binaries."
+ exit 1
+ fi
+ ASSET="Prairie-source-${GITHUB_SHA}.tar.gz"
+ gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-source/$ASSET" --clobber
+ SOURCE_URL="$(gh release view "$RELEASE_TAG" --json assets --jq ".assets[] | select(.name == \"$ASSET\") | .url")"
+ test -n "$SOURCE_URL"
+ echo "source_url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5dd28ab92..086bd9145 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,8 +27,8 @@ concurrency:
group: testflight-release
cancel-in-progress: false
-# Least privilege: the jobs only check out code and talk to Apple via the ASC
-# API key. They never use GITHUB_TOKEN to write to the repo.
+# Apple build jobs only read the repository. The source publication job gets
+# contents: write so it can attach rebuild sources before binary distribution.
permissions:
contents: read
@@ -38,6 +38,13 @@ env:
TESTFLIGHT_NOTIFY_EXTERNAL_TESTERS: "true"
jobs:
+ source:
+ permissions:
+ contents: write
+ uses: ./.github/workflows/release-source.yml
+ with:
+ release_tag: ${{ github.event_name == 'push' && github.ref_name || format('source-{0}', github.sha) }}
+
# Resolve the target platform from either the dispatch input or the tag's
# +ios / +tvos build-metadata suffix, so both trigger paths share one gate.
setup:
@@ -84,7 +91,7 @@ jobs:
BUILD_NUMBERS_FILE="$GITHUB_OUTPUT" bundle exec fastlane ios reserve_build_numbers
ios:
- needs: [setup, build-numbers]
+ needs: [setup, build-numbers, source]
# Skip only when the selection is tvOS-only. 'both' and 'ios' both run iOS.
# No status function in the if -> implicit success() gate on needs, so a
# failed build-numbers job skips this build.
@@ -139,13 +146,14 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
+ PRAIRIE_RELEASE_SOURCE_URL: ${{ needs.source.outputs.source_url }}
tvos:
# No longer needs: ios — build numbers are reserved up front, so tvOS runs in
# parallel with iOS rather than after it. (Trade-off: on a both run, tvOS now
# releases even if iOS fails; the platforms' build-number sequences are
# independent, so a gap is harmless.)
- needs: [setup, build-numbers]
+ needs: [setup, build-numbers, source]
# Skip only when the selection is iOS-only. Implicit success() gate on needs.
if: ${{ needs.setup.outputs.platform != 'ios' }}
runs-on: macos-15
@@ -198,3 +206,4 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
+ PRAIRIE_RELEASE_SOURCE_URL: ${{ needs.source.outputs.source_url }}
diff --git a/.github/workflows/sideload-ipa.yml b/.github/workflows/sideload-ipa.yml
index 1813591ab..adcd44052 100644
--- a/.github/workflows/sideload-ipa.yml
+++ b/.github/workflows/sideload-ipa.yml
@@ -121,13 +121,22 @@ jobs:
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$TITLE" \
- --notes "Unsigned iOS/tvOS IPAs for third-party signing / sideloading. See docs/release/sideloading.md."
+ --notes "Unsigned iOS/tvOS IPAs for third-party signing / sideloading. See docs/release/sideloading.md. The Prairie-source archive contains the matching source and rebuild instructions." \
+ || gh release view "$TAG" >/dev/null
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Releasing as $TAG (build $BUILD)"
+ source:
+ needs: release
+ permissions:
+ contents: write
+ uses: ./.github/workflows/release-source.yml
+ with:
+ release_tag: ${{ needs.release.outputs.tag }}
+
build:
- needs: [setup, release]
+ needs: [setup, release, source]
runs-on: macos-15
# Only the release-upload step (tag builds) needs write; scope it to the job
# rather than the whole workflow to keep the rest at least privilege.
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index a46709081..47f66f9bc 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -6,7 +6,7 @@ name: Unit Tests
#
# Coverage gate choice:
# Default scope is `/Networking/` minus live HTTP / session modules
-# (ContinuumAPI, HTTPClient, store refreshers, FramedJSONSession, …).
+# (PrairieAPI, HTTPClient, store refreshers, FramedJSONSession, …).
# See scripts/check-xccov-coverage.sh.
#
# CI speed:
@@ -53,7 +53,7 @@ jobs:
uses: actions/cache@v6
with:
path: iosApp/DerivedData
- key: netci-mac-${{ runner.os }}-${{ hashFiles('iosApp/project.yml', 'iosApp/iosApp/Networking/**/*.swift', 'iosApp/iosApp/Shared/SharedStorage.swift', 'iosApp/iosApp/Shared/KeychainAudience.swift', 'iosApp/iosApp/Shared/ImageSizeSelection.swift', 'iosApp/iosApp/Shared/AppleDecodeCapabilities.swift', 'iosApp/iosApp/Shared/ProfileLaunchBehavior.swift', 'iosApp/iosApp/Shared/ProfileLaunchState.swift', 'iosApp/iosApp/Shared/RememberedProfile.swift', 'iosApp/iosApp/Shared/ProfileLaunchResolution.swift', 'iosApp/iosApp/Navigation/UICustomizationPreferences.swift', 'iosApp/iosApp/Navigation/TabRouter.swift', 'iosApp/iosApp/Navigation/AppNavPreferences.swift', 'iosApp/iosApp/Screens/Profiles/ProfileLaunchPreferences.swift', 'iosApp/iosApp/Shared/AppleDeviceIdentity.swift', 'iosApp/iosApp/Screens/Auth/AuthModels.swift', 'iosApp/iosApp/Screens/Calendar/CalendarModels.swift', 'iosApp/iosApp/Screens/Player/Subtitles/SubtitleAppearance.swift', 'iosApp/iosApp/Screens/Player/ProtocolV3/PlaybackProtocolV3Models.swift', 'iosApp/CI/NetworkingHost/**/*.swift', 'iosApp/Tests/Networking*.swift', 'iosApp/Tests/TokenStore*.swift', 'iosApp/Tests/ServerRegistry*.swift', 'iosApp/Tests/WireFormat*.swift', 'iosApp/Tests/AuthAndDevice*.swift', 'iosApp/Tests/ResponseCache*.swift', 'iosApp/Tests/PrairieFrame*.swift', 'iosApp/Tests/TrackSelection*.swift', 'iosApp/Tests/LiveTVModel*.swift', 'iosApp/Tests/AIModel*.swift', 'iosApp/Tests/PlaybackPrefs*.swift', 'iosApp/Tests/SubtitleSearch*.swift', 'iosApp/Tests/AppUpdate*.swift', 'iosApp/Tests/AIJob*.swift', 'iosApp/Tests/LibraryVisibility*.swift', 'iosApp/Tests/SettingValuesAPITests.swift', 'iosApp/Tests/SettingsConformanceTests.swift', 'iosApp/Tests/SettingsContractResolve.swift', 'iosApp/Tests/ItemVideosDecodingTests.swift', 'iosApp/Tests/Fixtures/SettingsContract/**') }}
+ key: netci-mac-${{ runner.os }}-${{ hashFiles('iosApp/project.yml', 'iosApp/iosApp/Networking/**/*.swift', 'iosApp/iosApp/Shared/SharedStorage.swift', 'iosApp/iosApp/Shared/KeychainAudience.swift', 'iosApp/iosApp/Shared/ImageSizeSelection.swift', 'iosApp/iosApp/Shared/AppleDecodeCapabilities.swift', 'iosApp/iosApp/Shared/ProfileLaunchBehavior.swift', 'iosApp/iosApp/Shared/ProfileLaunchState.swift', 'iosApp/iosApp/Shared/RememberedProfile.swift', 'iosApp/iosApp/Shared/ProfileLaunchResolution.swift', 'iosApp/iosApp/Navigation/UICustomizationPreferences.swift', 'iosApp/iosApp/Navigation/TabRouter.swift', 'iosApp/iosApp/Navigation/AppNavPreferences.swift', 'iosApp/iosApp/Screens/Profiles/ProfileLaunchPreferences.swift', 'iosApp/iosApp/Shared/AppleDeviceIdentity.swift', 'iosApp/iosApp/Screens/Auth/AuthModels.swift', 'iosApp/iosApp/Screens/Calendar/CalendarModels.swift', 'iosApp/iosApp/Screens/Player/Subtitles/SubtitleAppearance.swift', 'iosApp/iosApp/Screens/Player/ProtocolV3/PlaybackProtocolV3Models.swift', 'iosApp/iosApp/Shared/ArtworkURL.swift', 'iosApp/iosApp/Shared/MediaLogRedactor.swift', 'iosApp/iosApp/Shared/CatalogPathSegment.swift', 'iosApp/iosApp/Shared/AccountSessionPersistence.swift', 'iosApp/iosApp/Downloads/DownloadModels.swift', 'iosApp/iosApp/Screens/Profiles/ProfileTransitionError.swift', 'iosApp/iosApp/Navigation/SeriesDetailContext.swift', 'iosApp/iosApp/Screens/Player/StreamRequest.swift', 'iosApp/iosApp/Screens/Browse/Filtering/*.swift', 'iosApp/iosApp/Screens/Audio/AudiobookPlaybackContext.swift', 'iosApp/iosApp/Screens/Detail/AudiobookDetailFormatting.swift', 'iosApp/iosApp/WatchParty/WatchPartyInvitation.swift', 'iosApp/iosApp/WatchParty/WatchPartySocket.swift', 'iosApp/iosApp/Shared/PrairieURLScheme.swift', 'iosApp/CI/NetworkingHost/**/*.swift', 'iosApp/Tests/Networking*.swift', 'iosApp/Tests/TokenStore*.swift', 'iosApp/Tests/ServerRegistry*.swift', 'iosApp/Tests/WireFormat*.swift', 'iosApp/Tests/AuthAndDevice*.swift', 'iosApp/Tests/ResponseCache*.swift', 'iosApp/Tests/PrairieFrame*.swift', 'iosApp/Tests/TrackSelection*.swift', 'iosApp/Tests/LiveTVModel*.swift', 'iosApp/Tests/AIModel*.swift', 'iosApp/Tests/PlaybackPrefs*.swift', 'iosApp/Tests/SubtitleSearch*.swift', 'iosApp/Tests/AppUpdate*.swift', 'iosApp/Tests/AIJob*.swift', 'iosApp/Tests/LibraryVisibility*.swift', 'iosApp/Tests/SettingValuesAPITests.swift', 'iosApp/Tests/SettingsContractResolve.swift', 'iosApp/Tests/ItemVideosDecodingTests.swift', 'iosApp/Tests/Fixtures/SettingsContract/**', 'iosApp/Tests/APIv2FixtureTestSupport.swift', 'iosApp/Tests/Support/**', 'iosApp/Tests/APIv2CatalogReadProjectionTests.swift', 'iosApp/Tests/APIv2PlaybackModelsTests.swift', 'iosApp/Tests/APIv2WireModelsTests.swift', 'iosApp/Tests/APIv2CatalogQueryAndSyncTests.swift', 'iosApp/Tests/MetadataRequestPoolTests.swift', 'iosApp/Tests/ServerIdentityProbeTests.swift', 'iosApp/Tests/TokenStoreSessionTests.swift', 'iosApp/Tests/CatalogShapeModelsTests.swift', 'iosApp/Tests/WatchPartyModelsTests.swift', 'iosApp/Tests/APIv2SubtitleSearchModelsTests.swift', 'iosApp/Tests/TokenStoreFailureTests.swift', 'iosApp/Tests/ServerRegistryEdgeTests.swift', 'iosApp/Tests/PlaybackV3FixtureTestSupport.swift', 'iosApp/Tests/Fixtures/PlaybackV3/**', 'iosApp/Tests/CredentialIdentityTests.swift', 'iosApp/Tests/MediaRequestAuthorizationTests.swift', 'iosApp/Tests/MetadataSingleFlightTests.swift', 'iosApp/Tests/PersonalListsAPITests.swift', 'iosApp/Tests/ServerIdentityMatchingTests.swift', 'iosApp/Tests/SettingValueWritesTests.swift', 'iosApp/Tests/SharedDefaultsTests.swift', 'iosApp/Tests/SharedKeychainCheckedReadTests.swift', 'iosApp/Tests/WatchPartyAPITests.swift', 'iosApp/Tests/Fixtures/APIv2/**', 'iosApp/Tests/APIv2ContractTests.swift', 'iosApp/Tests/APIv2MutationOutcomeTests.swift', 'iosApp/Tests/CatalogActionsAPITests.swift', 'iosApp/Tests/CatalogV2Tests.swift', 'iosApp/Tests/CollectionsV2Tests.swift', 'iosApp/Tests/LibraryBrowseV2Tests.swift', 'iosApp/Tests/MetadataAIV2Tests.swift', 'iosApp/Tests/OnboardingV2Tests.swift', 'iosApp/Tests/TrackPreferencesV2Tests.swift') }}
restore-keys: |
netci-mac-${{ runner.os }}-
@@ -84,9 +84,9 @@ jobs:
PrairieNetworkingTests/NetworkingGateBoostTests
PrairieNetworkingTests/NetworkingModelsCoverageTests
PrairieNetworkingTests/NetworkingModelsGateFillTests
- PrairieNetworkingTests/NetworkingSiloGateFillTests
- PrairieNetworkingTests/NetworkingSiloRegistryGateFillTests
- PrairieNetworkingTests/NetworkingSiloTokenStoreGateFillTests
+ PrairieNetworkingTests/NetworkingPrairieGateFillTests
+ PrairieNetworkingTests/NetworkingPrairieRegistryGateFillTests
+ PrairieNetworkingTests/NetworkingPrairieTokenStoreGateFillTests
PrairieNetworkingTests/TokenStorePersistenceTests
PrairieNetworkingTests/TokenStoreDiagnosticsTests
PrairieNetworkingTests/ServerRegistryMigrationTests
@@ -105,8 +105,37 @@ jobs:
PrairieNetworkingTests/PrairieQualityPresetsTests
PrairieNetworkingTests/SettingValueModelsGateFillTests
PrairieNetworkingTests/SettingValuesAPITests
- PrairieNetworkingTests/SettingsConformanceTests
PrairieNetworkingTests/ItemVideosDecodingTests
+ PrairieNetworkingTests/APIv2ContractTests
+ PrairieNetworkingTests/APIv2MutationOutcomeTests
+ PrairieNetworkingTests/CatalogActionsAPITests
+ PrairieNetworkingTests/CatalogV2Tests
+ PrairieNetworkingTests/CollectionsV2Tests
+ PrairieNetworkingTests/LibraryBrowseV2Tests
+ PrairieNetworkingTests/MetadataAIV2Tests
+ PrairieNetworkingTests/OnboardingV2Tests
+ PrairieNetworkingTests/TrackPreferencesV2Tests
+ PrairieNetworkingTests/APIv2CatalogReadProjectionTests
+ PrairieNetworkingTests/APIv2PlaybackModelsTests
+ PrairieNetworkingTests/APIv2WireModelsTests
+ PrairieNetworkingTests/APIv2CatalogQueryAndSyncTests
+ PrairieNetworkingTests/MetadataRequestPoolTests
+ PrairieNetworkingTests/ServerIdentityProbeTests
+ PrairieNetworkingTests/TokenStoreSessionTests
+ PrairieNetworkingTests/CatalogShapeModelsTests
+ PrairieNetworkingTests/WatchPartyModelsTests
+ PrairieNetworkingTests/APIv2SubtitleSearchModelsTests
+ PrairieNetworkingTests/TokenStoreFailureTests
+ PrairieNetworkingTests/ServerRegistryEdgeTests
+ PrairieNetworkingTests/CredentialIdentityTests
+ PrairieNetworkingTests/MediaRequestAuthorizationTests
+ PrairieNetworkingTests/MetadataSingleFlightTests
+ PrairieNetworkingTests/PersonalListsAPITests
+ PrairieNetworkingTests/ServerIdentityMatchingTests
+ PrairieNetworkingTests/SettingValueWritesTests
+ PrairieNetworkingTests/SharedDefaultsTests
+ PrairieNetworkingTests/SharedKeychainCheckedReadTests
+ PrairieNetworkingTests/WatchPartyAPITests
)
ONLY_TESTING_ARGS=()
@@ -136,6 +165,24 @@ jobs:
fi
xcrun xccov view --report --json TestResults.xcresult > coverage.json
+ - name: List uncovered Networking lines
+ working-directory: iosApp
+ if: always()
+ run: |
+ # Diagnostic only: never fail the job from here.
+ set +e
+ [[ -d TestResults.xcresult ]] || exit 0
+ # Print the unexecuted line ranges of each partly covered file in the
+ # gate scope, so a coverage drop can be traced without a Mac.
+ xcrun xccov view --archive --file-list TestResults.xcresult 2>/dev/null \
+ | grep '/Networking/' | while read -r file; do
+ ranges=$(xcrun xccov view --archive --file "$file" TestResults.xcresult 2>/dev/null \
+ | awk -F: '$2 ~ /^ *0( |$)/ { n = $1 + 0; if (n == last + 1) { last = n } else { if (start) printf "%s%s ", start, (last > start ? "-" last : ""); start = n; last = n } }
+ END { if (start) printf "%s%s", start, (last > start ? "-" last : "") }')
+ if [[ -n "$ranges" ]]; then echo "${file##*/iosApp/iosApp/}: $ranges"; fi
+ done
+ exit 0
+
- name: Enforce 95% Networking unit-test coverage gate
run: |
set -euo pipefail
diff --git a/.gitignore b/.gitignore
index 0c452a27f..c26ac7770 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,15 +8,15 @@ iosApp/.deriveddata/
# XcodeGen regenerates the project, but the exact Aether transitive graph is a
# release input and must remain reviewable.
-!iosApp/Silo.xcodeproj/
-iosApp/Silo.xcodeproj/*
-!iosApp/Silo.xcodeproj/project.xcworkspace/
-iosApp/Silo.xcodeproj/project.xcworkspace/*
-!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/
-iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/*
-!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
-iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/*
-!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+!iosApp/Prairie.xcodeproj/
+iosApp/Prairie.xcodeproj/*
+!iosApp/Prairie.xcodeproj/project.xcworkspace/
+iosApp/Prairie.xcodeproj/project.xcworkspace/*
+!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/
+iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/*
+!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
+iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/*
+!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
# SwiftPM / CocoaPods
.build/
@@ -44,14 +44,9 @@ fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
-# Local tooling state
-# `.agents/skills` is a symlink to `.claude/skills` so non-Claude agents find the
-# same files; everything else under .agents/ is per-developer scratch.
-/.agents/*
-!/.agents/skills
-# Ignore everything in .claude/ except the committed, shared skills.
-/.claude/*
-!/.claude/skills/
+# Local agent/tooling state
+/.agents/
+/.claude/
.codex/
.cursor/
.sweetpad/
@@ -74,4 +69,7 @@ docs/*
!docs/tvos-player/
docs/tvos-player/*
!docs/tvos-player/aetherengine-replacement-spec.md
+!docs/tvos-player/ass-subtitles.md
!docs/release/*
+!docs/release/
+!docs/*-api-v2.md
diff --git a/.ruby-version b/.ruby-version
index e650c01d9..4d541eb20 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.2.9
+3.3.12
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 6cab5b163..528bce1b3 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -111,8 +111,8 @@
"${input:iosTestDestination}",
"-derivedDataPath",
".deriveddata",
- "CODE_SIGN_IDENTITY=",
- "CODE_SIGNING_ALLOWED=NO"
+ "CODE_SIGN_IDENTITY=-",
+ "CODE_SIGNING_ALLOWED=YES"
],
"options": {
"cwd": "${workspaceFolder}/iosApp"
diff --git a/AGENTS.md b/AGENTS.md
index 951281446..702e7e299 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,18 +24,17 @@ repo.
## Build, Test, and Development Commands
- `cd iosApp && xcodegen generate` regenerates `Prairie.xcodeproj` from `project.yml`; do this after target or source layout changes.
-- `cd iosApp && xcodebuild build -project Prairie.xcodeproj -scheme Prairie -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO` builds iOS without local signing.
+- Use the `mac-builder` skill's local simulator helper for iOS/tvOS build, boot,
+ install, launch, and verification. Reuse the task's established simulator and
+ preserve signing and keychain entitlements for authenticated runs.
- Use scheme `PrairieTV` with a tvOS simulator destination for tvOS builds.
- Use scheme `PrairieMac` with `platform=macOS` for macOS builds.
-Those commands assume a local Xcode. When developing from a Linux host, every Apple toolchain
-operation runs on the remote `mac-builder` Mac instead — read the `mac-builder` skill first, and
-`docs/mac-builder.md` for setup and background.
-
-## Skills
-
-Task-specific guides live in `.claude/skills/`, also reachable as `.agents/skills/` for agents
-that look there. Read the one that matches the task instead of working from this file alone.
+Follow `mac-builder` host routing for local and remote builds. Simulator boot and
+reasonable build recovery are part of an authorized run; ask only for an ambiguous
+target or a change that would lose protected state. Unsigned compilation with
+`CODE_SIGNING_ALLOWED=NO` is compile-only evidence; do not install that artifact
+for an authenticated run. See `docs/mac-builder.md` for setup and background.
## Coding Style & Naming Conventions
@@ -48,14 +47,54 @@ manual directional focus mutation.
## Testing Guidelines
-Apple tests use XCTest under `iosApp/Tests/`. Do not add tests for small changes or UI changes unless requested. For shared logic changes, add focused tests only for critical or high-risk behavior.
+Apple tests use XCTest under `iosApp/Tests/`. Add focused regression tests when
+they meaningfully protect changed behavior; use rendered checks for visual changes.
+Avoid tests that only mirror wording or implementation. Run relevant checks while
+iterating and complete repository-required gates at their stated milestone.
+Broaden or repeat testing only for new changes, failures, or unresolved risks.
+
+## Writing
+
+Run a final readability pass on every human-facing issue, pull request,
+document, or status update.
+
+- Lead with the outcome.
+- Use concrete, plain language and active voice.
+- Cut filler, stock framing, repetition, and promotional claims.
+- Preserve meaning, evidence, citations, uncertainty, and established
+ terminology.
+- Never rewrite exact quotations, commands, logs, identifiers, API names, or
+ contractual language.
+- Match the tone to the audience and use only formatting that improves
+ readability.
+
+## Pull requests
+
+Never create a pull request unless the developer explicitly asks for one.
+
+Use a Conventional Commit title in plain language. Start the body with the
+problem, explain the solution next, and end with the required AI disclosure,
+including the exact model identifier, agent harness, and any other AI tooling.
+Include repository-required issue links, validation evidence, risks, and
+follow-up work.
+
+- Keep one concern per pull request. Split changes that solve independent
+ problems or can be reviewed and shipped separately.
+- Include before-and-after images for UI changes. Include a short video when
+ motion or timing matters.
+- Upload pull request evidence to GitHub. Never commit PR-only assets such as
+ `.github/pr-assets/`.
+- When babysitting a pull request, poll checks and review comments created
+ after the last push. Verify bot findings against the source, fix real issues,
+ and dismiss false positives with a written reason. Remain quiet when nothing
+ new has appeared. Stop when the latest commit is green.
ViewInspector (`https://github.com/nalexn/ViewInspector`) is linked to the `PrairieTests` target only (see `iosApp/project.yml`). Use it sparingly for high-risk SwiftUI screens where empty/loading/error affordances must not regress — for example Live TV channel list states — not as a default for every view. Prefer decoder and pure-logic unit tests for Networking and ViewModel code.
CI enforces a 95% line-coverage gate over unit-testable `/Networking/` code via
`scripts/check-xccov-coverage.sh` (not overall app UI coverage). The default
include is `/Networking/`; live HTTP / session modules that need a real server
-(ContinuumAPI, HTTPClient, ContinuumAI, DiagnosticsAPI, ConnectionMonitor,
+(PrairieAPI, HTTPClient, PrairieAI, DiagnosticsAPI, ConnectionMonitor,
FramedJSONSession, Overlay/Playback/Profile prefs stores, AICapabilities,
ImageSizeCapability, ServerIdentityResolver, and feature-store refreshers
including SubtitleProvidersStore) are excluded from the scoped percentage. Models,
diff --git a/APPSTORE-EXCEPTION.md b/APPSTORE-EXCEPTION.md
new file mode 100644
index 000000000..db6b5d1be
--- /dev/null
+++ b/APPSTORE-EXCEPTION.md
@@ -0,0 +1,49 @@
+# Silo Apple App Store / DRM Exception
+
+Copyright (C) 2026 Silo Media L.L.C. and contributors
+
+Silo Apple is licensed under the GNU Affero General Public License,
+version 3 or (at your option) any later version — see [LICENSE](LICENSE) —
+with the following additional permission.
+
+## Additional permission under AGPL v3 section 7
+
+As an additional permission under section 7 of the GNU Affero General
+Public License v3 (incorporated by section 7 of any later version the
+recipient elects), the copyright holders grant permission to distribute,
+install, and use this Program — and any modified versions thereof —
+through application stores and beta-distribution platforms operated by
+third parties (including but not limited to the Apple App Store and
+Apple TestFlight), even where the platform's terms impose restrictions
+on:
+
+- signing or re-signing of the binary by anyone other than the original
+ developer or the platform operator;
+- tamper protection, FairPlay-style DRM, or similar integrity controls
+ applied to the binary by the platform;
+- end-user redistribution of the installed binary outside the platform's
+ own channels;
+- end-user re-linking of the installed binary against modified versions
+ of the Program,
+
+even where such restrictions would otherwise be incompatible with
+sections 4 through 6 of AGPL v3.
+
+This permission applies solely to the binary distribution path. It does
+not waive any obligation under the License regarding source code: the
+complete corresponding source for the Program, including any
+modifications, remains available under AGPL v3, and recipients retain
+every right that license grants with respect to that source — they may
+obtain it, study it, modify it, build their own copy, and distribute
+their modified version under the same license, outside the constraints
+of any application store.
+
+As provided by section 7 of AGPL v3, any recipient redistributing the
+Program may remove this additional permission from their copy.
+
+## Trademark note
+
+This permission covers copyright only. The Silo name, logo, and wordmark
+are trademarks of Silo Media L.L.C. and are not licensed by it:
+publishing a Silo-branded build to an app store additionally requires
+written trademark permission. See [TRADEMARK.md](TRADEMARK.md).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..2f63898f8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,91 @@
+# Contributing to Prairie Apple
+
+The [Prairie contribution guide](https://github.com/Prairie-Server/.github/blob/main/CONTRIBUTING.md)
+covers project-wide coordination, focused changes, evidence, AI disclosure, and
+pull request expectations. Those requirements apply here; this guide adds the
+Apple-specific workflow.
+
+## Before you start
+
+Open an [issue](https://github.com/Prairie-Server/prairie-apple/issues) before
+implementing a feature, navigation or behavior change, large refactor, or work
+that changes the shared server contract. Documentation, narrow fixes, and
+well-scoped parity corrections can go straight to a pull request.
+
+This repository owns the iOS, tvOS, and macOS clients. Server/API work belongs
+in [`prairie-server`](https://github.com/Prairie-Server/prairie-server), and shared client
+behavior should be checked against
+[`prairie-android`](https://github.com/Prairie-Server/prairie-android).
+
+## Development setup
+
+Read [README.md](README.md) for prerequisites, signing setup, and build commands,
+then read [AGENTS.md](AGENTS.md) for module ownership and platform guidance.
+Regenerate `iosApp/Prairie.xcodeproj` from `iosApp/project.yml`; never hand-edit the
+generated project.
+
+## Validate your change
+
+Build every affected platform and run the focused XCTest targets. A typical
+iOS build and simulator test gate is:
+
+```sh
+cd iosApp
+xcodegen generate
+xcodebuild build \
+ -project Prairie.xcodeproj \
+ -scheme Prairie \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
+ CODE_SIGNING_ALLOWED=NO
+xcodebuild test \
+ -project Prairie.xcodeproj \
+ -scheme Prairie \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
+ CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-
+```
+
+Simulator tests use ad-hoc signing so Keychain-dependent tests have an application
+identity; they do not require a distribution certificate.
+
+The `PrairieTests` bundle belongs to the `Prairie` scheme; there are currently no
+separate tvOS or macOS test bundles. Use `PrairieTV` with a tvOS simulator for tvOS
+builds and `PrairieMac` with `platform=macOS` for macOS builds. Exercise visible
+changes in the affected app and include screenshots or a short recording in the
+pull request.
+
+## Open the pull request
+
+Use a Conventional Commit title, explain which platforms are affected, paste
+the actual validation results, and call out any server or Android coordination.
+Read the [AI-assisted contribution policy](https://github.com/Prairie-Server/prairie-server/blob/main/docs/ai-contributions.md)
+and include its disclosure block.
+
+## Licensing of contributions
+
+This project is licensed under `AGPL-3.0-or-later` with the App Store /
+DRM additional permission in
+[APPSTORE-EXCEPTION.md](APPSTORE-EXCEPTION.md).
+
+By submitting a contribution, you agree that it is licensed under those
+same terms — the AGPL *including* the App Store additional permission —
+and that the project may distribute builds containing your contribution
+through application stores such as the Apple App Store and TestFlight as
+that permission describes. If you cannot agree to the additional
+permission, say so in your pull request before it is merged.
+
+You retain copyright in your contribution; no assignment is requested.
+
+## Practical notes
+
+- Repository layout, build commands, and coding conventions are in
+ [AGENTS.md](AGENTS.md) and the docs under `docs/`.
+- Third-party components and their licenses are inventoried in
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md); update it when a
+ dependency changes.
+
+## Instructions for coding agents
+
+Coding agents must read [AGENTS.md](AGENTS.md) before changing the repository
+(`CLAUDE.md` points to the same guidance). The organization-wide contribution
+guide and AI-assisted contribution policy apply to agent and human authors
+equally.
diff --git a/Gemfile b/Gemfile
index 9f3e0ef5f..1b389cadb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,5 +1,5 @@
source "https://rubygems.org"
-ruby "~> 3.2"
+ruby "~> 3.3"
gem "fastlane"
diff --git a/Gemfile.lock b/Gemfile.lock
index 711675815..4cb1418c3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -30,6 +30,7 @@ GEM
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
+ cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -42,38 +43,26 @@ GEM
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
+ erb (6.0.7)
excon (1.5.0)
logger
- faraday (1.10.6)
- faraday-em_http (~> 1.0)
- faraday-em_synchrony (~> 1.0)
- faraday-excon (~> 1.1)
- faraday-httpclient (~> 1.0)
- faraday-multipart (~> 1.0)
- faraday-net_http (~> 1.0)
- faraday-net_http_persistent (~> 1.0)
- faraday-patron (~> 1.0)
- faraday-rack (~> 1.0)
- faraday-retry (~> 1.0)
- ruby2_keywords (>= 0.0.4)
+ faraday (2.14.4)
+ faraday-net_http (>= 2.0, < 3.5)
+ json
+ logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
- faraday-em_http (1.0.0)
- faraday-em_synchrony (1.0.1)
- faraday-excon (1.1.0)
- faraday-httpclient (1.0.1)
+ faraday-follow_redirects (0.5.0)
+ faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
- faraday-net_http (1.0.2)
- faraday-net_http_persistent (1.2.0)
- faraday-patron (1.0.0)
- faraday-rack (1.0.0)
- faraday-retry (1.0.4)
- faraday_middleware (1.2.1)
- faraday (~> 1.0)
+ faraday-net_http (3.4.4)
+ net-http (~> 0.5)
+ faraday-retry (2.4.0)
+ faraday (~> 2.0)
fastimage (2.4.1)
- fastlane (2.237.0)
+ fastlane (2.240.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -83,24 +72,27 @@ GEM
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
+ cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
- faraday (~> 1.0)
- faraday-cookie_jar (~> 0.0.6)
- faraday_middleware (~> 1.0)
+ faraday (~> 2.7)
+ faraday-cookie_jar (~> 0.0.8)
+ faraday-follow_redirects (~> 0.3)
+ faraday-multipart (~> 1.0)
+ faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
- google-apis-androidpublisher_v3 (~> 0.3)
+ google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
- google-cloud-env (>= 1.6.0, < 2.3.0)
+ google-cloud-env (>= 1.6.0, < 2.4.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
- http-cookie (~> 1.0.5)
+ irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
@@ -113,11 +105,11 @@ GEM
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
- rubyzip (>= 2.0.0, < 3.0.0)
- security (= 0.1.5)
+ rubyzip (>= 3.4.0, < 4.0.0)
+ security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
- terminal-table (~> 3)
+ terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@@ -172,6 +164,12 @@ GEM
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
+ io-console (0.9.4)
+ irb (1.18.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.2.0)
@@ -184,14 +182,31 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
+ net-http (0.9.1)
+ uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
+ pp (0.6.4)
+ prettyprint
+ prettyprint (0.2.0)
+ prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
+ rbs (4.2.0)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
+ erb
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
+ tsort
+ reline (0.7.0)
+ io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -199,9 +214,8 @@ GEM
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
- ruby2_keywords (0.0.5)
- rubyzip (2.4.1)
- security (0.1.5)
+ rubyzip (3.4.0)
+ security (0.3.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@@ -210,15 +224,17 @@ GEM
CFPropertyList
naturally
terminal-notifier (2.0.0)
- terminal-table (3.0.2)
- unicode-display_width (>= 1.1.1, < 3)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
trailblazer-option (0.1.2)
+ tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
+ uri (1.1.1)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
@@ -256,6 +272,7 @@ CHECKSUMS
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7
+ cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
@@ -266,22 +283,16 @@ CHECKSUMS
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
+ erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50
- faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d
+ faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
- faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
- faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
- faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
- faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
+ faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
- faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
- faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
- faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
- faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
- faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
- faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
+ faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
+ faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
- fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9
+ fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff
@@ -298,6 +309,8 @@ CHECKSUMS
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
+ io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
@@ -309,38 +322,46 @@ CHECKSUMS
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
+ net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
+ pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+ rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
- ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
- rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
- security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
+ rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b
+ security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
- terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
RUBY VERSION
- ruby 3.2.9
+ ruby 3.3.12
BUNDLED WITH
4.0.15
diff --git a/README.md b/README.md
index 6cfacbffb..5cac4e4bc 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,9 @@ Try the latest beta builds of the iOS and tvOS apps:
+
+
### tvOS
@@ -39,7 +41,7 @@ Try the latest beta builds of the iOS and tvOS apps:
## Prerequisites
-- Xcode 16+
+- Xcode 26+
- `xcodegen`
- Ruby 3.2 with Bundler for release automation
- A running Prairie server for local auth, browsing, and playback validation
@@ -80,7 +82,7 @@ xcodebuild build \
## VS Code
The checked-in `.vscode` configuration provides recommended extensions,
-unsigned build and test tasks, and SweetPad integration for building, running,
+unsigned build tasks, ad-hoc-signed simulator test tasks, and SweetPad integration for building, running,
debugging, simulator management, and Swift code intelligence without using the
Xcode UI.
@@ -128,9 +130,19 @@ Personal Apple Developer teams cannot join the production App Group, so Top Shel
Fastlane lanes are defined in `fastlane/Fastfile`. All Apple IDs, team IDs, signing repo URLs, and App Store Connect credentials must come from CI environment variables.
+## Contributing
+
+Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Features,
+navigation or behavior changes, large refactors, and shared contract changes
+should start as an issue.
+
## License & Trademarks
-Prairie Apple is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
+Prairie Apple is licensed under `AGPL-3.0-or-later` with an additional
+permission under AGPL section 7, inherited from upstream Silo Apple, allowing
+distribution through the Apple App Store and TestFlight despite those platforms' signing, DRM, and
+redistribution terms. See [LICENSE](LICENSE) and
+[APPSTORE-EXCEPTION.md](APPSTORE-EXCEPTION.md).
The **Prairie name, logo, wordmark, app icons, and other brand assets** are **not**
covered by the AGPL. You're free to fork and redistribute the code, including with
@@ -138,4 +150,6 @@ truthful notices such as "fork of Prairie," but forks and redistributions must n
use the Prairie brand as their identity and must remove or replace the Prairie
brand assets. See [TRADEMARK.md](TRADEMARK.md).
-FFmpeg, Nuke, fastlane, and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+Media playback is built on [AetherEngine](https://github.com/superuser404notfound/AetherEngine)
+by Vincent Herbst, used under LGPL-3.0 with its Apple Store / DRM exception.
+AetherEngine, FFmpeg, Nuke, fastlane, and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 744e0eef0..cceb4243e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -15,10 +15,11 @@ The authoritative dependency lock is
| Component | Exact revision | Shipped form | License |
| --- | --- | --- | --- |
-| AetherEngine 6.34.0 | `0ae80496ab6f3fda135f43ef195ff10961c0e625` | Swift package target linked into each host app | LGPL-3.0-only with AetherEngine's Apple Store / DRM exception |
-| FFmpegBuild 2.4.3 | `b2185fa842b829cd53d182a5e9a53182c1d9c84c` | Nine separately embedded dynamic frameworks | See the component table below |
-| LibDovi 2.0.0 | `89be93431c2a5f2e54fb77e93059071b8d2ddb3a` | Static `Dovi.xcframework` linked through AetherEngine | MIT packaging; embedded libdovi is MIT |
-| Nuke and NukeUI 12.9.0 | `83e19143355b02e9261edb2323b3e1e93287ebb9` | Swift package targets linked into each host app | MIT |
+| AetherEngine 7.13.0 + Silo subtitle, HLS authorization and TrueHD Atmos patches | `a02975eda62a13c7c24a2f1ac57840a12975c0b5` | Swift package target linked into each host app | LGPL-3.0-only with AetherEngine's Apple Store / DRM exception |
+| FFmpegBuild 3.4.0 | `9ee46ba4fb533e35efa6492eb1a903ca4f8058fc` | Nine separately embedded dynamic frameworks | See the component table below |
+| SiloObjectAudio 1.0.0 | `645b91c072fafb60146151fd5127f8ff0ca38b6f` | Static `SiloObjectAudio.xcframework` linked through AetherEngine | Apache-2.0; embedded `truehd` and `oamd` crates are Apache-2.0 |
+| LibDovi 2.1.0 | `0d7cce1d6836a30d13a3a2326e50a153af53f014` | Static `Dovi.xcframework` linked through AetherEngine | MIT packaging; embedded libdovi is MIT |
+| Nuke and NukeUI 13.2.0 | `30f7a7e72e0607d304fbf69c799474bd5fb6d1ce` | Swift package targets linked into each host app | MIT |
SwiftPM also resolves SMBClient 0.3.1 because AetherEngine publishes an
optional `AetherEngineSMB` product. Prairie depends only on the `AetherEngine`
@@ -31,11 +32,23 @@ included here.
Copyright (C) 2026 Vincent Herbst.
AetherEngine is licensed under GNU LGPL version 3 with its upstream Apple
-Store / DRM exception. Prairie uses the source unmodified at the exact revision
-above. The bundled acknowledgements include AetherEngine's complete license
-and exception plus the GNU GPL version 3 text incorporated by LGPLv3.
-
-- Exact source:
+Store / DRM exception. Prairie builds the fork revision published by the Silo
+project: upstream release `7.13.0` plus Silo patches for complete native
+subtitle renditions, source
+timing after a media reanchor, ASS subtitle routing, and refreshable authorization
+for native HLS, sidecar subtitles and font bundles, and TrueHD Atmos object
+rendering to Apple Positional Audio. Raw ASS events stay on the
+primary local overlay; secondary subtitles and software PiP receive normalized
+text, and packaged HLS retains
+its native text rendition for PiP and AirPlay. These modifications are
+published under the LGPL at the exact source revision below. The bundled
+acknowledgements include AetherEngine's complete license and exception plus
+the GNU GPL version 3 text incorporated by LGPLv3.
+
+- Exact source:
+ (engine pull requests: ,
+ )
+- Upstream base:
- Rebuild input: `Package.swift` and the source tree at that revision
- Bundled texts: `AetherEngine-LGPL-3.0-App-Store-Exception.txt`,
`GPL-3.0.txt`
@@ -43,8 +56,9 @@ and exception plus the GNU GPL version 3 text incorporated by LGPLv3.
The exception permits Apple App Store and TestFlight distribution despite
store signing, DRM, and relinking restrictions. It does not waive source-code
obligations: the exact-revision link above must stay current for each release,
-and any downstream modifications must be published under the LGPL. Prairie ships
-the source unmodified, so the pointer is the whole obligation.
+and any downstream modifications must be published under the LGPL. The exact
+source revision linked above is that publication; keep it public for as long
+as builds that link this revision are distributed.
## FFmpegBuild and its component libraries
@@ -54,40 +68,51 @@ dynamic frameworks:
| Frameworks | Upstream input | License |
| --- | --- | --- |
-| Libavcodec, Libavformat, Libavutil, Libswresample, Libswscale, Libavfilter | FFmpeg `n8.1.2` (currently `38b88335f99e76ed89ff3c93f877fdefce736c13`) | LGPL-2.1-or-later |
-| Libdav1d | dav1d `1.5.1` (currently `42b2b24fb8819f1ed3643aa9cf2a62f03868e3aa`) | BSD-2-Clause |
-| Libzimg | zimg `release-3.0.5` (currently `e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87`) | WTFPL-2.0 |
-| Libzvbi | libzvbi `v0.2.44` (currently `5169a428d51c3ae8ff7b0897e8a687d8e05e37b5`) | LGPL-2.0-or-later, conveyed under LGPL-2.1; `src/ure.c` is MIT |
-
-The exact FFmpegBuild 2.4.3 `build.sh` is the rebuild recipe and patch record:
+| AetherLibavcodec, AetherLibavformat, AetherLibavutil, AetherLibswresample, AetherLibswscale, AetherLibavfilter | FFmpeg `n8.1.2` (currently `38b88335f99e76ed89ff3c93f877fdefce736c13`) | LGPL-2.1-or-later |
+| AetherLibdav1d | dav1d `1.5.4` (currently `54706fc6bc0cdecab7e9593974a4039cc038fca7`) | BSD-2-Clause |
+| AetherLibzimg | zimg `release-3.0.6` (currently `f819b14e8f39d1282400b0d9543e8ef73c1b2bbd`) | WTFPL-2.0 |
+| AetherLibzvbi | libzvbi `v0.2.45` (currently `d3a5ee9f2b047bf16cd1ee5ccf6ec05ee75409d0`) | LGPL-2.0-or-later, conveyed under LGPL-2.1; `src/ure.c` is MIT |
+
+FFmpegBuild 3.0.0 renamed every target, framework bundle, and install name
+with an `Aether` prefix so the build can coexist with another FFmpeg in the
+same app. Earlier releases added WMV/ASF and legacy Flash support, the VC-1
+parser repair for seeking, and dSYMs for crash symbolication. The pinned 3.4.0
+release adds FFmpeg support for the `dav1` (Dolby Vision AV1) MP4 sample entry.
+The exact FFmpegBuild 3.4.0 `build.sh` is the rebuild recipe and patch record:
- it builds FFmpeg with dynamic linkage and does not enable GPL, version-3,
or nonfree FFmpeg components;
- it removes libzvbi's three GPL-family source files (`packet-830.c`, `pdc.c`,
and `exp-vtx.c`) before compilation and publishes the LGPL replacement stubs;
- it records its FFmpeg, dav1d, zimg, and libzvbi tags and every downstream
- patch applied to those sources.
+ patch applied to those sources;
+- it does not build the FFmpeg `concat` demuxer, which 2.5.0 removed.
-The commit IDs in the table are the tags' dereferenced values observed on
-2026-08-22. FFmpegBuild's script records tag names rather than immutable
+libzvbi 0.2.45 is the GHSA-86rm-g7qf-j2fh security update (out-of-bounds
+read, out-of-bounds write, integer underflow reachable through the teletext
+decoder). The commit IDs in the table are the tags' dereferenced values
+observed on 2026-09-04. FFmpegBuild's script records tag names rather than immutable
upstream commit IDs; recording the dereferenced commits here pins them if the
tags ever move. Forking the pinned FFmpegBuild revision is cheap,
commit-immutable insurance if stronger provenance is ever wanted, but the
exact-revision links satisfy the source pointer as they stand.
-The checked iOS debug app embeds exactly the nine frameworks in the table,
-and its FFmpeg configure strings contain `--enable-shared` without
-`--enable-gpl`, `--enable-version3`, or nonfree enablement. Repeat this
-inventory against each release archive; debug evidence is not a release
-substitute.
+Historical binary inventory (FFmpegBuild 3.0.0): a tvOS Simulator debug build,
+inspected on 2026-09-04, embedded exactly the nine `Aether`-prefixed frameworks
+in the table under `PrairieTV.app/Frameworks/`. Its `AetherLibavcodec` configure
+string contained `--enable-shared` without `--enable-gpl`, `--enable-version3`, or
+nonfree enablement, and the app binary exported no `avcodec_`/`avformat_`
+symbols of its own. These observations describe that older debug artifact;
+they are not a binary inventory of the current 3.4.0 dependency. Repeat this
+inventory against each release archive; debug evidence is not a release substitute.
- Exact packaging source and rebuild script:
-
+
- Current upstream tag resolutions:
[FFmpeg](https://github.com/FFmpeg/FFmpeg/tree/38b88335f99e76ed89ff3c93f877fdefce736c13),
- [dav1d](https://code.videolan.org/videolan/dav1d/-/tree/42b2b24fb8819f1ed3643aa9cf2a62f03868e3aa),
- [zimg](https://github.com/sekrit-twc/zimg/tree/e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87),
- and [libzvbi](https://github.com/zapping-vbi/zvbi/tree/5169a428d51c3ae8ff7b0897e8a687d8e05e37b5)
+ [dav1d](https://code.videolan.org/videolan/dav1d/-/tree/54706fc6bc0cdecab7e9593974a4039cc038fca7),
+ [zimg](https://github.com/sekrit-twc/zimg/tree/f819b14e8f39d1282400b0d9543e8ef73c1b2bbd),
+ and [libzvbi](https://github.com/zapping-vbi/zvbi/tree/d3a5ee9f2b047bf16cd1ee5ccf6ec05ee75409d0)
- Bundled texts: `FFmpegBuild-LGPL-2.1.txt`,
`dav1d-BSD-2-Clause.txt`, `zimg-WTFPL.txt`, `libzvbi-ure-MIT.txt`
@@ -99,41 +124,68 @@ a substitute for a final release archive scan.
LibDovi's packaging and rebuild script are MIT licensed, Copyright (c) 2026
Vincent Herbst. Its `Dovi.xcframework` contains the static `dolby_vision`
-3.3.2 crate built from dovi_tool tag `libdovi-3.3.2`, exact upstream revision
-`4fd2b2235c9f93582dd4a00e65ee34a07800afd7`, under the MIT license,
-Copyright (c) 2025 quietvoid.
+3.4.0 crate built from dovi_tool tag `libdovi-3.4.0`, exact upstream revision
+`d1abe0e27ff2c7ab3339614d06db9f8a058af6b2`, under the MIT license,
+Copyright (c) 2026 quietvoid.
As with FFmpegBuild, LibDovi's rebuild script clones the tag name rather than
an immutable commit. The commit above is the tag's value observed on
-2026-08-22; preserve the actual release source alongside the static library.
+2026-09-04; preserve the actual release source alongside the static library.
LibDovi's packaging `LICENSE` describes the embedded crate as dual MIT or
-Apache-2.0, but the exact `libdovi-3.3.2` source's `LICENSE` and Cargo manifest
+Apache-2.0, but the exact `libdovi-3.4.0` source's `LICENSE` and Cargo manifest
declare MIT. The bundled files reproduce the packaging license unmodified and
also include quietvoid's controlling MIT text; this notice uses MIT for the
embedded crate.
- Exact packaging source and rebuild script:
-
+
- Exact embedded crate source:
-
+
- Bundled texts: `LibDovi-Packaging-MIT.txt`, `libdovi-MIT.txt`
+## SiloObjectAudio and truehd
+
+SiloObjectAudio is the Silo project's object-audio decoder library, licensed under the Apache License
+2.0. Its static `SiloObjectAudio.xcframework` contains the `truehd` crate (TrueHD/MLP
+decoder) from the truehdd project at the exact commit
+`45eff984e3e5ab0cf36a47d12148b1628c38bda4`, Copyright Rainbaby and the truehdd
+contributors, and the `oamd` crate 0.1.0 (object audio metadata) from crates.io,
+Copyright Rainbaby, both under the Apache License 2.0. It also compiles in
+the Rust standard library and the crates its NOTICE lists, each under the MIT
+license or the Apache License 2.0. The bundled text reproduces the NOTICE and the
+Apache License.
+
+- Exact source and rebuild script:
+
+- Embedded decoder source:
+- Embedded metadata parser source:
+- Bundled text: `SiloObjectAudio-Apache-2.0.txt`
+
## Nuke and NukeUI
Nuke and NukeUI are distributed under the MIT license, Copyright (c)
2015-2026 Alexander Grebenyuk.
- Exact source:
-
+
- Bundled text: `Nuke-MIT.txt`
+## ThumbHash decoder
+
+Prairie includes an adapted copy of the ThumbHash Swift decoder, distributed
+under the MIT license, Copyright (c) 2023 Evan Wallace. Only the image decode
+path is retained; the adaptation adds bounds validation, cross-platform image creation,
+and a bounded asynchronous cache around the reference algorithm.
+
+- Exact source:
+
+- Bundled text: `ThumbHash-MIT.txt`
+
## Release checklist
-The bundled notices, license texts, and exact-revision source links above
-cover the standing LGPL obligations: this matches the adopter steps
-AetherEngine's own README asks for (embed FFmpeg dynamically, ship the license
-texts, point at the exact source). Per release, before App Store, TestFlight,
-or other external distribution:
+The dynamically embedded FFmpeg libraries and the statically linked SwiftLibass
+libraries use different rebuild paths. Before App Store, TestFlight, or other
+external distribution:
1. verify the release archive embeds the FFmpegBuild libraries as separate
dynamic frameworks and does not merge or statically link them into the app
@@ -141,4 +193,58 @@ or other external distribution:
2. confirm the revisions in this file and in the bundled acknowledgements
match what the release actually resolves in `Package.resolved`;
3. if AetherEngine or any LGPL component was modified, publish the modified
- source under its license before shipping.
+ source under its license before shipping;
+4. publish the matching `Prairie-source-.tar.gz` archive, with the app
+ source, pinned Swift packages, SwiftLibass dependency sources, revision manifest,
+ and rebuild instructions, before distributing a binary that links SwiftLibass.
+
+## Local ASS rendering
+
+Prairie uses SwiftLibass 1.4.0 for libass bindings and SwiftAssRenderer 1.3.1
+for image compositing on all three Apple clients. Both Swift packages are MIT.
+The native libraries are libass 0.17.3 (ISC), Fontconfig 2.15.0, FreeType 2.13.2
+(FreeType License), FriBidi 1.0.14 (LGPL-2.1-or-later), HarfBuzz 8.5.0, and
+libpng 1.6.43. Their full notices are bundled in `OpenSourceLicenses/`.
+This product uses the FreeType project.
+
+The native XCFrameworks contain static `.a` libraries and headers. Prairie links
+the libraries without embedding subtitle framework wrappers or generated stubs.
+
+- SwiftAssRenderer source:
+- SwiftLibass source and binary artifacts:
+- Rebuild entry point: `build-libraries.sh` at that revision.
+- Transitive Swift dependencies: Combine Schedulers 1.2.2, Concurrency Extras
+ 1.4.1, and IssueReporting from xctest-dynamic-overlay 1.13.1, all MIT; exact revisions are in
+ `Package.resolved` and their notices are bundled.
+- The test-only `SiloASSFixture.ttf` is an original rectangular glyph
+ generated by `iosApp/Tests/Fixtures/ASS/generate-font.py`; it is not shipped
+ in the application.
+
+### Source-based relinking
+
+FriBidi is statically linked. LGPL 2.1 section 6(a) allows the work that uses
+the library to be supplied as source code or object code so a recipient can
+modify the library and relink the application. Prairie uses the source-code path;
+the app is distributed under the repository's AGPL license.
+
+The TestFlight and sideload workflows publish a source archive to GitHub
+Releases before their binary jobs run. Tag-triggered builds attach it to the
+same tag as the binary release. Manual TestFlight builds publish it under
+`source-` and include its URL in the build's What to Test notes.
+External local TestFlight uploads require `PRAIRIE_RELEASE_SOURCE_URL` to point
+to the matching published archive. Other distribution channels must publish
+these materials and provide their location alongside the binary as well.
+
+`scripts/ci/package-release-source.py` captures the committed app tree and
+every resolved Swift package, plus immutable snapshots of SwiftLibass's six
+native libraries recorded in `scripts/ci/swift-libass-sources.json`. The archive
+includes a pinned ffmpeg-kit builder and a local build entry point that skips
+its destructive checkout step, preserving a recipient's edits. `REBUILD.md`
+explains how to build and relink through local package overrides. Signing
+credentials and untracked configuration are excluded. `revisions.json` records
+source revisions and checksums.
+
+SwiftLibass's upstream script clones an unpinned ffmpeg-kit branch. The builder
+and native source tags recorded here match its documented prebuilt versions;
+upstream does not publish a manifest establishing byte-for-byte provenance of
+those prebuilt libraries. The supplied rebuild inputs are pinned independently.
diff --git a/TRADEMARK.md b/TRADEMARK.md
index c859a3784..8b7eda8ba 100644
--- a/TRADEMARK.md
+++ b/TRADEMARK.md
@@ -1,6 +1,6 @@
# Prairie Trademark & Brand Policy
-Prairie is a fork of Prairie. Prairie's source code is free and open source under
+Prairie is a fork of Silo. Prairie's source code is free and open source under
the **GNU Affero General Public License v3.0 or later**
(`AGPL-3.0-or-later`). You are welcome to use, study, modify, and redistribute
the code under those terms.
@@ -8,7 +8,7 @@ the code under those terms.
**That freedom applies to the code, not to the Prairie name and brand.** The
Prairie marks identify this project and its builds. This policy explains how
they may and may not be used so users can distinguish Prairie from other forks
-and from the original Prairie project.
+and from the original Silo project.
## Marks covered
@@ -29,7 +29,7 @@ marks.** This reservation is expressly permitted by AGPL-3.0 §7(e).
or "a plugin for Prairie." Use the word to refer to this project, not to
brand yours.
- **Referring to the fork history truthfully** - for example, "Prairie is a
- fork of Prairie" or "based on Prairie."
+ fork of Silo" or "based on Silo."
- **Redistributing official, unmodified Prairie builds** under the Prairie
name, with all notices kept intact.
- **Running, modifying, and self-hosting** Prairie for yourself or your
diff --git a/docs/collections-api-v2.md b/docs/collections-api-v2.md
new file mode 100644
index 000000000..d9333a0e6
--- /dev/null
+++ b/docs/collections-api-v2.md
@@ -0,0 +1,9 @@
+# Personal collection API v2
+
+Personal collection lists decode `items` and `groups`. Collection and group creation use POST. Move and group rename use PATCH; moving to Ungrouped sends an explicit `group_id: null`.
+
+An editor or delete confirmation first reads the canonical collection or group resource. Its ETag stays attached to the captured account and profile and is sent unchanged as `If-Match`. Background list refreshes do not replace it. A 412 keeps the sheet and draft open, blocks submission, and requires an explicit reload and review. A 428 (missing precondition) is logged as a client bug and handled the same way. Creates and edits are `non_retryable`: a write whose answer is lost is never resent; the sheet requires a reload, and a create re-reads the list. A 404 from an edit or its reload means the item is gone: the sheet closes and the list is re-read. A write's outcome only changes the sheet that sent it; if that sheet was dismissed, an uncertain outcome re-reads the list instead. Group controls require `GET /api/v2/collections/capabilities` to report `allowed`, `state: available` and `groups: true`. A failed capability read hides group controls and offers Retry instead of deciding that groups are unsupported. A 501 `capability_unsupported` from a group operation hides them.
+
+Apple has no existing collection/group/item reorder, membership mutation, import, artwork, or template consumers. Those actions are not added by this migration. Personal collection cards use `GET /api/v2/catalog?source=user_collection&collection_id=...` and the shared catalog card model. Each load follows opaque cursor pages of 50 under one account/profile identity. The existing complete-list views publish only the finished result; malformed continuation, a failed page, identity changes, or the 100-page safety limit fail the load. Refresh explicitly restarts after an invalid cursor. Raw membership records are never decoded as cards.
+
+Regular library collection listing/browsing and general catalog browsing remain on their existing transport; this slice changes only the personal collection source.
diff --git a/docs/images/ios-detail-redesign/home.png b/docs/images/ios-detail-redesign/home.png
new file mode 100644
index 000000000..cbaebe46e
Binary files /dev/null and b/docs/images/ios-detail-redesign/home.png differ
diff --git a/docs/images/ios-detail-redesign/movie-detail.png b/docs/images/ios-detail-redesign/movie-detail.png
new file mode 100644
index 000000000..f94cff715
Binary files /dev/null and b/docs/images/ios-detail-redesign/movie-detail.png differ
diff --git a/docs/images/ios-detail-redesign/series-detail.png b/docs/images/ios-detail-redesign/series-detail.png
new file mode 100644
index 000000000..e790c3a4e
Binary files /dev/null and b/docs/images/ios-detail-redesign/series-detail.png differ
diff --git a/docs/images/tvos-detail-redesign/cast-and-trailers.jpg b/docs/images/tvos-detail-redesign/cast-and-trailers.jpg
new file mode 100644
index 000000000..04894de2f
Binary files /dev/null and b/docs/images/tvos-detail-redesign/cast-and-trailers.jpg differ
diff --git a/docs/images/tvos-detail-redesign/episode-detail.jpg b/docs/images/tvos-detail-redesign/episode-detail.jpg
new file mode 100644
index 000000000..c2e783e9b
Binary files /dev/null and b/docs/images/tvos-detail-redesign/episode-detail.jpg differ
diff --git a/docs/images/tvos-detail-redesign/favorites.jpg b/docs/images/tvos-detail-redesign/favorites.jpg
new file mode 100644
index 000000000..13228252e
Binary files /dev/null and b/docs/images/tvos-detail-redesign/favorites.jpg differ
diff --git a/docs/images/tvos-detail-redesign/home.jpg b/docs/images/tvos-detail-redesign/home.jpg
new file mode 100644
index 000000000..ff05966e0
Binary files /dev/null and b/docs/images/tvos-detail-redesign/home.jpg differ
diff --git a/docs/images/tvos-detail-redesign/movie-detail.jpg b/docs/images/tvos-detail-redesign/movie-detail.jpg
new file mode 100644
index 000000000..8b1727bd9
Binary files /dev/null and b/docs/images/tvos-detail-redesign/movie-detail.jpg differ
diff --git a/docs/images/tvos-detail-redesign/series-detail.jpg b/docs/images/tvos-detail-redesign/series-detail.jpg
new file mode 100644
index 000000000..2cda2be1d
Binary files /dev/null and b/docs/images/tvos-detail-redesign/series-detail.jpg differ
diff --git a/docs/native-api-v2.md b/docs/native-api-v2.md
new file mode 100644
index 000000000..c214bdaf8
--- /dev/null
+++ b/docs/native-api-v2.md
@@ -0,0 +1,75 @@
+# Native API v2 ownership
+
+The stable native business API is `/api/v2`. Apple release callers use the
+accepted v2 operation contracts and refuse unavailable operations. A failed or
+unconfigured v2 playback request must not silently start a v1 session. The
+retained `/api/v1/health` probe is the only v1 request and an operational
+exception, not a fallback. Debug builds assert this in `HTTPClient`, and
+`scripts/ci/check-no-api-v1.sh` holds every v1 path mention in the sources to
+an exact allowlist. The server's [native API contract](https://github.com/Prairie-Server/prairie-server/blob/main/docs/architecture/api-contract.md)
+defines the release boundary.
+
+## Displayed reads authorize card actions
+
+A catalog response belongs to the account, profile, credential owner and profile
+proof captured for that read. Its collection, library or recommendation scope
+also belongs to that response. A later tap uses this displayed owner; it does
+not capture whichever profile happens to be current when an asynchronous task
+runs.
+
+Collection, library-collection, History and recommendation cards provide this
+owner to their membership model. Browse, search, person, saved-list, Home and
+library landing cards retain their existing owning models or captured callbacks.
+Cards without an owning read do not offer an ownerless membership mutation.
+Section watched actions inherit the library or recommendation read owner;
+Home continues to supply its own model-owned callbacks.
+
+Opaque catalog continuations retain the original query and authority. Personal
+collection reads publish only after the bounded complete read succeeds.
+Library-collection and History grids keep incremental paging. Legacy cached
+cards without an owner cannot authorize a fresh action or seed a different
+owner's display. Accepted membership flags on existing cards survive appending
+the next page.
+
+Membership uses the accepted PUT/DELETE favorite and watchlist operations;
+watched state uses POST/DELETE. Each prepared action dispatches once. Receipt
+application checks the captured owner and displayed generation again, so a late
+response cannot update a replacement profile's cards or caches. These callers
+do not add revision headers, refresh-authentication retries or a v1 fallback.
+An uncertain action stays held by its owning model across refreshes. A new read
+is not proof that the earlier mutation reached a terminal outcome. These models
+do not create an offline replay queue.
+
+## Durable commands keep their original authority
+
+Playback and canonical settings journals preserve exact command bytes and
+captured durable ownership before dispatch. Persistent target matching and
+transient dispatch checks serve different purposes: a process restart alone
+must not erase an unresolved durable target barrier, while a different login
+or profile cannot take ownership of an old command.
+
+Recovery is operation-specific. Canonical settings value writes are
+`natural_idempotent`: a 401 refreshes the session once and re-sends the same
+desired value under the same captured owner. They hold uncertain writes and do
+not invent an idempotency or revision-precondition contract. Playback can resolve an exact retained command
+where its accepted protocol explicitly supports that resolution. Neither path
+may convert old queued intent bytes, infer new authorization or rebase an
+unresolved operation onto a new owner.
+
+See [bound audiobook playback](bound-audiobook-playback.md) for timeline identity,
+sequenced receipts and part-transition rules.
+
+## Media request credentials stay ephemeral
+
+A validated immutable playback plan is joined with the authority already
+captured by its start or replan request. Where an accepted auxiliary transport
+requires account authentication, Authorization and the matching `X-Profile-Id`
+belong only in ephemeral, scoped request headers. Their use must be fenced by
+the exact issued origin, session, path, pins and authority lifetime.
+
+The server must not echo a bearer into `stream.headers`. Clients must not add
+one to durable start responses, current plans, replacement JSON or journal
+records. A later ambient token/profile read cannot supply authority for an old
+plan. The recipe profile remains authoritative; a selector only selects media.
+Signed URL and opaque-token transports retain their own contracts. This rule
+does not imply that a particular auxiliary route is implemented or available.
diff --git a/docs/realtime-updates-spec.md b/docs/realtime-updates-spec.md
index 8a096f1e2..627f768dc 100644
--- a/docs/realtime-updates-spec.md
+++ b/docs/realtime-updates-spec.md
@@ -38,7 +38,7 @@ never finds out.
### 2.1 Apple clients today (pull-only)
-- Networking is REST via `HTTPClient` (actor) → `ContinuumAPI` (actor). No shared HTTP cache.
+- Networking is REST via `HTTPClient` (actor) → `PrairieAPI` (actor). No shared HTTP cache.
- `Screens/Home/HomeView.swift` refresh triggers:
- `.task` — first load (`HomeViewModel.loadSections()` + recommendations). (~L79–83)
- `.onAppear` — refetch on return to page, skipped on first appear. (~L84–91)
@@ -61,7 +61,7 @@ will mirror. Key properties to reuse as a pattern:
failures (~L27, surfaces a non-fatal "realtime unavailable" flag).
- `http→ws` / `https→wss` scheme conversion (~L252–259).
- **Generation-based binding** to cancel stale connection loops on rebind/unbind.
-- `ContinuumAPI.shared.currentServerUrl()` and `.currentAccessToken()` provide base URL + token.
+- `PrairieAPI.shared.currentServerUrl()` and `.currentAccessToken()` provide base URL + token.
### 2.3 Server events hub (already live — verify in `prairie-server`)
@@ -186,7 +186,7 @@ actor EventsRealtimeClient {
#### Connection loop (mirrors `PlaybackRealtimeClient.runConnectionLoop`)
-1. Build request: base URL from `ContinuumAPI.shared.currentServerUrl()`, path
+1. Build request: base URL from `PrairieAPI.shared.currentServerUrl()`, path
`/api/v1/events/ws`, `http→ws` / `https→wss`, `Authorization: Bearer `.
No `?token=` and no `?ticket=` (we don't subscribe to `notifications`).
2. `webSocketTask(with:)`, `resume()`.
@@ -406,7 +406,7 @@ Focused tests only (per repo guidelines — no broad UI tests):
bind `scenePhase`, register Home, reconnect on profile switch / stop on sign-out.
- `iosApp/project.yml` — only if new folders need explicit inclusion; regenerate with
`xcodegen generate`.
-- (Reference only, no change) `ContinuumAPI` — already exposes `currentServerUrl()`,
+- (Reference only, no change) `PrairieAPI` — already exposes `currentServerUrl()`,
`currentAccessToken()`, `homeSections()`.
**Server (`prairie-server`): none.** (Possible *future* opt-in: add `position_seconds` to
diff --git a/docs/requests-api-v2.md b/docs/requests-api-v2.md
new file mode 100644
index 000000000..d6cfdb4c0
--- /dev/null
+++ b/docs/requests-api-v2.md
@@ -0,0 +1,39 @@
+# Requests API v2 consumer
+
+The Apple clients call `/api/v2/requests` for the request capability, search, discovery, detail, create, My Requests, and cancel. The methods live in `APIv2Client+Requests.swift`; `PrairieAPI+Requests.swift` forwards to them. No request falls back to v1.
+
+## Scope and ownership
+
+Every operation is profile scoped. A call needs a selected profile, sends `X-Profile-Id` (plus `X-Profile-Token` when the profile has a proof), and runs under the owner captured when it starts. A profile, account, or server change before dispatch refuses the call; a change while it is in flight discards the response.
+
+## Capability
+
+`GET /api/v2/requests/status` returns `FeatureStatus`. Entry points show only when `requests_enabled` is true, `allowed` is true, and `state` is `available`. A blocked account gets `requests_enabled: true` with `allowed: false`, so its entry points stay hidden. The contract requires `allowed`; a response without it reads as not allowed. A failed probe keeps the previous value.
+
+## Reads
+
+- Search sends `q`, `media_type` (`movie`, `series` or `all`) and a 1-based `page`. Blank text is refused before dispatch because the server answers 422.
+- Discovery decodes `{items}`. The server omits `page` because the collection is bounded. Each row may carry `next_page` when rating backfill used more than one provider page.
+- Detail accepts only `movie` and `series` in the path.
+- My Requests follows `page.next_cursor` with a page size of 50. A failed page, a missing or repeated cursor, an owner change, or the 100-page bound fails the load instead of showing a partial list.
+
+Every read requires status 200.
+
+## Create and cancel
+
+`POST /api/v2/requests` requires 201. `POST /api/v2/requests/{id}/cancel` requires 200 and percent-encodes the opaque request ID. The contract marks both `non_retryable`: the server keeps no client request identity, so a resend can act twice.
+
+| Outcome | Examples | Behavior |
+|---|---|---|
+| Definite success | 201 create, 200 cancel with a readable record | Publish the record, then re-read the detail or list. |
+| Definite failure | Never sent (no connection, DNS or TLS failure), no selected profile, or any problem response | Show the error once. The user may try again. |
+| Uncertain | Connection lost or timed out after sending, an unexpected 2xx, an unreadable 2xx body | Never resend. Hold the action (the detail CTA shows "Not confirmed yet"; a held cancel hides its menu item) and re-read. A successful re-read releases the hold. |
+| Uncertain, owner changed | The account, credential owner or profile changed after the call captured its owner (`APIv2RequestsError.outcomeUnknownOwnerChanged`) | Never resend and hold as above, but skip the re-read: it would run under the new owner. The banner tells the user to check My Requests. |
+
+The transport raises the same identity error just before sending and after a response arrives, so the client cannot tell whether a mutation interrupted by an owner change reached the server. It treats every such case as uncertain. Reads keep the ordinary `HTTPError.authorityChanged`.
+
+## Error copy
+
+v2 folds "already requested", "already available" and "invalid state" into one `conflict` problem, and the quota problem carries its counts. `RequestErrorCopy` therefore shows the problem's `detail`, which the contract marks safe to show. `validation_failed` and the capability problems use fixed copy, and `client_upgrade_required` uses the app-update message.
+
+This covers the existing request screens only. It does not add watch-provider or history-import management. Jellyfin protocol behavior is unchanged.
diff --git a/docs/requests/mockups.html b/docs/requests/mockups.html
index d1ed8aa01..66f6308b0 100644
--- a/docs/requests/mockups.html
+++ b/docs/requests/mockups.html
@@ -617,7 +617,7 @@ 04Apple TV — request from Search
-
SILO
+
PRAIRIE
HomeMoviesSeries
For YouCalendar
@@ -708,7 +708,7 @@
06Apple TV — hub & entry point
-
SILO
+
PRAIRIE
HomeMoviesSeries
For YouCalendar
diff --git a/docs/skyline-design-guide.md b/docs/skyline-design-guide.md
index 63f391603..3cb832902 100644
--- a/docs/skyline-design-guide.md
+++ b/docs/skyline-design-guide.md
@@ -51,7 +51,7 @@ All dimensions in this guide are **mockup pixels at 1920×1080**, which map:
| Android TV (Compose) | 1 px = **0.5 dp** (1080p TV ≈ xhdpi → 960×540 dp window) | 32 dp |
Sanity check against existing code: tvOS poster `260×390 pt` ≡ Android `130×195 dp`
-(already true in `ContinuumTheme.swift` / `Spacing.kt`), so existing token files
+(already true in `PrairieTheme.swift` / `Spacing.kt`), so existing token files
already follow this rule. Android text sizes below assume the TV app's global
0.86 font-scale stays in place — give Compose the sp values listed, not px/2 of type.
@@ -61,7 +61,7 @@ already follow this rule. Android text sizes below assume the TV app's global
```
TOP BAR (persistent on all root screens)
-├─ SILO wordmark (left, non-interactive)
+├─ PRAIRIE wordmark (left, non-interactive)
├─ Tabs (center):
│ Home
│ Movies ── cascading selector on focus (§5.3)
@@ -133,7 +133,7 @@ FULL-SCREEN (pushed over everything, no top bar):
## 4. Design tokens
-Unchanged from the current apps (`ContinuumTheme.swift`, `Color.kt`): background
+Unchanged from the current apps (`PrairieTheme.swift`, `Color.kt`): background
`#000000`, surface `#0A0A0A`, elevated `#15171C`, primary text `#EDEDED`,
secondary = primary @ 60%, tertiary @ 38%, hairline white @ 12%, error `#B00020`,
rating amber `#FFC107`. Radii: 8 / 12 / 18 (+ pill = capsule). No chromatic accent.
diff --git a/docs/skyline-implementation-prompt.md b/docs/skyline-implementation-prompt.md
index 90e027bd8..64cebb2a1 100644
--- a/docs/skyline-implementation-prompt.md
+++ b/docs/skyline-implementation-prompt.md
@@ -2,7 +2,7 @@
> **Status:** Phase 1 (chrome) is **complete** on `feature/skyline-phase-1`
> (type-derived tabs · pill row · Libraries/For You roots removed · profile
-> dropdown · `ContinuumTheme.Skyline` tokens). This prompt targets **Phase 2 —
+> dropdown · `PrairieTheme.Skyline` tokens). This prompt targets **Phase 2 —
> the focus marquee** from guide Rev 2. Write the Phase 3 (cascading selector)
> and Phase 4 (polish & parity) prompts after this phase merges.
@@ -33,7 +33,7 @@ Implement **Phase 2 of the Skyline navigation redesign** for the tvOS app: the
`iosApp/iosApp/Screens/Home/HomeView.swift`,
`iosApp/iosApp/Screens/Home/FeaturedCarousel.swift`,
`iosApp/iosApp/tvOS/Components/TVRootHeroBackdrop.swift`,
- `iosApp/iosApp/Theme/ContinuumTheme.swift` (the `Skyline` namespace).
+ `iosApp/iosApp/Theme/PrairieTheme.swift` (the `Skyline` namespace).
## What Phase 1 already built (do not redo)
@@ -57,7 +57,7 @@ Implement **Phase 2 of the Skyline navigation redesign** for the tvOS app: the
1. **`TVFocusMarquee` component** (new, tvOS-only) per guide §5.4/§5.5: two
scales — Home (block top 218, title 84, eyebrow 17) and library (top 246,
title 66, eyebrow 16). Eyebrow = the **source row's title**, preceded by the
- `marquee.tick` dash (white @ 85%, 26×3, r 2 — add to `ContinuumTheme.Skyline`).
+ `marquee.tick` dash (white @ 85%, 26×3, r 2 — add to `PrairieTheme.Skyline`).
Meta line: codec/HDR badges, then year·genre·runtime, or
`S2 E7 · episode title · 23 min left` for episodic items. 2-line synopsis,
max-width 780 (clamp to 1 line if the title wraps to 2). Title falls back to
@@ -112,8 +112,8 @@ any server changes, any iOS/macOS behavior changes.
Search, Calendar, Recommendations) are also compiled for iOS/macOS — guard
tvOS-only changes and keep the other platforms building unchanged.
- Match existing style: types `PascalCase`, the `TV` prefix for tvOS views,
- tokens from `ContinuumTheme` / `Colors.swift` — no hardcoded values, add new
- tokens to the theme (`ContinuumTheme.Skyline` exists from Phase 1). No new
+ tokens from `PrairieTheme` / `Colors.swift` — no hardcoded values, add new
+ tokens to the theme (`PrairieTheme.Skyline` exists from Phase 1). No new
dependencies. No tests for this UI work unless shared logic changes (per
`CLAUDE.md`).
- Branch `feature/skyline-phase-2` stacked on `feature/skyline-phase-1` (or on
@@ -131,7 +131,7 @@ The codebase already encodes several tvOS lessons — preserve them:
once. Tab switches swap content under the stack; they are not pushes.
- **The top menu is custom on purpose** (the system `TabView` sidebar steals
leftward focus). Keep the custom bar; keep `.focusEffectDisabled()` +
- chrome driven by focus state (`ContinuumFlatButtonStyle` pattern) rather
+ chrome driven by focus state (`PrairieFlatButtonStyle` pattern) rather
than the system halo.
- **The marquee never participates in focus.** No focusable subviews, no
`focusSection` membership; the vertical zone model becomes top bar → pill
diff --git a/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md b/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md
index 38b3720e3..0039a2f6e 100644
--- a/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md
+++ b/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md
@@ -32,7 +32,7 @@
- Regenerate the project after adding/removing files: `xcodegen generate --spec iosApp/project.yml` (writes `iosApp/Prairie.xcodeproj`; no `cd` needed).
- iOS build (no signing): `xcodebuild build -project iosApp/Prairie.xcodeproj -scheme Prairie -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO`
- tvOS build: same with `-scheme PrairieTV -destination 'generic/platform=tvOS Simulator'`.
-- The card deliberately uses **system blue** for its primary button and selection accents (the app's global tint is `.continuumOnSurface`, near-white). Blue matches the native iOS pairing aesthetic the card emulates. This is intentional, not a mistake.
+- The card deliberately uses **system blue** for its primary button and selection accents (the app's global tint is `.prairieOnSurface`, near-white). Blue matches the native iOS pairing aesthetic the card emulates. This is intentional, not a mistake.
---
@@ -360,10 +360,10 @@ struct CompanionPairingCard: View {
VStack(spacing: 0) {
heroGlyph.padding(.bottom, 16)
Text("Set Up \(tv.name)")
- .font(.continuumTitle)
+ .font(.prairieTitle)
.multilineTextAlignment(.center)
Text("Sign this Apple TV in using this iPhone.")
- .font(.continuumCaption)
+ .font(.prairieCaption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 6)
@@ -400,7 +400,7 @@ struct CompanionPairingCard: View {
.background(accent, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
.foregroundStyle(.white)
Text(server.displayName)
- .font(.continuumBody)
+ .font(.prairieBody)
.foregroundStyle(.primary)
Spacer()
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
@@ -410,7 +410,7 @@ struct CompanionPairingCard: View {
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
- isOn ? Color.continuumChromeSelectedFill : Color.continuumChromeRestingFill,
+ isOn ? Color.prairieChromeSelectedFill : Color.prairieChromeRestingFill,
in: RoundedRectangle(cornerRadius: 13, style: .continuous)
)
}
@@ -420,15 +420,15 @@ struct CompanionPairingCard: View {
private func confirm(serverName: String, matchCode: String) -> some View {
VStack(spacing: 0) {
Text("Make sure your TV shows")
- .font(.continuumCaption)
+ .font(.prairieCaption)
.foregroundStyle(.secondary)
Text(matchCode)
- .font(.continuumPIN)
+ .font(.prairiePIN)
.textCase(.uppercase)
.tracking(8)
.padding(.top, 8)
Text("for \(serverName)")
- .font(.continuumCaption)
+ .font(.prairieCaption)
.foregroundStyle(.secondary)
.padding(.top, 4)
primaryButton("Yes, this matches") { Task { await coordinator?.confirmMatch() } }
@@ -445,11 +445,11 @@ struct CompanionPairingCard: View {
.foregroundStyle(.green)
.padding(.bottom, 12)
Text(signedIn.isEmpty ? "Nothing set up" : "Set up \(signedIn.joined(separator: ", "))")
- .font(.continuumHeadline)
+ .font(.prairieHeadline)
.multilineTextAlignment(.center)
if !failed.isEmpty {
Text("Couldn’t set up: \(failed.joined(separator: ", "))")
- .font(.continuumCaption)
+ .font(.prairieCaption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 4)
@@ -465,7 +465,7 @@ struct CompanionPairingCard: View {
.foregroundStyle(.yellow)
.padding(.bottom, 12)
Text(message)
- .font(.continuumBody)
+ .font(.prairieBody)
.multilineTextAlignment(.center)
primaryButton("Close") { animateOut(onClose) }.padding(.top, 22)
}
@@ -484,8 +484,8 @@ struct CompanionPairingCard: View {
HStack(spacing: 12) {
Image(systemName: "appletv.fill").font(.system(size: 22)).frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
- Text(title).font(.continuumHeadline)
- Text(subtitle).font(.continuumCaption).foregroundStyle(.secondary)
+ Text(title).font(.prairieHeadline)
+ Text(subtitle).font(.prairieCaption).foregroundStyle(.secondary)
}
Spacer()
}
@@ -494,8 +494,8 @@ struct CompanionPairingCard: View {
private func progressStep(title: String, subtitle: String) -> some View {
VStack(spacing: 10) {
- Text(title).font(.continuumHeadline)
- Text(subtitle).font(.continuumCaption).foregroundStyle(.secondary)
+ Text(title).font(.prairieHeadline)
+ Text(subtitle).font(.prairieCaption).foregroundStyle(.secondary)
ProgressView().padding(.top, 4)
}
.padding(.vertical, 8)
@@ -503,7 +503,7 @@ struct CompanionPairingCard: View {
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
- Text(title).font(.continuumHeadline).frame(maxWidth: .infinity).padding(.vertical, 6)
+ Text(title).font(.prairieHeadline).frame(maxWidth: .infinity).padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
@@ -512,7 +512,7 @@ struct CompanionPairingCard: View {
private func tertiaryButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
- Text(title).font(.continuumBody).frame(maxWidth: .infinity).padding(.vertical, 8)
+ Text(title).font(.prairieBody).frame(maxWidth: .infinity).padding(.vertical, 8)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
diff --git a/docs/superpowers/plans/2026-06-14-companion-pairing.md b/docs/superpowers/plans/2026-06-14-companion-pairing.md
index 972754665..3da8b42c9 100644
--- a/docs/superpowers/plans/2026-06-14-companion-pairing.md
+++ b/docs/superpowers/plans/2026-06-14-companion-pairing.md
@@ -14,7 +14,7 @@
## Decisions locked in during planning (read before starting)
-1. **Pairing networking is isolated from `HTTPClient`.** `HTTPClient`/`ContinuumAPI`/`TokenStore` operate against a single *active* server and have no per-server request API. Rather than rework that, all device-auth calls during pairing go through a new `PairingDeviceAPI` (plain `URLSession`, explicit base URL, optional bearer). This keeps pairing from disturbing the running app's active server.
+1. **Pairing networking is isolated from `HTTPClient`.** `HTTPClient`/`PrairieAPI`/`TokenStore` operate against a single *active* server and have no per-server request API. Rather than rework that, all device-auth calls during pairing go through a new `PairingDeviceAPI` (plain `URLSession`, explicit base URL, optional bearer). This keeps pairing from disturbing the running app's active server.
2. **The phone displays the server-authoritative match code.** After receiving a `userCode` over the channel, the phone calls `GET /auth/device?code=
` (`PairingDeviceAPI.lookup`) and shows the match code **the server** returns for that code — never the match code from the channel. This is what makes the visual compare resistant to a channel MITM (design spec §6).
3. **Persist-on-success.** The TV holds a pushed server URL as a *pending candidate* and writes it to `ServerRegistry` / `TokenStore` only after the poll returns tokens. Any cancel/fail/timeout/drop discards it (design spec §5/§6/§7).
4. **Tests.** The repo has no working XCTest target (the lone `iosApp/Tests/*.swift` file is an orphaned `@main` + `precondition()` script). Per CLAUDE.md ("focused tests only for critical or high-risk behavior") we unit-test only the **pure, dependency-free** logic — `PairingProtocol` and `PairingFrame` — with standalone `swiftc`-compiled `precondition()` programs that mirror the existing pattern. The stateful coordinators are structured against injected protocols and verified by the **manual LAN end-to-end test** (Task 14).
@@ -818,7 +818,7 @@ final class ReceiverPairingCoordinator {
/// True while `pollTask` is active. The protocol is one-server-at-a-time;
/// an overlapping PushServer is ignored.
private var isPolling = false
- private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.receiver")
+ private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.receiver")
/// - Parameter onAuthenticated: called on the main actor after at least
/// one server is signed in, to advance the router to profile selection.
@@ -979,7 +979,7 @@ import OSLog
final class TVPairingAdvertiser {
private var listener: NWListener?
private var busy = false
- private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.advertiser")
+ private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.advertiser")
/// - Parameter onConnection: called on the main actor with an opened
/// session + its inbound stream for the coordinator to drive.
@@ -1166,7 +1166,7 @@ final class CompanionPairingCoordinator {
private var confirmed = false
private var signedIn: [String] = []
private var failed: [String] = []
- private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.companion")
+ private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.companion")
init(api: PairingDeviceAPI = PairingDeviceAPI(), session: PairingSession, stream: AsyncThrowingStream) {
self.api = api
@@ -1631,7 +1631,7 @@ git commit --allow-empty -m "Verify companion pairing end-to-end on LAN"
## Self-Review (completed during planning)
-**Spec coverage:** §4 components → PairingProtocol (T2), PairingSession+frame (T4/T6), ContinuumAPI approve/lookup → PairingDeviceAPI (T7), Receiver coordinator+advertiser+UI (T8/T9), Companion coordinator+browser+banner+view (T10–T13). §5 flow + confirm-once → T10. §6 persist-on-success → T8; server-authoritative match code → T10; opportunistic TLS → T6. §7 edge cases → coordinators + T14. §8 Info.plist/permissions → T1. §9 testing → T3/T5 (pure logic) + T14 (manual). All spec sections map to a task.
+**Spec coverage:** §4 components → PairingProtocol (T2), PairingSession+frame (T4/T6), PrairieAPI approve/lookup → PairingDeviceAPI (T7), Receiver coordinator+advertiser+UI (T8/T9), Companion coordinator+browser+banner+view (T10–T13). §5 flow + confirm-once → T10. §6 persist-on-success → T8; server-authoritative match code → T10; opportunistic TLS → T6. §7 edge cases → coordinators + T14. §8 Info.plist/permissions → T1. §9 testing → T3/T5 (pure logic) + T14 (manual). All spec sections map to a task.
**Type consistency:** `PairingMessage` cases and field names are identical across T2 (definition), T8/T10 (producers/consumers). `PairingSession.open()` returns `AsyncThrowingStream` and is consumed with that exact type in T8–T13. `PairingDeviceAPI` method names (`start/poll/lookup/approve`) match their call sites in T8/T10. `ServerEntry`, `ServerRegistry.{normalize,serverId,addOrUpdate,switchTo,sortedEntries}`, `TokenStore.{getAccessToken(for:),saveTokens,switchActiveServer,setServerUrl}`, `AppRouter.showProfileSelection()`, and `AppleDeviceIdentity.current.{id,name,platform}` all match the verbatim signatures extracted from the codebase.
diff --git a/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md b/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md
index 173962da7..b5e5cdaf7 100644
--- a/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md
+++ b/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md
@@ -136,7 +136,7 @@ struct AuroraInputField: View {
)
.shadow(color: isFocused ? Color.auroraAccent.opacity(0.4) : .clear,
radius: isFocused ? 20 : 0, y: 0)
- .animation(ContinuumTheme.springAnimation, value: isFocused)
+ .animation(PrairieTheme.springAnimation, value: isFocused)
}
private var displayString: String {
@@ -229,7 +229,7 @@ import SwiftUI
// MARK: - Field content classification (platform-neutral)
-/// Mirrors the small enum pattern in the old Continuum forms so the shared
+/// Mirrors the small enum pattern in the old Prairie forms so the shared
/// component carries no UIKit types in its signature — macOS has no
/// `UITextContentType`.
enum AuroraFieldContentType { case username, password, email, oneTimeCode, url }
@@ -259,8 +259,8 @@ struct AuroraErrorLabel: View {
Text(text)
.fixedSize(horizontal: false, vertical: true)
}
- .font(.continuumCaption)
- .foregroundStyle(Color.continuumError)
+ .font(.prairieCaption)
+ .foregroundStyle(Color.prairieError)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity)
}
@@ -338,7 +338,7 @@ struct AuroraTextField: View {
)
.shadow(color: isFocused ? Color.auroraAccent.opacity(0.35) : .clear,
radius: isFocused ? 16 : 0)
- .animation(ContinuumTheme.springAnimation, value: isFocused)
+ .animation(PrairieTheme.springAnimation, value: isFocused)
}
}
@@ -471,7 +471,7 @@ struct ServerSetupView: View {
VStack(spacing: 12) {
AuroraEyebrow(text: "Step 01 — Connect", centered: true)
Text("Add your server")
- .font(.continuumTitle)
+ .font(.prairieTitle)
.foregroundStyle(Color.auroraInk)
}
.frame(maxWidth: .infinity)
@@ -519,7 +519,7 @@ struct ServerSetupView: View {
@ViewBuilder
private var advancedDisclosure: some View {
Button {
- withAnimation(ContinuumTheme.springAnimation) {
+ withAnimation(PrairieTheme.springAnimation) {
viewModel.showsAdvancedOptions.toggle()
}
} label: {
@@ -636,7 +636,7 @@ with:
```swift
Button {
- withAnimation(ContinuumTheme.springAnimation) {
+ withAnimation(PrairieTheme.springAnimation) {
viewModel.showsAdvancedOptions.toggle()
}
} label: {
@@ -681,7 +681,7 @@ On `manualCard`, add an animation for the toggle next to the existing error anim
to:
```swift
.animation(.easeInOut(duration: 0.2), value: viewModel.error)
- .animation(ContinuumTheme.springAnimation, value: viewModel.showsAdvancedOptions)
+ .animation(PrairieTheme.springAnimation, value: viewModel.showsAdvancedOptions)
```
- [ ] **Step 4: Build tvOS**
@@ -731,7 +731,7 @@ struct LoginView: View {
VStack(spacing: 12) {
AuroraEyebrow(text: "Step 02 — Sign in", centered: true)
Text("Welcome back")
- .font(.continuumTitle)
+ .font(.prairieTitle)
.foregroundStyle(Color.auroraInk)
if let host = hostLabel {
Text(host)
@@ -865,7 +865,7 @@ struct SignupView: View {
VStack(spacing: 12) {
AuroraEyebrow(text: "Create account", centered: true)
Text("Create your account")
- .font(.continuumTitle)
+ .font(.prairieTitle)
.foregroundStyle(Color.auroraInk)
.multilineTextAlignment(.center)
}
@@ -965,7 +965,7 @@ In `background`, replace the `#else` branch:
```swift
#else
ZStack {
- Color.continuumBackground.ignoresSafeArea()
+ Color.prairieBackground.ignoresSafeArea()
RadialGradient(
colors: [Color.white.opacity(0.07), Color.black.opacity(0)],
center: .center,
@@ -1086,11 +1086,11 @@ struct ServerNeedsSetupView: View {
VStack(spacing: 12) {
Text("Finish setup in your browser")
- .font(.continuumTitle)
+ .font(.prairieTitle)
.foregroundStyle(Color.auroraInk)
.multilineTextAlignment(.center)
Text("This server doesn't have an account yet. Open it in a browser to create the first one, then come back here to sign in.")
- .font(.continuumBody)
+ .font(.prairieBody)
.foregroundStyle(Color.auroraInkSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
@@ -1233,7 +1233,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)`
title: "Finish setup in your browser",
subtitle: nil
)
- .continuumBackground()
+ .prairieBackground()
#else
ServerNeedsSetupView(router: router)
#endif
@@ -1244,7 +1244,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)`
title: "Sign up from a phone or the web",
subtitle: nil
)
- .continuumBackground()
+ .prairieBackground()
#else
SignupView(router: router)
#endif
@@ -1262,7 +1262,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)`
title: "Coming Soon",
subtitle: "This screen is under construction."
)
- .continuumBackground()
+ .prairieBackground()
}
}
```
@@ -1278,7 +1278,7 @@ with:
case .serverNeedsSetup:
#if os(tvOS)
EmptyStateView(icon: "gearshape.2", title: "Finish setup in your browser", subtitle: nil)
- .continuumBackground()
+ .prairieBackground()
#else
ServerNeedsSetupView(router: router)
#endif
@@ -1289,7 +1289,7 @@ And gate the `.signup` arm in `profileFlowDestination` the same way (it currentl
case .signup:
#if os(tvOS)
EmptyStateView(icon: "person.badge.plus", title: "Sign up from a phone or the web", subtitle: nil)
- .continuumBackground()
+ .prairieBackground()
#else
SignupView(router: router)
#endif
@@ -1346,7 +1346,7 @@ Expected: `** BUILD SUCCEEDED **` for all three.
- [ ] **Step 2: Run the iOS app in the simulator and walk the flow**
Boot the iPhone simulator, install, and launch the `Prairie` scheme. Verify visually:
-- **Server setup**: plum Aurora backdrop with drifting ribbons + starfield, `SILO.` wordmark, "Step 01 — Connect" gold eyebrow, single glass form, "Advanced options" collapsed by default; expanding reveals Protocol + Port. Enter a known dev server and connect.
+- **Server setup**: plum Aurora backdrop with drifting ribbons + starfield, `PRAIRIE.` wordmark, "Step 01 — Connect" gold eyebrow, single glass form, "Advanced options" collapsed by default; expanding reveals Protocol + Port. Enter a known dev server and connect.
- **Sign in**: "Step 02 — Sign in" + "Welcome back" + host label; username/password with a working eye toggle; keyboard pushes the focused field into view (form scrolls); "Create account" appears only when signup is enabled; "Change server" returns to server setup.
- **Profile**: "Who's watching?" over the Aurora backdrop; tiles tappable; a PIN profile presents the PIN sheet; "Switch server"/"Sign out" chips work.
- **Sign up** (if the dev server allows it): all fields render and scroll; create-account works.
diff --git a/docs/superpowers/plans/2026-06-15-resume-first-home.md b/docs/superpowers/plans/2026-06-15-resume-first-home.md
index a74cc50a9..5b1e22b00 100644
--- a/docs/superpowers/plans/2026-06-15-resume-first-home.md
+++ b/docs/superpowers/plans/2026-06-15-resume-first-home.md
@@ -63,7 +63,7 @@ In `HomeView.swift`, inside the `#if !os(tvOS)` state block, delete the three he
```swift
// DELETE these three lines:
- @State private var heroTintColor: Color = .continuumBackground
+ @State private var heroTintColor: Color = .prairieBackground
@State private var heroBackdropURL: String?
@State private var heroBackdropThumbhash: String?
```
@@ -87,7 +87,7 @@ In `HomeView.swift`, in the non-tvOS `body` (the `#else` branch), the `ZStack(al
```swift
ZStack(alignment: .top) {
- Color.continuumBackground
+ Color.prairieBackground
.ignoresSafeArea()
Group {
@@ -147,7 +147,7 @@ In `HomeView.swift`, replace the body of `scrollContent`'s `LazyVStack` so there
.id(HomeFocusTarget.row(section.id))
}
}
- .padding(.bottom, ContinuumTheme.largePadding)
+ .padding(.bottom, PrairieTheme.largePadding)
```
Update the `HomeFocusTarget` enum to drop the featured case and rename the spacer:
@@ -163,8 +163,8 @@ Rename the helper `noFeaturedTopSpacing(topSafeAreaInset:)` to `topRunwaySpacing
```swift
private func topRunwaySpacing(topSafeAreaInset: CGFloat) -> CGFloat {
- let headerContentHeight: CGFloat = 40 + (ContinuumTheme.smallPadding * 2)
- return topSafeAreaInset + headerContentHeight + ContinuumTheme.largePadding + ContinuumTheme.smallPadding
+ let headerContentHeight: CGFloat = 40 + (PrairieTheme.smallPadding * 2)
+ return topSafeAreaInset + headerContentHeight + PrairieTheme.largePadding + PrairieTheme.smallPadding
}
```
@@ -221,7 +221,7 @@ In `BrowseView.swift`, in `LibraryRecommendedView`'s `content`, delete the entir
```swift
private var content: some View {
ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(spacing: ContinuumTheme.largePadding) {
+ LazyVStack(spacing: PrairieTheme.largePadding) {
ForEach(viewModel.regularSections) { section in
SectionRow(
section: section,
@@ -229,7 +229,7 @@ In `BrowseView.swift`, in `LibraryRecommendedView`'s `content`, delete the entir
)
}
}
- .padding(.bottom, ContinuumTheme.largePadding)
+ .padding(.bottom, PrairieTheme.largePadding)
}
// Report the distance scrolled from the resting top position. We add
// the top content inset so the value starts at 0 at rest regardless
@@ -250,7 +250,7 @@ In `BrowseView.swift`, `extendsBackdropToTop` is now used only by `refreshStatus
```swift
private var refreshStatusTopPadding: CGFloat {
- ContinuumTheme.padding
+ PrairieTheme.padding
}
```
diff --git a/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md b/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md
index 1b3009094..56a2b427e 100644
--- a/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md
+++ b/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md
@@ -6,7 +6,7 @@
**Architecture:** A view-layer change on the existing `TVDetailHero` + per-type detail views. Today's round controls (`Capsule` pills, `Circle` icon buttons, capsule season chips) are reshaped to one 8 pt rounded-rectangle family. A new `TVPlaybackSelectorRow` consolidates the version picker and the audio/subtitle menus (which already exist in the `⋯` menu) into a webapp-parity row, and adds an Edition selector derived from `FileVersion.edition`. The `ReadableFocusSection` focus workaround is removed; the synopsis becomes an expand-in-place control. The audiobook page gets a cover-forward cinematic hero.
-**Tech Stack:** Swift 5 / SwiftUI, tvOS. XcodeGen (`project.yml` → `Prairie.xcodeproj`). Design tokens in `ContinuumTheme.swift`.
+**Tech Stack:** Swift 5 / SwiftUI, tvOS. XcodeGen (`project.yml` → `Prairie.xcodeproj`). Design tokens in `PrairieTheme.swift`.
---
@@ -14,7 +14,7 @@
**Read before starting any task.**
-- **Design language:** Skyline — OLED black, monochrome chrome, white-at-opacity. Squared-control radius = `ContinuumTheme.smallCornerRadius` (8 pt). Spec: `docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md`. Mockups: `docs/tvos-detail-mockups/`.
+- **Design language:** Skyline — OLED black, monochrome chrome, white-at-opacity. Squared-control radius = `PrairieTheme.smallCornerRadius` (8 pt). Spec: `docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md`. Mockups: `docs/tvos-detail-mockups/`.
- **TDD reconciliation (important):** `CLAUDE.md` says *do not add tests for UI changes* — only focused tests for critical/high-risk shared logic. So **only Task 3 (edition grouping) is test-first.** Every other task is **build-and-verify**: make the change, build `PrairieTV`, then visually confirm in the tvOS Simulator (`admin` / `water1234` per the PrairieTV debugging notes).
- **Build gate (run after every task, must succeed):**
```bash
@@ -86,17 +86,17 @@ to:
```swift
.overlay(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(
innerBorderColor,
lineWidth: innerBorderWidth
)
)
.background(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(background)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(background)
)
.overlay {
if isFocused {
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius + 2, style: .continuous)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius + 2, style: .continuous)
.stroke(focusOutlineColor, lineWidth: focusOutlineWidth)
.padding(-focusOutlineInset)
}
@@ -130,19 +130,19 @@ to:
```swift
.background(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(
isFocused ? .white : Color.white.opacity(0.10)
)
)
.overlay(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(
isFocused ? Color.black.opacity(0.12) : Color.white.opacity(0.34),
lineWidth: isFocused ? 1.6 : 1.4
)
)
.overlay {
if isFocused {
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius + 2, style: .continuous)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius + 2, style: .continuous)
.stroke(Color.white.opacity(0.96), lineWidth: 3)
.padding(-5)
}
@@ -161,9 +161,9 @@ to:
to:
```swift
- .background(RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.black.opacity(0.42)))
+ .background(RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.black.opacity(0.42)))
.overlay(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 1.2)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 1.2)
)
```
@@ -190,11 +190,11 @@ Co-Authored-By: Claude Opus 4.8 "
@ViewBuilder
private var background: some View {
if isSelected {
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.white)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.white)
} else if isFocused {
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.white.opacity(0.18))
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.white.opacity(0.18))
} else {
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 1.5)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 1.5)
}
}
```
@@ -704,7 +704,7 @@ struct TVExpandableSynopsis: View {
.contentShape(Rectangle())
}
.buttonStyle(TVSynopsisButtonStyle())
- .animation(.easeOut(duration: ContinuumTheme.normalDuration), value: expanded)
+ .animation(.easeOut(duration: PrairieTheme.normalDuration), value: expanded)
}
}
@@ -721,13 +721,13 @@ private struct TVSynopsisButtonStyle: ButtonStyle {
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous)
- .fill(Color.continuumSurfaceElevated.opacity(isFocused ? 0.55 : 0))
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous)
+ .fill(Color.prairieSurfaceElevated.opacity(isFocused ? 0.55 : 0))
)
.padding(.horizontal, -20)
.padding(.vertical, -14)
.focusEffectDisabled()
- .animation(.easeOut(duration: ContinuumTheme.fastDuration), value: isFocused)
+ .animation(.easeOut(duration: PrairieTheme.fastDuration), value: isFocused)
}
}
}
@@ -876,7 +876,7 @@ to:
.frame(maxWidth: 900, alignment: .leading)
Spacer(minLength: 0)
}
- .padding(.horizontal, ContinuumTheme.safePadding)
+ .padding(.horizontal, PrairieTheme.safePadding)
.padding(.bottom, 64)
.padding(.top, 120)
}
@@ -902,13 +902,13 @@ to:
.blur(radius: 60)
.opacity(0.5)
} else {
- Color.continuumSurface.frame(height: 760)
+ Color.prairieSurface.frame(height: 760)
}
LinearGradient(
stops: [
- .init(color: Color.continuumBackground.opacity(0.55), location: 0.0),
- .init(color: Color.continuumBackground.opacity(0.30), location: 0.5),
- .init(color: Color.continuumBackground, location: 1.0),
+ .init(color: Color.prairieBackground.opacity(0.55), location: 0.0),
+ .init(color: Color.prairieBackground.opacity(0.30), location: 0.5),
+ .init(color: Color.prairieBackground, location: 1.0),
],
startPoint: .top, endPoint: .bottom
)
@@ -963,7 +963,7 @@ Co-Authored-By: Claude Opus 4.8 "
```swift
.background(
- RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous)
+ RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous)
.fill(isFocused ? Color.white : Color.white.opacity(0.06))
)
```
@@ -1038,10 +1038,10 @@ Co-Authored-By: Claude Opus 4.8 "
```
and change the modifier to:
```swift
- .animation(reduceMotion ? nil : .easeOut(duration: ContinuumTheme.normalDuration), value: expanded)
+ .animation(reduceMotion ? nil : .easeOut(duration: PrairieTheme.normalDuration), value: expanded)
```
-- [ ] **Step 2: Confirm the squared button/chip focus already respects Reduce Motion** or is acceptable (the existing styles animate `isFocused` via `ContinuumTheme.springAnimation`; leave as-is unless it visibly janks — these predate this redesign).
+- [ ] **Step 2: Confirm the squared button/chip focus already respects Reduce Motion** or is acceptable (the existing styles animate `isFocused` via `PrairieTheme.springAnimation`; leave as-is unless it visibly janks — these predate this redesign).
- [ ] **Step 3: Full build gate.** Expected: `** BUILD SUCCEEDED **`.
diff --git a/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md b/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md
index 0a22220f9..f9e0d88a3 100644
--- a/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md
+++ b/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md
@@ -6,7 +6,7 @@
**Architecture:** Pure iOS view-layer change. `PrairieCastViews.swift` is split into four focused, `#if os(iOS)`-guarded files under `iosApp/iosApp/Cast/iOS/`. Artwork is resolved client-side from the `contentId` already in the cast state (cache → API), so no protocol field is added. The remote screen is split into a thin controller-observing wrapper plus a pure presentational view driven by plain `PrairieCastPlaybackState` + a command callback, which makes it previewable with mock data.
-**Tech Stack:** Swift 5, SwiftUI, `@Observable`, XcodeGen (`project.yml`), existing app primitives (`AsyncImageView`, `PlayerTimeFormatter`, `ContinuumAPI`, `ResponseCache`, `Color.continuum*` tokens).
+**Tech Stack:** Swift 5, SwiftUI, `@Observable`, XcodeGen (`project.yml`), existing app primitives (`AsyncImageView`, `PlayerTimeFormatter`, `PrairieAPI`, `ResponseCache`, `Color.prairie*` tokens).
---
@@ -33,7 +33,7 @@ The artwork resolver is low-risk network/cache glue (not critical/high-risk shar
```
- **`Prairie.xcodeproj` is gitignored** (XcodeGen output) — never `git add` it. Commit only the Swift/doc files.
- **Every new file under `Cast/iOS/` MUST be wrapped in `#if os(iOS) … #endif`** — the tvOS (`PrairieTV`) target also globs `iosApp/` and will otherwise try to compile iOS-only types.
-- **Monochrome chrome** (spec §3): no chromatic accent. White (`continuumOnSurface`) fills, black (`continuumBackground`) glyph on the play button; artwork is the only color.
+- **Monochrome chrome** (spec §3): no chromatic accent. White (`prairieOnSurface`) fills, black (`prairieBackground`) glyph on the play button; artwork is the only color.
## File structure (decomposition)
@@ -85,7 +85,7 @@ final class PrairieCastArtworkResolver {
}
do {
- let detail = try await ContinuumAPI.shared.itemDetail(contentId: contentId)
+ let detail = try await PrairieAPI.shared.itemDetail(contentId: contentId)
apply(detail, contentId: contentId)
} catch {
// Degrade silently: the remote simply shows the flat background.
@@ -106,7 +106,7 @@ struct PrairieCastArtworkBackground: View {
var body: some View {
ZStack {
- Color.continuumBackground
+ Color.prairieBackground
if let urlString, !urlString.isEmpty {
AsyncImageView(url: urlString, contentMode: .fill, placeholderStyle: .clear)
.id(urlString)
@@ -114,7 +114,7 @@ struct PrairieCastArtworkBackground: View {
.opacity(0.45)
.clipped()
}
- Color.continuumBackground.opacity(0.55)
+ Color.prairieBackground.opacity(0.55)
}
.ignoresSafeArea()
}
@@ -234,21 +234,21 @@ struct PrairieCastRemoteControlView: View {
private var connectingView: some View {
VStack(spacing: 18) {
RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(Color.continuumSurfaceElevated)
+ .fill(Color.prairieSurfaceElevated)
.frame(width: 150, height: 216)
if let error = controller.errorMessage, !error.isEmpty {
Text(error)
.font(.subheadline)
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.multilineTextAlignment(.center)
.padding(.horizontal, 14)
.padding(.vertical, 10)
- .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.continuumError.opacity(0.9)))
+ .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.prairieError.opacity(0.9)))
} else {
ProgressView()
Text("Connecting to \(controller.activeTarget?.name ?? "Prairie TV")…")
.font(.headline)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
.padding(24)
@@ -292,12 +292,12 @@ private struct RemoteNowPlayingContent: View {
AsyncImageView(url: posterURL, contentMode: .fit)
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(Color.continuumSurfaceElevated)
+ .fill(Color.prairieSurfaceElevated)
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.overlay {
Image(systemName: "tv")
.font(.system(size: 36))
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
}
@@ -312,13 +312,13 @@ private struct RemoteNowPlayingContent: View {
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
.lineLimit(2)
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
if let subtitle = state.subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.multilineTextAlignment(.center)
.lineLimit(2)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
}
@@ -332,10 +332,10 @@ private struct RemoteNowPlayingContent: View {
Text("Playing on \(targetName)")
.font(.caption.weight(.medium))
}
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(Capsule().fill(Color.continuumChromeRestingFill))
+ .background(Capsule().fill(Color.prairieChromeRestingFill))
}
}
@@ -353,7 +353,7 @@ private struct RemoteNowPlayingContent: View {
self.scrubPreview = nil
}
)
- .tint(Color.continuumOnSurface)
+ .tint(Color.prairieOnSurface)
.disabled(state.duration <= 0)
.accessibilityLabel("Playback position")
@@ -363,7 +363,7 @@ private struct RemoteNowPlayingContent: View {
Text(remainingLabel)
}
.font(.caption.monospacedDigit())
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
@@ -386,13 +386,13 @@ private struct RemoteNowPlayingContent: View {
onCommand(.playPause)
} label: {
ZStack {
- Circle().fill(Color.continuumOnSurface).frame(width: 64, height: 64)
+ Circle().fill(Color.prairieOnSurface).frame(width: 64, height: 64)
if state.isLoading || state.isBuffering {
- ProgressView().tint(Color.continuumBackground)
+ ProgressView().tint(Color.prairieBackground)
} else {
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 28, weight: .medium))
- .foregroundStyle(Color.continuumBackground)
+ .foregroundStyle(Color.prairieBackground)
}
}
}
@@ -406,7 +406,7 @@ private struct RemoteNowPlayingContent: View {
}
.accessibilityLabel("Forward 30 seconds")
}
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.buttonStyle(.plain)
}
@@ -503,12 +503,12 @@ private struct RemoteNowPlayingContent: View {
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.footnote)
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.padding(.horizontal, 14)
- .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.continuumError.opacity(0.9)))
+ .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.prairieError.opacity(0.9)))
}
}
@@ -523,7 +523,7 @@ private struct RemoteChipLabel: View {
Text(caption)
.font(.caption2)
}
- .foregroundStyle(Color.continuumOnSurface.opacity(0.9))
+ .foregroundStyle(Color.prairieOnSurface.opacity(0.9))
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
@@ -622,7 +622,7 @@ struct PrairieCastTargetPickerView: View {
searchingState
}
}
- .background(Color.continuumBackground.ignoresSafeArea())
+ .background(Color.prairieBackground.ignoresSafeArea())
.navigationTitle("Cast")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -645,7 +645,7 @@ struct PrairieCastTargetPickerView: View {
ProgressView()
Text("Searching for Prairie TVs…")
.font(.headline)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -673,16 +673,16 @@ struct PrairieCastTargetPickerView: View {
HStack(spacing: 12) {
Image(systemName: "tv")
.font(.title3)
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.frame(width: 38, height: 38)
- .background(Circle().fill(Color.continuumChromeRestingFill))
+ .background(Circle().fill(Color.prairieChromeRestingFill))
VStack(alignment: .leading, spacing: 3) {
Text(target.name).font(.headline)
if let serverName = target.serverName {
Text(serverName)
.font(.subheadline)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
@@ -695,7 +695,7 @@ struct PrairieCastTargetPickerView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
- .listRowBackground(Color.continuumSurface)
+ .listRowBackground(Color.prairieSurface)
}
.scrollContentBackground(.hidden)
}
@@ -781,13 +781,13 @@ struct PrairieCastControlModeButton: View {
private func buttonLabel(isActive: Bool) -> some View {
Image(systemName: "airplayvideo")
.font(.system(size: 17, weight: .semibold))
- .foregroundStyle(isActive ? Color.continuumBackground : Color.continuumOnSurface)
+ .foregroundStyle(isActive ? Color.prairieBackground : Color.prairieOnSurface)
.frame(width: 40, height: 40)
.background {
- Circle().fill(isActive ? Color.continuumOnSurface : Color.continuumChromeRestingFill)
+ Circle().fill(isActive ? Color.prairieOnSurface : Color.prairieChromeRestingFill)
}
.overlay {
- Circle().stroke(isActive ? Color.clear : Color.continuumOutline, lineWidth: 1)
+ Circle().stroke(isActive ? Color.clear : Color.prairieOutline, lineWidth: 1)
}
.contentShape(Circle())
}
@@ -799,7 +799,7 @@ struct PrairieCastControlModeButton: View {
PrairieCastControlModeButton(controller: PrairieCastController(), onChooseTarget: {})
}
.padding()
- .background(Color.continuumBackground)
+ .background(Color.prairieBackground)
}
#endif
#endif
@@ -881,6 +881,6 @@ Expected: clean working tree for the cast files; `Prairie.xcodeproj` does not ap
**2. Placeholder scan:** No TBD/TODO/"handle errors"/"similar to". Every code step is complete and compilable.
-**3. Type consistency:** `PrairieCastArtworkResolver.resolve(contentId:)` / `.posterURL` / `.backdropURL`; `PrairieCastArtworkBackground(urlString:)`; `RemoteNowPlayingContent(state:targetName:posterURL:onCommand:)`; `RemoteChipLabel(systemImage:caption:)`; `PrairieCastPlaybackState.previewPlaying()` — all used consistently across tasks. Command factories (`.seek`, `.playPause`, `.stop`, `.selectAudioTrack`, `.selectSubtitleTrack`, `.setPlaybackSpeed`, `.setQuality`, `.setVideoGravity`, `.setHDREnabled`) match `PrairieCastControlCommand`. State fields match `PrairieCastPlaybackState`. `VideoGravity.allCases`/`.label`/`.rawValue`, `ResponseCache.shared.get`/`CacheKey.itemDetail`, `ContinuumAPI.shared.itemDetail(contentId:)`/`ItemDetail.posterUrl`/`.backdropUrl`, `AsyncImageView(url:contentMode:placeholderStyle:)`, `PlayerTimeFormatter.formatHMS` — all verified against the codebase.
+**3. Type consistency:** `PrairieCastArtworkResolver.resolve(contentId:)` / `.posterURL` / `.backdropURL`; `PrairieCastArtworkBackground(urlString:)`; `RemoteNowPlayingContent(state:targetName:posterURL:onCommand:)`; `RemoteChipLabel(systemImage:caption:)`; `PrairieCastPlaybackState.previewPlaying()` — all used consistently across tasks. Command factories (`.seek`, `.playPause`, `.stop`, `.selectAudioTrack`, `.selectSubtitleTrack`, `.setPlaybackSpeed`, `.setQuality`, `.setVideoGravity`, `.setHDREnabled`) match `PrairieCastControlCommand`. State fields match `PrairieCastPlaybackState`. `VideoGravity.allCases`/`.label`/`.rawValue`, `ResponseCache.shared.get`/`CacheKey.itemDetail`, `PrairieAPI.shared.itemDetail(contentId:)`/`ItemDetail.posterUrl`/`.backdropUrl`, `AsyncImageView(url:contentMode:placeholderStyle:)`, `PlayerTimeFormatter.formatHMS` — all verified against the codebase.
No gaps found.
diff --git a/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md b/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md
index 050368fae..f1cc37999 100644
--- a/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md
+++ b/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md
@@ -880,14 +880,14 @@ Replace `content` in `PrairieCastRemoteControlView` with:
VStack(spacing: 18) {
Image(systemName: "airplayvideo")
.font(.system(size: 44, weight: .medium))
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
Text("Connected to \(controller.activeTarget?.name ?? "Prairie TV")")
.font(.title3.weight(.semibold))
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
Text("Pick something from your library to start playing.")
.font(.subheadline)
.multilineTextAlignment(.center)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
.padding(32)
}
@@ -895,7 +895,7 @@ Replace `content` in `PrairieCastRemoteControlView` with:
private func statusView(title: String, showSpinner: Bool) -> some View {
VStack(spacing: 14) {
if showSpinner { ProgressView() }
- Text(title).font(.headline).foregroundStyle(Color.continuumSecondaryText)
+ Text(title).font(.headline).foregroundStyle(Color.prairieSecondaryText)
}
.padding(32)
}
@@ -938,7 +938,7 @@ Wrap the scrubber + time labels in a `TimelineView(.periodic(from: .now, by: 0.2
self.scrubPreview = nil
}
)
- .tint(Color.continuumOnSurface)
+ .tint(Color.prairieOnSurface)
.disabled(state.duration <= 0)
.accessibilityLabel("Playback position")
.accessibilityValue(PlayerTimeFormatter.formatHMS(live))
@@ -949,7 +949,7 @@ Wrap the scrubber + time labels in a `TimelineView(.periodic(from: .now, by: 0.2
Text(remainingLabel(live: live))
}
.font(.caption.monospacedDigit())
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
}
}
}
@@ -994,11 +994,11 @@ Add:
),
in: 0...1
)
- .tint(Color.continuumOnSurface)
+ .tint(Color.prairieOnSurface)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int((state.isMuted ? 0 : state.volume) * 100)) percent")
}
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.buttonStyle(.plain)
}
```
@@ -1064,7 +1064,7 @@ struct PrairieCastMiniBar: View {
.lineLimit(1)
Text("Playing on \(controller.activeTarget?.name ?? "Prairie TV")")
.font(.caption)
- .foregroundStyle(Color.continuumSecondaryText)
+ .foregroundStyle(Color.prairieSecondaryText)
.lineLimit(1)
}
Spacer(minLength: 8)
@@ -1082,8 +1082,8 @@ struct PrairieCastMiniBar: View {
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
- .stroke(Color.continuumOutline, lineWidth: 1))
- .foregroundStyle(Color.continuumOnSurface)
+ .stroke(Color.prairieOutline, lineWidth: 1))
+ .foregroundStyle(Color.prairieOnSurface)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -1103,9 +1103,9 @@ struct PrairieCastMiniBar: View {
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.continuumSurfaceElevated)
+ .fill(Color.prairieSurfaceElevated)
.frame(width: 34, height: 50)
- .overlay { Image(systemName: "tv").foregroundStyle(Color.continuumSecondaryText) }
+ .overlay { Image(systemName: "tv").foregroundStyle(Color.prairieSecondaryText) }
}
}
}
diff --git a/docs/superpowers/plans/2026-06-16-now-playing-shelf.md b/docs/superpowers/plans/2026-06-16-now-playing-shelf.md
index ff897af9a..606c53b8e 100644
--- a/docs/superpowers/plans/2026-06-16-now-playing-shelf.md
+++ b/docs/superpowers/plans/2026-06-16-now-playing-shelf.md
@@ -61,7 +61,7 @@ struct NowPlayingBarChrome: ViewModifier {
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(Color.continuumOutline, lineWidth: 1)
+ .strokeBorder(Color.prairieOutline, lineWidth: 1)
)
case .accessory:
content
@@ -87,8 +87,8 @@ Then replace the chrome chain. Change:
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
- .stroke(Color.continuumOutline, lineWidth: 1))
- .foregroundStyle(Color.continuumOnSurface)
+ .stroke(Color.prairieOutline, lineWidth: 1))
+ .foregroundStyle(Color.prairieOnSurface)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -101,7 +101,7 @@ to:
.padding(.horizontal, 14)
.padding(.vertical, 8)
.modifier(NowPlayingBarChrome(style: style))
- .foregroundStyle(Color.continuumOnSurface)
+ .foregroundStyle(Color.prairieOnSurface)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -127,7 +127,7 @@ Then replace the chrome chain. Change:
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay {
RoundedRectangle(cornerRadius: 14)
- .strokeBorder(Color.continuumOutline, lineWidth: 1)
+ .strokeBorder(Color.prairieOutline, lineWidth: 1)
}
.overlay(alignment: .bottomLeading) {
```
@@ -289,7 +289,7 @@ git commit -m "cast(iOS): NowPlayingShelf + tab-bar accessory attachment"
In `MainTabView.body`, delete the `.safeAreaInset` block that currently carries the two bars. Change:
```swift
- .tint(.continuumOnSurface)
+ .tint(.prairieOnSurface)
#if !os(macOS)
.safeAreaInset(edge: .bottom, spacing: 0) {
#if os(iOS)
@@ -305,7 +305,7 @@ In `MainTabView.body`, delete the `.safeAreaInset` block that currently carries
to:
```swift
- .tint(.continuumOnSurface)
+ .tint(.prairieOnSurface)
#if !os(macOS)
.fullScreenCover(isPresented: Binding(
```
diff --git a/docs/superpowers/specs/2026-06-14-companion-pairing-design.md b/docs/superpowers/specs/2026-06-14-companion-pairing-design.md
index 08933f2ed..c2e97f9e3 100644
--- a/docs/superpowers/specs/2026-06-14-companion-pairing-design.md
+++ b/docs/superpowers/specs/2026-06-14-companion-pairing-design.md
@@ -56,7 +56,7 @@ is still dramatically faster than thumb-typing a URL and password on a remote.
| Role | Platform | Behavior | Reuses |
|------|----------|----------|--------|
| **Receiver** | tvOS | Advertises on the LAN; waiting to be set up | `TVServerSetupView`, `ServerSetupViewModel` (URL normalize), `QRLoginViewModel`, `AppRouter` (auth state), `AppleDeviceIdentity`, `ServerRegistry`, `TokenStore` |
-| **Companion** | iOS | Browses; in-hand; already signed in | `ServerRegistry`, `TokenStore`, `ContinuumAPI`, `HTTPClient` |
+| **Companion** | iOS | Browses; in-hand; already signed in | `ServerRegistry`, `TokenStore`, `PrairieAPI`, `HTTPClient` |
| **Server** | Go | Mints tokens; unchanged in v1 | `/auth/device/{start,poll,approve}`, `GET /auth/device`, `GET /auth/sessions` |
### Server endpoints (already implemented — verified in prairie-server)
@@ -67,7 +67,7 @@ is still dramatically faster than thumb-typing a URL and password on a remote.
- State table `device_login_requests`: `pending → approved → consumed`, `pending → denied`, 10-minute TTL.
### New client method
-`ContinuumAPI.approveDevice(code:)` and `ContinuumAPI.lookupDevice(code:)` — the
+`PrairieAPI.approveDevice(code:)` and `PrairieAPI.lookupDevice(code:)` — the
endpoints exist but approval was previously web-only (`web/src/pages/ActivateDevice.tsx`),
so the iOS client needs these thin wrappers over `HTTPClient`.
@@ -87,7 +87,7 @@ Each unit has one purpose, a defined interface, and is testable in isolation.
discovered TVs (name, deviceId, state, endpoint). Owns Local Network permission
state and foreground/background lifecycle.
- **`CompanionPairingCoordinator`** — phone-side state machine. Connects to the
- chosen TV, runs the per-server loop, calls `ContinuumAPI` / `ServerRegistry` /
+ chosen TV, runs the per-server loop, calls `PrairieAPI` / `ServerRegistry` /
`TokenStore`. Publishes observable state for the UI.
- **UI:**
- `SetUpTVBanner` — auto-appears when a TV in `setup` state is discovered while
@@ -269,7 +269,7 @@ exposure below for v1.
- `PairingSession` framing over an in-memory loopback pair (partial reads,
multiple messages in one buffer, oversized frame rejection).
- `CompanionPairingCoordinator` and `ReceiverPairingCoordinator` state machines
- against a **fake transport** + **fake `ContinuumAPI`**: happy path, multi-server,
+ against a **fake transport** + **fake `PrairieAPI`**: happy path, multi-server,
partial failure, match-code mismatch/decline, token expiry, cancel, code expiry.
Assert **persist-on-success**: no `ServerRegistry` / `TokenStore` mutation until
`signedIn`, and the pending candidate is rolled back on every
diff --git a/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md b/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md
index 440c1222d..a3087bcb6 100644
--- a/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md
+++ b/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md
@@ -111,7 +111,7 @@ iOS view).
## 6. Screens
-Each screen = backdrop variant + centered `SILO.` wordmark + gold eyebrow + a
+Each screen = backdrop variant + centered `PRAIRIE.` wordmark + gold eyebrow + a
heading block + a liquid-glass form/content card.
### 6.1 Server setup — `ServerSetupView`
@@ -179,7 +179,7 @@ that reveals the protocol segments + port (focus-managed for the remote).
## 9. What stays untouched / out of scope
-- `AuthService`, `ServerRegistry`, `TokenStore`, `ContinuumAPI`, `HTTPClient`.
+- `AuthService`, `ServerRegistry`, `TokenStore`, `PrairieAPI`, `HTTPClient`.
- `AppRouter` auth-state machine and view models (`ServerSetupViewModel`,
`LoginViewModel`, `SignupViewModel`, `ProfileSelectionViewModel`,
`QRLoginViewModel`).
diff --git a/docs/superpowers/specs/2026-06-15-resume-first-home-design.md b/docs/superpowers/specs/2026-06-15-resume-first-home-design.md
index 571b5007c..d04644299 100644
--- a/docs/superpowers/specs/2026-06-15-resume-first-home-design.md
+++ b/docs/superpowers/specs/2026-06-15-resume-first-home-design.md
@@ -73,7 +73,7 @@ direction A (flat background) is the honest fit.
`noFeaturedTopSpacing(...)`) is rendered only in the no-featured branch to keep row 1 from
sliding under the floating header. With the hero permanently gone, this spacer becomes the
**permanent** first element of the stack.
-- **Background:** **flat OLED** (`Color.continuumBackground` / `.continuumBackground()`).
+- **Background:** **flat OLED** (`Color.prairieBackground` / `.prairieBackground()`).
Remove the page-level ambient hero machinery that exists only to back the carousel:
`heroTintBackground`, `heroBackdropImage`, `heroBackdropFadeMask`, `heroTintColor`,
`heroBackdropURL`, `heroBackdropThumbhash`, `computedHeroHeight`,
diff --git a/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md b/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md
index cd06b96b8..27e2372c3 100644
--- a/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md
+++ b/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md
@@ -37,7 +37,7 @@ Conceptual north star: **the detail page is the Skyline focus marquee bloomed to
full screen** — the same backdrop, eyebrow "tick," title, and metadata the
marquee was already previewing expand into an interactive page.
-Reused from `docs/skyline-design-guide.md` and `ContinuumTheme.swift`:
+Reused from `docs/skyline-design-guide.md` and `PrairieTheme.swift`:
- Background OLED black `#000000`; surfaces `#0A0A0A` / elevated `#15171C`.
- Monochrome chrome — primary text `#EDEDED`, secondary @ 60%, tertiary @ 38%,
@@ -48,7 +48,7 @@ Reused from `docs/skyline-design-guide.md` and `ContinuumTheme.swift`:
labels.
- Menus, the selector menus, and the overflow menu use Skyline `glass.strong`
(`#16171B` @ 86% + blur 40).
-- Radii: the squared-control family is **8 pt** (`ContinuumTheme.smallCornerRadius`).
+- Radii: the squared-control family is **8 pt** (`PrairieTheme.smallCornerRadius`).
Cards keep 12 pt; section card backgrounds 18 pt.
## 3. Scope — locked decisions
@@ -218,7 +218,7 @@ in the same family (audiobooks have square cover art, not a 16:9 still):
## 10. Motion
-Reuse `ContinuumTheme` durations: focus 0.12 s, transitions 0.20 s, crossfades
+Reuse `PrairieTheme` durations: focus 0.12 s, transitions 0.20 s, crossfades
0.30 s. Specifics: squared-control focus ring/scale 0.12 s; synopsis expand/
collapse 0.20 s height + fade; selector menu open = `glass.strong` scale
0.96→1.0 + fade. **Reduce Motion:** no scale/expand animation — snap; ring-only
diff --git a/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md b/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md
index ba839fed1..c9d6c9023 100644
--- a/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md
+++ b/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md
@@ -48,16 +48,16 @@ From `iosApp/iosApp/Cast/iOS/PrairieCastViews.swift`:
Reuse the existing iOS tokens — do not introduce new colors or a new type scale.
From `iosApp/iosApp/Theme/Colors.swift`:
-- Background OLED black `continuumBackground` `#000000`; elevated surface
- `continuumSurfaceElevated` `#15171C`; glass `continuumGlassStrong`
+- Background OLED black `prairieBackground` `#000000`; elevated surface
+ `prairieSurfaceElevated` `#15171C`; glass `prairieGlassStrong`
(`#16171B` @ 86%).
-- Monochrome chrome — primary `continuumOnSurface` `#EDEDED`, secondary
- `continuumSecondaryText` (`#EDEDED` @ 60%), hairline `continuumOutline`
- (white @ 12%). Active/selected chrome uses `continuumChromeSelectedFill`
- (white @ 14%) / resting `continuumChromeRestingFill` (white @ 7%).
+- Monochrome chrome — primary `prairieOnSurface` `#EDEDED`, secondary
+ `prairieSecondaryText` (`#EDEDED` @ 60%), hairline `prairieOutline`
+ (white @ 12%). Active/selected chrome uses `prairieChromeSelectedFill`
+ (white @ 14%) / resting `prairieChromeRestingFill` (white @ 7%).
- **No chromatic accent.** The play button is a white (`#EDEDED`) filled circle
with a black glyph; artwork is the only place full color lives. Errors use
- `continuumError`.
+ `prairieError`.
Typography uses the system text styles already in the file (`.title2`,
`.headline`, `.subheadline`, `.caption.monospacedDigit()`), not custom fonts.
@@ -85,13 +85,13 @@ background:
1. **Top bar** — leading `chevron.down` (minimize: `hideRemoteControl()` +
`dismiss()`, session stays alive); trailing `•••` overflow menu (see §8).
The TV name moves out of the nav title into the now-playing block.
-2. **Artwork card** — rounded (`continuumSurfaceElevated` placeholder), poster
+2. **Artwork card** — rounded (`prairieSurfaceElevated` placeholder), poster
aspect, rendered with `AsyncImageView(url:thumbhash:contentMode:)`. Modest
max height so the controls always fit without scrolling on small devices.
3. **Title / subtitle** — `state.title` (`.title2`, semibold) and `state.subtitle`
(`.subheadline`, secondary), centered, line-limited (2).
4. **"Playing on {TV}" pill** — `airplayvideo`/`tv` glyph + `activeTarget.name`
- in a subtle `continuumChromeRestingFill` capsule.
+ in a subtle `prairieChromeRestingFill` capsule.
5. **Scrubber** (§6).
6. **Transport row** (§6).
7. **Secondary control row** (§7).
@@ -99,7 +99,7 @@ background:
### Background
A full-bleed `AsyncImageView` of the backdrop (poster fallback), `.blur(radius:)`
-+ a `continuumBackground` scrim at high opacity for legibility, behind the
++ a `prairieBackground` scrim at high opacity for legibility, behind the
foreground stack. This is the single biggest contributor to the native feel.
## 6. Artwork resolution, scrubber & transport
@@ -111,7 +111,7 @@ A small resolver keyed on `state.contentId`:
- Resolves through the **same path the detail screen uses**: check
`ResponseCache.shared` under `CacheKey.itemDetail(contentId)` first (warm and
instant when the user cast from a detail page), otherwise
- `ContinuumAPI.shared.itemDetail(contentId:)`, reading `posterUrl` /
+ `PrairieAPI.shared.itemDetail(contentId:)`, reading `posterUrl` /
`backdropUrl` off the returned `ItemDetail`.
- Holds `posterURL` / `backdropURL` in `@State`; nil `contentId` (idle) → flat
black, no artwork. Failures degrade silently to the flat background.
@@ -165,7 +165,7 @@ as the entry point from Home.
- **Loading / buffering** (`isLoading` / `isBuffering`): spinner replaces the
play/pause glyph; scrubber disabled if `duration <= 0`.
- **Error** (`state.error ?? controller.errorMessage`): compact inline banner on
- `continuumSurfaceElevated` with `continuumError` text, not raw red caption.
+ `prairieSurfaceElevated` with `prairieError` text, not raw red caption.
## 10. Target picker (`PrairieCastTargetPickerView`)
@@ -181,8 +181,8 @@ as the entry point from Home.
## 11. Control-mode button (`PrairieCastControlModeButton`, Home top bar)
Keep the `airplayvideo`-in-a-circle. Replace the ad-hoc `Color.white`/`.clear`
-fills with the chrome tokens (`continuumChromeSelectedFill` /
-`continuumChromeRestingFill`, `continuumOutline` border). Active/connected state
+fills with the chrome tokens (`prairieChromeSelectedFill` /
+`prairieChromeRestingFill`, `prairieOutline` border). Active/connected state
reads clearly (filled + subtle connected indicator). Behavior (menu vs. picker)
is unchanged.
diff --git a/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md b/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md
index 570a4aa6f..6781903d3 100644
--- a/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md
+++ b/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md
@@ -3,7 +3,7 @@ title: Prairie Profile Launch Choice and Apple TV User Mapping
description: Implementation-ready design for automatic or required profile selection and per-user tvOS profile memory.
date: 2026-08-11
tags:
- - silo
+ - prairie
- apple
- profiles
- design-spec
@@ -13,11 +13,11 @@ tags:
- **Date:** 2026-08-11
- **Status:** Implemented; simulator-validated
-- **Repo:** `silo-apple` (iOS, iPadOS, macOS, tvOS, and the tvOS Top Shelf extension)
+- **Repo:** `prairie-apple` (iOS, iPadOS, macOS, tvOS, and the tvOS Top Shelf extension)
- **Server:** No change required
- **Android:** Parity follow-up recommended; not part of this implementation
-- **Exploration baseline:** `silo-apple` `fdb5225`; `silo-server` `origin/main`
- `2dc7d5e36`; `silo-android` `origin/main` `3efdbd90`
+- **Exploration baseline:** `prairie-apple` `fdb5225`; `prairie-server` `origin/main`
+ `2dc7d5e36`; `prairie-android` `origin/main` `3efdbd90`
## Implementation validation
@@ -374,7 +374,7 @@ picker. Do not automatically select a sibling profile.
### 8.1 Use the modern entitlement model
-Add this entitlement to both the `SiloTV` app and `SiloTVTopShelf` extension targets:
+Add this entitlement to both the `PrairieTV` app and `PrairieTVTopShelf` extension targets:
```xml
com.apple.developer.user-management
@@ -491,7 +491,7 @@ sign-out regression.
### 9.3 Personal-team signing
-Add the User Management key to `SiloTV.personal.entitlements` and
+Add the User Management key to `PrairieTV.personal.entitlements` and
`TopShelf.personal.entitlements` only if personal provisioning supports it. If it does not, keep a
documented local-signing fallback that compiles/runs without system-user separation; production
entitlements remain authoritative. Never replace the paid-team production entitlements to make a
@@ -683,6 +683,6 @@ Use a signed-in physical device; the Simulator does not prove Apple TV user stor
- `iosApp/iosApp/tvOS/Navigation/TVMainTabView.swift` — centralized explicit switch action.
- `iosApp/TopShelf/ContentProvider.swift` / `TopShelfHTTPClient.swift` — policy gating and split
credential reads.
-- `iosApp/SiloTV.entitlements`, `iosApp/TopShelf/TopShelf.entitlements`, and supported personal
+- `iosApp/PrairieTV.entitlements`, `iosApp/TopShelf/TopShelf.entitlements`, and supported personal
variants — User Management capability.
- `iosApp/Tests/` — resolver, migration, identity, Top Shelf, and account-boundary coverage.
diff --git a/docs/tvos-focus.md b/docs/tvos-focus.md
index 2f6f5b1a7..52045bc54 100644
--- a/docs/tvos-focus.md
+++ b/docs/tvos-focus.md
@@ -16,14 +16,35 @@ Use this for ordinary rows, grids, button groups, sheets, and menus where each
actionable item can be a real focus target.
- Render stable `Button`, `NavigationLink`, or `.focusable(true)` items.
-- Group related movement with `.focusSection()` and `.focusScope(...)`.
+ Every actionable element must be reachable by directional movement alone;
+ tvOS has no Tab-key or pointer fallback.
+- Use `.focusSection()` on a container so directional movement can enter it
+ and land on its nearest focusable child, for example a sidebar column that
+ does not line up with the grid beside it.
+- Use `.focusScope(namespace)` together with `prefersDefaultFocus(in:)` and
+ `resetFocus(in:)` to define where default focus lands inside that scope.
+ `focusScope` does not affect directional movement; `focusSection` does.
- Use `@FocusState`, `prefersDefaultFocus`, `defaultFocus`, or `resetFocus` to
seed or restore focus, not to fight the focus engine on every move.
+ `defaultFocus` is evaluated when the view first appears and on automatic
+ focus-state updates, not on user-driven moves, unless you pass
+ `priority: .userInitiated`.
+- Do not move focus programmatically in response to app state unless the
+ focused item disappeared. Apple's Human Interface Guidelines say to avoid
+ changing focus without the user's interaction; the one exception is moving
+ focus to a neighbour when the focused item is removed.
+- Rely on the system focus effect. Use `.focusEffectDisabled()` only when the
+ control draws its own focus appearance, and keep that appearance visually
+ consistent with the platform (scale, lift, highlight).
- Keep the focused subtree mounted and structurally stable while moving focus.
- Attach `onMoveCommand` only at intentional boundaries, such as "Up from the
first card returns to the top menu." Do not intercept normal in-zone movement.
- Move focus geometry with layout (`padding`, `frame`, alignment), not
`.offset`, because tvOS resolves focus from layout frames.
+- Focus can only reach views that are loaded. A `LazyHStack` unloads cards
+ outside its scroll view's bounds, so a row that pins the focused card to its
+ leading edge must extend the scroll view one card past the visible edge
+ (and mask the overflow) or the previous card is unreachable.
Good local examples:
@@ -31,6 +52,7 @@ Good local examples:
- `TVLibraryCollectionsView`
- `TVForYouDropdown`
- `TVProfileDropdown`
+- `TVEpisodeRail` (Series carousel: native cards pinned to the leading edge)
### Composite Focus Control
@@ -38,13 +60,23 @@ Use this when the visual control is one logical selector even though it renders
multiple highlighted rows or columns. A cascading selector is the main example.
- Make one container the real focus target with `.focusable(true)` and a single
- `@FocusState`.
+ `@FocusState`. On tvOS the default `interactions` set already includes
+ `.activate`, so `.focusable(true)` and
+ `.focusable(true, interactions: .activate)` behave the same; use the
+ explicit form only if the view is shared with macOS or iOS.
- Render rows as passive labels; do not make them `Button`s and do not attach
per-row `.focused(...)` bindings.
- Store the highlighted row/column in ordinary `@State`.
- Handle all D-pad movement for the composite with one `onMoveCommand`.
+ The focus engine resolves the move first. If a focusable lies beyond the
+ composite on an axis it owns, even diagonally or off screen, focus can
+ leave before the handler runs, especially on touch-surface swipes, and a
+ swipe can end with a stray orthogonal command. Prefer a native focus graph
+ for anything the focus engine can drive, such as a row of cards.
- Commit the highlighted selection on Select, usually with `onTapGesture` on
- the focused container.
+ the focused container. Use `onExitCommand` for Menu/Back and
+ `onPlayPauseCommand` for Play/Pause. Do not use `onKeyPress` for the Siri
+ Remote; Apple documents it as hardware-keyboard input only.
- Add useful accessibility labels and button/selected traits to the composite
or its rendered labels so VoiceOver still describes the action.
@@ -75,6 +107,42 @@ bar.focusedItem -> Calendar
When this happens, stop adding press interceptors. Decide which focus model the
control should use, then remove the other one.
+## Every Page Needs a Focus Owner
+
+A page that renders no focusable view leaves the focus engine with nothing
+focused. The remote stops responding, and Menu/Back never reaches the shell's
+`onExitCommand` either, because exit commands travel up the *focused* responder
+chain. The page reads as hard-frozen and force quit is the only way out. The
+top bar cannot save it: the shell disables the bar while it hands focus to
+content, so a disabled bar and a placeholder page leave zero focus targets in
+the window.
+
+Empty states, error states, and loading skeletons are the usual offenders —
+they are inert by nature, so it is easy to ship one that is the whole content
+of a page. Apply `tvPageFocusOwner` to any placeholder that can be a page's
+entire content. It makes the placeholder a real focus target, routes Up back to
+the top menu, and seeds itself from the shell's content hand-down token.
+
+`TVFocusWatchdogModifier` is the backstop, not the fix. Its first repair re-arms
+page content, which cannot land on a page with no focus target, so a repeated
+outage at the root escalates to the top menu (`tvFocusRepairAction`).
+
+## Settings Pane Navigation
+
+Settings uses two native focus sections. Right from a category enters its
+first available detail control. Left from any detail row returns to that
+same category, including after scrolling or dismissing a picker. Up/Down
+continues through the controls in the current pane.
+
+Give both pane entry targets `defaultFocus` with `.userInitiated` priority.
+Do not downgrade the rail's selected-category target while the detail pane
+owns focus: doing so lets Left choose a different category by geometry.
+The outer focus scope still chooses the active pane for entry and modal
+restoration. Remember the rail's current control separately from the selected
+category so cancelling Sign Out restores Sign Out, while Left from the details
+still restores the category. Back returns to the category, then exits Settings
+to Home.
+
## Top Menu Ownership
The top menu has three conceptual states:
@@ -98,6 +166,28 @@ When closing a panel, choose the next owner explicitly:
- Selecting a panel row closes, updates route/scope state, and then hands focus
to the destination content.
+## Selecting a Page from the Top Menu
+
+Selecting Home, another tab, or a panel destination enters the page at its
+first row or top control, including when reselecting the current page after
+Menu/Back. Reset the vertical scroll position before forwarding the entry
+focus request. A row feed also resets its first row to the first card.
+
+Use `TVMenuEntryScroll` on the page's scroll view to reveal lazy entry controls
+without animation. Forward focus after scroll geometry reports the top and
+the entry row has had a layout pass. Animating a long scroll while claiming
+focus lets intermediate rows take focus and cancel the entry row's
+restoration ownership. Keep page identity and loaded data stable;
+a menu selection only resets scrolling and entry focus.
+
+If Menu/Back returns ownership to the bar or a panel before the entry request
+finishes, cancel that request. Do not replay it when menu focus clears; the
+next deliberate page selection provides a new request. Cancel on disappearance
+as well, so queued layout callbacks cannot focus a page that has been left.
+
+Returning from a card's detail page still restores the launching card. Ordinary
+Up/Down navigation remains owned by the native focus engine.
+
## Debugging Checklist
When tvOS focus feels random, capture logs for the ownership boundary first:
@@ -130,7 +220,24 @@ Unexpected signs:
## References
-- Apple tvOS focus engine and remote guidance:
- https://developer.apple.com/library/archive/documentation/General/Conceptual/AppleTV_PG/WorkingwiththeAppleTVRemote.html
+- Human Interface Guidelines, Focus and selection (system focus effects, do
+ not move focus without user interaction, every tvOS element must be
+ reachable):
+ https://developer.apple.com/design/human-interface-guidelines/focus-and-selection
+- UIKit, About focus interactions for Apple TV (focus engine rules; only the
+ engine moves focus directionally):
+ https://developer.apple.com/documentation/uikit/about-focus-interactions-for-apple-tv
+- SwiftUI Focus overview (focusable, FocusState, focusScope, focusSection,
+ default focus, resetFocus, focus effects):
+ https://developer.apple.com/documentation/swiftui/focus
- SwiftUI `focusSection()`:
- https://developer.apple.com/documentation/swiftui/view/focussection%28%29
+ https://developer.apple.com/documentation/swiftui/view/focussection()
+- SwiftUI `focusable(_:interactions:)`:
+ https://developer.apple.com/documentation/swiftui/view/focusable(_:interactions:)
+- SwiftUI `defaultFocus(_:_:priority:)`:
+ https://developer.apple.com/documentation/swiftui/view/defaultfocus(_:_:priority:)
+- SwiftUI `onMoveCommand(perform:)`, `onExitCommand(perform:)`,
+ `onPlayPauseCommand(perform:)`:
+ https://developer.apple.com/documentation/swiftui/view/onmovecommand(perform:)
+- Focus Cookbook sample (WWDC23, "The SwiftUI cookbook for focus"):
+ https://developer.apple.com/documentation/swiftui/focus-cookbook-sample
diff --git a/docs/tvos-onboarding/base.css b/docs/tvos-onboarding/base.css
index 421c62a6f..8ddbf96fb 100644
--- a/docs/tvos-onboarding/base.css
+++ b/docs/tvos-onboarding/base.css
@@ -1,5 +1,5 @@
/* ============================================================================
- Silo tvOS — First-Run Redesign Mockups
+ Prairie tvOS — First-Run Redesign Mockups
base.css — shared design tokens (Skyline), component kit, 1920×1080 scaler
Tokens sourced from docs/skyline-design-guide.md + iosApp/.../Theme/*.swift
(1 tvOS pt = 1 px on the 1920×1080 canvas)
@@ -38,7 +38,7 @@
--field-fill: #15171c;
--field-border: rgba(255, 255, 255, 0.22);
- /* Radii (ContinuumTheme.swift) */
+ /* Radii (PrairieTheme.swift) */
--r-card: 12px;
--r-small: 8px;
--r-container: 18px;
diff --git a/docs/tvos-onboarding/base.js b/docs/tvos-onboarding/base.js
index 71dd1889c..7d9703b18 100644
--- a/docs/tvos-onboarding/base.js
+++ b/docs/tvos-onboarding/base.js
@@ -33,7 +33,7 @@
}
function renderQR(el) {
var n = 25; // modules
- var rnd = seeded(el.getAttribute("data-qr") || "silo");
+ var rnd = seeded(el.getAttribute("data-qr") || "prairie");
var cells = "";
function reserved(r, c) {
return (r < 8 && c < 8) || (r < 8 && c > n - 9) || (r > n - 9 && c < 8);
diff --git a/docs/tvos-onboarding/index.html b/docs/tvos-onboarding/index.html
index 206666b54..c521811a3 100644
--- a/docs/tvos-onboarding/index.html
+++ b/docs/tvos-onboarding/index.html
@@ -99,7 +99,7 @@ Three takes on the same flow
Each renders all five screens end-to-end. All are fully dark (TV research strongly favors it) and reuse your Skyline color/spacing/motion tokens — they differ in mood, surface, and how loudly the phone-handoff idea is expressed.