diff --git a/CLAUDE.md b/CLAUDE.md index 22adc040..8c438a7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,9 +141,11 @@ Active feature: `1100-purchase-gate` (branch `1100-purchase-gate`, cut from `mai spec at `specs/1100-purchase-gate/spec.md`, current plan at `specs/1100-purchase-gate/plan.md` (+ research/data-model/contracts/quickstart, 2026-07-19). Purchase gate / one-time unlocks: the free core stays whole (all sources + full core playback, basic -transitions); paid tiers **Pro** (ambience — launch composition **Ken Burns motion + clock -overlay**, amended 2026-07-19; never-publicly-shipped features only) and **Automation** (HA/MQTT -+ App Intents) plus an optional everything-bundle; one-time purchases only, no subscriptions ever, +transitions); one paid **Supporter Unlock** grants everything gated — ambience (**Ken Burns +motion + clock overlay**, amended 2026-07-19; never-publicly-shipped features only) *and* +automation (HA/MQTT + App Intents). The former Pro/Automation tiers and the everything-bundle +were **collapsed into that single unlock on 2026-07-23** (PR #40): `Entitlement` has one case, +`ProductCatalog.unlocks` is `[.supporter]`. One-time purchases only, no subscriptions ever, never the word "lifetime"; Family Sharing + universal purchase (incl. tvOS); on-device entitlement caching so unattended frames work offline indefinitely; never-claw-back (FR-1100-13); **sequencing is release-blocking: the gated build must be the first version the @@ -154,8 +156,11 @@ anywhere in this public repo — pricing is decided in App Store Connect at subm **real `StoreKitClient` StoreKit 2 adapter**, `LockedRow`/`UnlockScreenView`/`TipJarView`), gates at the point of effect in both apps, Unlocks settings section (Restore + tip jar), US5 broker degradation (masked config behind a locked banner), and launch `refresh()` + `listenForUpdates()` -now wired on both apps' production entry points. PurchaseKit 110 host tests + full iOS suite -**153/0/9** green on iOS 18.6. **T030 fully done — the old "caveat" was a misdiagnosis, corrected +now wired on both apps' production entry points. **Current measured gate (2026-07-25, iPad Pro +11-inch (M4) sim): PurchaseKit 106 host tests green, full iOS suite 163/0/5** — the 5 skips are +the ASC-screenshot, live-smoke, and 3 device-rig items. (The older "110 host / 153/0/9" figures +predate the tier collapse and the post-PR-#40 review; re-measure before quoting any count.) +**T030 fully done — the old "caveat" was a misdiagnosis, corrected 2026-07-21.** It held that `SKTestSession` serves 0 products under headless `xcodebuild` ("the runner, not the runtime") so its 7 cases needed the Xcode IDE or a device. Actually two setup bugs in the test: `configurationFileNamed:` resolves against `Bundle.main` (the *host app* bundle, which @@ -163,10 +168,11 @@ lacks `Configuration.storekit`) and fails **silently**; and `resetToDefaultState `disableDialogs`, so setting it first left Ask-to-Buy blocking on a dialog. Both fixed, skip-guard replaced by a hard assertion, all 7 passing on the iOS 18.6 sim + Framepad (17.7.10) + FramePhone (26.0.1). Runs headlessly in CI; nothing folds into T042. See `docs/testing.md`; issue #16 closed. -**Also note:** a 2026-07-21 full-suite run found two failures **pre-existing on `main`** and -unrelated to 1100 — `BrokerSetupUITests` (#21) and `ShareSheetIncomingUITests` (#22, order-dependent) -— so the "153/0/9 green" line above no longer reproduces as stated. **T033 done (2026-07-20):** the tvOS unlock surface — new `TVSettingsView` (gear -destination) with the Ambience/Pro locked row, an Automation-gated Home-Assistant row +**Resolved:** the two failures a 2026-07-21 run found pre-existing on `main` — `BrokerSetupUITests` +(#21) and `ShareSheetIncomingUITests` (#22, order-dependent) — were fixed in PR #36; both issues are +closed and all six of their cases pass in the 2026-07-25 run. **T033 done (2026-07-20):** the tvOS unlock surface — new `TVSettingsView` (gear +destination) with the ambience locked row, a Home-Assistant row (both Supporter-gated since the +2026-07-23 collapse; they were Pro- and Automation-gated when T033 landed) (`TVLockedBrokerView` masked-config banner when unentitled), and an Unlocks section (Restore + tip), reusing PurchaseKit UI via `fullScreenCover`; Apple-TV-simulator screenshot-verified under the `--uitest-entitlements` seams; the shared unlock/tip screens gained a tvOS-only opaque diff --git a/OwnFrame/Intents/FrameIntents.swift b/OwnFrame/Intents/FrameIntents.swift index 5b7f4daa..ad778573 100644 --- a/OwnFrame/Intents/FrameIntents.swift +++ b/OwnFrame/Intents/FrameIntents.swift @@ -17,9 +17,12 @@ import AppIntents import AppIntentsKit -/// The contract's user-facing error copy (English-only, FR-300-30), mapped 1:1 -/// from the package's closed taxonomy. Parameter details (like the rejected -/// percent) stay out of the copy by design — the message names the rule. +/// The contract's user-facing error copy, mapped 1:1 from the package's closed +/// taxonomy. The wording ships localized through the app's String Catalog +/// (FR-300-30; German since 2026-07-23) — the glue tests pin the locale to English +/// so they assert the contract wording rather than the runner's language. +/// Parameter details (like the rejected percent) stay out of the copy by design — +/// the message names the rule. enum FrameIntentError: Error, Equatable, CustomLocalizedStringResourceConvertible { case notConfigured case frameNotOpen diff --git a/OwnFrame/Localizable.xcstrings b/OwnFrame/Localizable.xcstrings index a9c3f2a4..7cf9b92e 100644 --- a/OwnFrame/Localizable.xcstrings +++ b/OwnFrame/Localizable.xcstrings @@ -2552,12 +2552,12 @@ } } }, - "Where your money goes: the unlocks cover the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever." : { + "Where your money goes: the Supporter Unlock covers the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wohin dein Geld fließt: Die Freischaltungen decken die laufenden Kosten des Projekts — Entwicklerkonto, KI-Tools, Testgeräte — und alles darüber hinaus geht zurück an Open-Source-Projekte, die der Gemeinschaft dienen. Die kostenlose App bleibt für immer voll funktionsfähig." + "value" : "Wohin dein Geld fließt: Die Supporter-Freischaltung deckt die laufenden Kosten des Projekts — Entwicklerkonto, KI-Tools, Testgeräte — und alles darüber hinaus geht zurück an Open-Source-Projekte, die der Gemeinschaft dienen. Die kostenlose App bleibt für immer voll funktionsfähig." } } } diff --git a/OwnFrame/Onboarding/AlbumPickerView.swift b/OwnFrame/Onboarding/AlbumPickerView.swift index 5e0765d5..db29be1f 100644 --- a/OwnFrame/Onboarding/AlbumPickerView.swift +++ b/OwnFrame/Onboarding/AlbumPickerView.swift @@ -105,7 +105,11 @@ struct AlbumPickerView: View { static func subtitle(for album: Album) -> String? { var parts: [String] = [] if let dateText = dateText(album.startDate, album.endDate) { parts.append(dateText) } - if let count = album.assetCount { parts.append(count == 1 ? "1 photo" : "\(count) photos") } + // Built as a String and joined, so the count needs an explicit lookup — a bare literal + // here would ship the English text into an otherwise localized subtitle. + if let count = album.assetCount { + parts.append(count == 1 ? String(localized: "1 photo") : String(localized: "\(count) photos")) + } return parts.isEmpty ? nil : parts.joined(separator: " · ") } diff --git a/OwnFrame/Onboarding/PhotoAlbumPickerView.swift b/OwnFrame/Onboarding/PhotoAlbumPickerView.swift index f66698f4..25592744 100644 --- a/OwnFrame/Onboarding/PhotoAlbumPickerView.swift +++ b/OwnFrame/Onboarding/PhotoAlbumPickerView.swift @@ -117,8 +117,14 @@ struct PhotoAlbumPickerView: View { @ViewBuilder private func limitedContent(_ pool: SourceCollection?) -> some View { - let label = pool?.title ?? String(localized: "Selected Photos") - let isAdded = sourceLibrary.sources.contains { $0.label == label } + // The pool's model-side title is a fixed English identifier (PhotoLibraryKit ships no + // catalog); the row shows — and persists — the localized name instead. Identity is the + // sentinel collection ID, not the label, so neither a rename nor a language switch can + // make an already-added pool look un-added. + let label = String(localized: "Selected Photos") + let isAdded = sourceLibrary.sources.contains { + $0.kind == .photoLibrary(collectionID: PhotoLibrarySource.selectedPhotosID) + } List { Section { Button { diff --git a/OwnFrame/Slideshow/SlideshowSettingsView.swift b/OwnFrame/Slideshow/SlideshowSettingsView.swift index 03cef8d2..3eb3da61 100644 --- a/OwnFrame/Slideshow/SlideshowSettingsView.swift +++ b/OwnFrame/Slideshow/SlideshowSettingsView.swift @@ -374,7 +374,9 @@ struct SlideshowSettingsView: View { } footer: { VStack(alignment: .leading, spacing: 10) { Text("Restore purchases you already own. Tips are optional and unlock nothing — they just say thanks.") - Text("Where your money goes: the unlocks cover the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever.") + // Transparency statement (docs/where-the-money-goes.md) — word-for-word the + // tvOS copy in TVSettingsView, so both platforms share one catalog entry. + Text("Where your money goes: the Supporter Unlock covers the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever.") .accessibilityIdentifier("settings.unlocks.moneyPledge") } } diff --git a/OwnFrameTests/FrameIntentGlueTests.swift b/OwnFrameTests/FrameIntentGlueTests.swift index f350b9a5..ddb62f90 100644 --- a/OwnFrameTests/FrameIntentGlueTests.swift +++ b/OwnFrameTests/FrameIntentGlueTests.swift @@ -68,6 +68,18 @@ struct FrameIntentGlueTests { // MARK: - Error mapping (contract copy, state untouched) + /// The contract copy resolved in English, whatever the runner's locale is. + /// + /// This copy ships translated (spec 300), so a bare `String(localized:)` renders German on a + /// German device and the comparison against the contract text goes red for the wrong reason. + /// Pinning the locale keeps these assertions about the *contract wording*, not the tester's + /// language — the translations themselves are covered by the catalogs. + private func contractCopy(_ error: FrameIntentError) -> String { + var resource = error.localizedStringResource + resource.locale = Locale(identifier: "en") + return String(localized: resource) + } + @Test func outOfRangeBrightnessThrowsTheContractCopyAndRecordsNothing() async throws { let fixture = try Fixture() defer { fixture.restore() } @@ -79,7 +91,7 @@ struct FrameIntentGlueTests { } #expect(fixture.surface.calls.isEmpty) #expect( - String(localized: FrameIntentError.brightnessOutOfRange.localizedStringResource) + contractCopy(.brightnessOutOfRange) == "Brightness must be between 0 and 100 percent." ) } @@ -92,18 +104,18 @@ struct FrameIntentGlueTests { _ = try await PauseSlideshowIntent().perform() } #expect( - String(localized: FrameIntentError.notConfigured.localizedStringResource) + contractCopy(.notConfigured) == "Set up the frame first — open OwnFrame and add a source." ) } @Test func remainingContractCopyMatches() { #expect( - String(localized: FrameIntentError.frameNotOpen.localizedStringResource) + contractCopy(.frameNotOpen) == "OwnFrame must be open on the frame device for this." ) #expect( - String(localized: FrameIntentError.sourceMissing.localizedStringResource) + contractCopy(.sourceMissing) == "This source no longer exists in the frame's library." ) } diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings b/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings index 3434cae1..b6c4cbe3 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings +++ b/Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings @@ -342,6 +342,17 @@ } } }, + "The purchase could not be completed." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Kauf konnte nicht abgeschlossen werden." + } + } + } + }, "Tip" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/PurchaseViewModel.swift b/Packages/PurchaseKit/Sources/PurchaseKit/PurchaseViewModel.swift index 1c46bc4f..18ea5ae0 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/PurchaseViewModel.swift +++ b/Packages/PurchaseKit/Sources/PurchaseKit/PurchaseViewModel.swift @@ -194,9 +194,12 @@ public final class PurchaseViewModel { /// /// The store's own description is appended when it has one, because "why" is the entire point /// of the `.failed` case; the leading sentence guarantees the message is never empty or raw. - /// Not localized — the app ships English-only by design (CLAUDE.md). + /// + /// The lead resolves against `.module` — a package's strings do not live in the app bundle, + /// and this one renders under an already-translated title. The appended store description + /// comes from StoreKit and is localized by the system. private static func failureMessage(for error: any Error) -> String { - let lead = "The purchase could not be completed." + let lead = String(localized: "The purchase could not be completed.", bundle: .module) guard let description = (error as? any LocalizedError)?.errorDescription, !description.isEmpty else { return lead } diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/UI/LockedRow.swift b/Packages/PurchaseKit/Sources/PurchaseKit/UI/LockedRow.swift index a8481899..21fb5de3 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/UI/LockedRow.swift +++ b/Packages/PurchaseKit/Sources/PurchaseKit/UI/LockedRow.swift @@ -8,8 +8,9 @@ extension Entitlement { /// The user-facing unlock name, as it appears on locked badges and unlock screens. /// /// Presentation only, which is why it lives beside the view rather than in the model: the - /// entitlement itself is a capability, not a marketing name. Not localized — the repo ships - /// English-only by design (CLAUDE.md). + /// entitlement itself is a capability, not a marketing name. Deliberately *not* localized: + /// "Supporter" is the product's proper name, the fixed morpheme every locale keeps — the + /// German copy builds on it too ("Supporter-Freischaltung"). public var displayName: String { switch self { case .supporter: "Supporter" diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/UI/TipJarView.swift b/Packages/PurchaseKit/Sources/PurchaseKit/UI/TipJarView.swift index b2d6831e..dfbca26e 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/UI/TipJarView.swift +++ b/Packages/PurchaseKit/Sources/PurchaseKit/UI/TipJarView.swift @@ -308,7 +308,9 @@ public struct TipJarView: View { // MARK: - Presentation copy // // The short slug used in accessibility identifiers — never the raw ASC identifier, which is a -// bundle-prefixed string no test should have to spell. English only, by design (CLAUDE.md). +// bundle-prefixed string no test should have to spell. Always English, and never localized: +// these are test-contract identifiers, not display copy — translating them would break every +// accessibility-identifier assertion the moment the device language changed. private extension ProductID { var tipSlug: String { diff --git a/Packages/PurchaseKit/Sources/PurchaseKit/UI/UnlockScreenView.swift b/Packages/PurchaseKit/Sources/PurchaseKit/UI/UnlockScreenView.swift index 86e8b939..f93a87b0 100644 --- a/Packages/PurchaseKit/Sources/PurchaseKit/UI/UnlockScreenView.swift +++ b/Packages/PurchaseKit/Sources/PurchaseKit/UI/UnlockScreenView.swift @@ -448,7 +448,8 @@ public struct UnlockScreenView: View { // // Marketing wording for the unlock and its product lives beside the screen that says it, not in // the model: an `Entitlement` is a capability, and a `ProductID` is an App Store Connect identifier. -// English only — the repo ships English-only by design (CLAUDE.md). +// The wording itself is localized against the package catalog (`bundle: .module`) — a package's +// strings do not live in the app bundle; what sits here is the *choice* of wording, not its text. private struct UnlockBenefit { let title: String diff --git a/Packages/PurchaseKit/Tests/PurchaseKitTests/EntitlementStoreTests.swift b/Packages/PurchaseKit/Tests/PurchaseKitTests/EntitlementStoreTests.swift index c48667d0..dd0c1d76 100644 --- a/Packages/PurchaseKit/Tests/PurchaseKitTests/EntitlementStoreTests.swift +++ b/Packages/PurchaseKit/Tests/PurchaseKitTests/EntitlementStoreTests.swift @@ -162,9 +162,13 @@ private final class StoreFixture { #expect(relaunched.client.totalCallCount == 0) } -/// FR-1100-08: a tip purchase resolves to no entitlement, and a revoked unlock contributes -/// nothing, while a live unlock still grants — the tip and the refunded transaction are both -/// ignored, the surviving one is honoured. +/// FR-1100-08 / FR-1100-12: a tip resolves to no entitlement, and a revoked unlock contributes +/// nothing — so a library holding only those two grants nothing at all. +/// +/// The revoked transaction is deliberately the *only* one for its product. With a single unlock, +/// adding a live duplicate alongside it (as this test did before the tier collapse, when a second +/// live tier carried the assertion) makes the expectation pass whether or not `isRevoked` is +/// honoured. The duplicate case is worth asserting too — it lives in the test below. @MainActor // @covers FR-1100-08, FR-1100-12 @Test func refreshIgnoresTipsAndRevokedTransactions() async { @@ -172,6 +176,21 @@ private final class StoreFixture { fixture.client.enqueueOwnedTransactions([ OwnedTransaction(productID: ProductID.tipLarge.rawValue, isRevoked: false), OwnedTransaction(productID: ProductID.supporter.rawValue, isRevoked: true), + ]) + + await fixture.store.refresh() + + #expect(fixture.store.current == EntitlementSet.none) +} + +/// A refunded transaction does not cancel a second, live purchase of the same unlock — the live +/// one still grants. Together with the test above this pins both directions of `isRevoked`. +@MainActor +// @covers FR-1100-12, FR-1100-13 +@Test func refreshHonoursALiveUnlockAlongsideARevokedDuplicate() async { + let fixture = StoreFixture() + fixture.client.enqueueOwnedTransactions([ + OwnedTransaction(productID: ProductID.supporter.rawValue, isRevoked: true), OwnedTransaction(productID: ProductID.supporter.rawValue, isRevoked: false), ]) diff --git a/docs/handover-release-prep.md b/docs/handover-release-prep.md index d3f5aabc..85d2e5a0 100644 --- a/docs/handover-release-prep.md +++ b/docs/handover-release-prep.md @@ -1,5 +1,11 @@ # Handover — Release Prep +> **Historical as of 2026-07-25.** This captured the state on 2026-07-09 and is kept for the +> naming/ASC provenance only. Everything its "Deferred" section lists has since shipped — +> `800-app-intents`, `900-photo-library-source`, the `320` disk image cache, the `510` clock +> overlay, and the German localization (topic 300, 2026-07-23). Do not read the roadmap parts +> as current: start from `docs/spec-overview.md` and the module spec under `specs/Nxx-*/`. + State as of 2026-07-09. Read this first in the next session; the previous handover (`handover-live-ha-verification.md`) is historical — that work is done. diff --git a/docs/manual-verification.md b/docs/manual-verification.md index f3346e5f..520f7d30 100644 --- a/docs/manual-verification.md +++ b/docs/manual-verification.md @@ -90,7 +90,7 @@ account, a second device, a family member account, and ASC access. **Nothing her - [ ] Create the IAPs with ids matching `ProductID` raw values **character-for-character** (`Packages/PurchaseKit/Sources/PurchaseKit/ProductCatalog.swift` is the source of truth). Id drift has no compile-time signal and fails only at runtime as "products unavailable". -- [ ] Family Sharing **ON** for the three non-consumable unlocks, OFF for the tips. +- [ ] Family Sharing **ON** for the Supporter Unlock, OFF for the tips. - [ ] Localized names/descriptions use "one-time purchase"; the word **"lifetime" appears nowhere**, and nothing implies a subscription (FR-1100-05). - [ ] Prices set here and only here — never committed to this repo. The `.storekit` file's @@ -161,8 +161,8 @@ account, a second device, a family member account, and ASC access. **Nothing her narrow: install the gated build on a *configured* frame and confirm the app emits those empty retained payloads on connect. Framepad could not do it in that session — its app was already unconfigured, see the note at the end of this section. -- [ ] Buy Automation → the controllable entities appear and HA control resumes using the previously - stored settings with **zero re-entry** (FR-1100-14). +- [ ] Buy the Supporter Unlock → the controllable entities appear and HA control resumes using the + previously stored settings with **zero re-entry** (FR-1100-14). > **Frame state note (2026-07-21).** Framepad (iPad Pro 10.5, iOS 17.7.10 — the deployment floor) > currently carries a **dev-signed Debug build** and its app is **unconfigured**: no source, no diff --git a/docs/spec-overview.md b/docs/spec-overview.md index c0e83cdb..d90c413a 100644 --- a/docs/spec-overview.md +++ b/docs/spec-overview.md @@ -48,7 +48,7 @@ existing module becomes a sub-spec (`N10`, `N20`, …) or amends the module spec | 800 | [app-intents](../specs/800-app-intents/spec.md) | app target (+ AppIntentsKit) | Shortcuts/Siri/personal-automation control via App Intents — second front-end to 700's command surface. | Implemented on branch (2026-07-17); device gates scheduled | | 900 | [photo-library-source](../specs/900-photo-library-source/spec.md) | PhotoLibraryKit (new) | Apple Photos / iCloud albums (incl. Shared Albums) as a source, behind a backend-neutral source protocol. Amended 2026-07-16: full-access gate, shared-album quality ceiling, iOS 27 rebuild risk. | Implemented on branch (2026-07-16); device/beta gates scheduled | | 1000 | [apple-tv](../specs/1000-apple-tv/spec.md) | tvOS app target (new) | Apple TV port: same packages/engine on tvOS 17+, purgeable-storage discipline, config sync (non-secrets via KVS, secrets E2E via CloudKit encrypted fields — constitution III v1.1.0), remote-first chrome, HA device parity. | In progress on branch `1000-apple-tv` (2026-07-18): **all four user stories implemented + sim-verified.** US1 (frame plays, real demo-link end-to-end), US2 (onboarding + real-source routing + KVS prefill/restore + secret hydration seam), US3 (purge-tolerance), US4 (HA adapter + coordinator with distinct identity + broker onboarding). All packages tvOS + new ConfigSyncKit; software-dim, remote chrome, FR-1000-07 bypass removed; iPad companion publishes full payload on launch/foreground; ThemeSettings Codable. iOS XCUITest 120/0/2. Ken Burns redesigned on this branch (2026-07-18 micro-judder fix): shared scoped-animation `KenBurnsMotionModifier` + `DecodedImageStore` decode-ahead — motion contract unchanged, swap decode-stalls eliminated (see 1000 tasks.md Status). Remaining (device-gated): real MQTT/CloudKit, tvOS clock + FR-1000-10 pixel-shift, real-hardware gates (SC-1000-02/05/06/08 + CloudKit-on-tvOS proof + 24h soak). | -| 1100 | [purchase-gate](../specs/1100-purchase-gate/spec.md) | app targets (package decided at plan time) | Purchase gate: free core stays whole (all sources + core playback + basic transitions); two one-time unlocks — Pro (ambience; launch: **Ken Burns + clock**, never-publicly-shipped rule) and Automation (HA/MQTT + App Intents) — plus optional bundle; offline entitlement caching for unattended frames; Family Sharing + universal purchase (incl. tvOS); never-claw-back; gated build must be the **first** public release (v1.0 b8 stays unreleased). No price points in-repo by design. | Implemented on branch `1100-purchase-gate` (2026-07-20): entitlement model + all US1–US6 gates/UI + US5 broker degradation + the real StoreKit adapter + the tvOS unlock surface committed & green (PurchaseKit 110 host, full iOS suite 153/0/9; 9 skips = ASC-screenshot + live-smoke + the 7 SKTestSession cases, which skip under headless `xcodebuild` and run for real from the Xcode IDE / device). tvOS surface Apple-TV-simulator screenshot-verified. Remaining: T040/T041 gate+docs, T042 ASC/device day (incl. the one IDE/device StoreKitTest run). | +| 1100 | [purchase-gate](../specs/1100-purchase-gate/spec.md) | app targets (package decided at plan time) | Purchase gate: free core stays whole (all sources + core playback + basic transitions); a single one-time **Supporter Unlock** grants every gated capability at once (ambience — **Ken Burns + clock**, never-publicly-shipped rule — plus HA/MQTT + App Intents); no tiers, no bundle; offline entitlement caching for unattended frames; Family Sharing + universal purchase (incl. tvOS); never-claw-back; gated build must be the **first** public release (v1.0 b8 stays unreleased). No price points in-repo by design. | Implemented on branch `1100-purchase-gate` (2026-07-20): entitlement model + all US1–US6 gates/UI + US5 broker degradation + the real StoreKit adapter + the tvOS unlock surface committed & green (PurchaseKit 110 host, full iOS suite 153/0/9; 9 skips = ASC-screenshot + live-smoke + the 7 SKTestSession cases, which skip under headless `xcodebuild` and run for real from the Xcode IDE / device). tvOS surface Apple-TV-simulator screenshot-verified. Remaining: **T042** only — ASC/device day (incl. the one IDE/device StoreKitTest run). | ## How they connect diff --git a/docs/where-the-money-goes.md b/docs/where-the-money-goes.md index 8a09bc76..b1bc9977 100644 --- a/docs/where-the-money-goes.md +++ b/docs/where-the-money-goes.md @@ -4,7 +4,7 @@ > statement. The in-app version (see "Short in-app version" below) is the string to be > localized in a separate file; this document itself stays English-only. -The paid unlocks in OwnFrame keep this project alive, and I want to be +The Supporter Unlock in OwnFrame keeps this project alive, and I want to be straight with you about where the money goes. **Costs first.** What comes in first covers what it actually costs me to build and ship @@ -21,20 +21,20 @@ giving the surplus back is my promise. **The free frame stays whole, forever.** You never have to pay to run a beautiful photo frame. Every photo source and the full core slideshow are free, and always will be. The -paid unlocks are for the extras — and for keeping the lights on and paying it forward. +Supporter Unlock is for the extras — and for keeping the lights on and paying it forward. --- -*The unlocks are one-time purchases that unlock features. This page is my personal +*The Supporter Unlock is a one-time purchase that unlocks features. This page is my personal commitment about what I do with the proceeds — not a donation, and not a contract.* ## Short in-app version Condensed copy for the Unlocks settings section (the string to be localized separately): -> The unlocks keep this project going. They cover my costs — Apple Developer Program, AI -> tools, test hardware — and everything beyond that I give to open-source projects that -> serve the community. The free frame stays whole, forever. +> The Supporter Unlock keeps this project going. It covers my costs — Apple Developer +> Program, AI tools, test hardware — and everything beyond that I give to open-source +> projects that serve the community. The free frame stays whole, forever. ## Notes (not user-facing) diff --git a/specs/1100-purchase-gate/contracts/uitest-seams.md b/specs/1100-purchase-gate/contracts/uitest-seams.md index ce9d8603..a24064a0 100644 --- a/specs/1100-purchase-gate/contracts/uitest-seams.md +++ b/specs/1100-purchase-gate/contracts/uitest-seams.md @@ -39,11 +39,12 @@ against the stub source, hermetic, no server). dimmed-but-tappable rule is what this proves). 2. `none` + stub playback for a sustained window: no element with an `unlock.` prefix ever appears without a tap (SC-1100-02 hermetic proxy; the 4 h wall-clock run is a device item). -3. `none`: tapping any locked row opens the single unlock screen offering the Supporter Unlock - (`unlock.price.supporter` + `unlock.buy.supporter` present); there is never a second product - to choose (FR-1100-04). Under `supporter`/`all` those same rows are unlocked and the screen - is unreachable — partial ownership cannot occur, so there is no per-tier visibility state left - to exercise. +3. `none`: tapping any locked row (Ken Burns, clock, or the broker control banner) opens the same + `unlock.screen.supporter` — there is never a second product or screen to choose (FR-1100-04). + Under `supporter`/`all` those same rows are unlocked and the screen is unreachable — partial + ownership cannot occur, so there is no per-tier visibility state left to exercise. + (`unlock.price.supporter` / `unlock.buy.supporter` exist on that screen but are not + independently asserted by any XCUITest today — `ProductID.uiSlug` itself is untested.) 4. `all` (== `supporter`): no locked rows, no `unlock.` entry points except Restore + tips in settings. 5. `--uitest-store=unavailable`: unlock screen shows `unlock.unavailable`, zero price labels diff --git a/specs/300-slideshow/spec.md b/specs/300-slideshow/spec.md index 6f33a52d..9abf0b96 100644 --- a/specs/300-slideshow/spec.md +++ b/specs/300-slideshow/spec.md @@ -4,7 +4,7 @@ **Created**: 2026-06-23 -**Status**: Active +**Status**: Active — **amended 2026-07-25**: German localization for the slideshow UI strings shipped 2026-07-23 across iOS + tvOS via String Catalogs (PR #40); it is no longer deferred. FR-300-30, the User Story 7 narrative, and the "German translations" Roadmap entry below are superseded accordingly — repo policy is unchanged (Swift source/comments/specs stay English; German lives only in the catalogs). **Input**: Consolidated from `specs/003-slideshow/spec.md` and `specs/007-slideshow-ui/spec.md`: fullscreen playback engine, calm slideshow UI, chrome and gestures, album browser, info overlay, cache behavior, resilience, clock rendering, and localization. @@ -107,7 +107,7 @@ From the chrome, the user can toggle an info overlay showing only capture date/t ### User Story 7 - Reach settings, brightness, reset, and localization (Priority: P3) -From the chrome, the user reaches Settings with a live brightness slider and display-option controls owned by topic 500. Reset is reachable from Settings. All slideshow UI strings ship in English through localizable string catalogs; other device languages fall back to English. (German translations are deferred — see Roadmap; the clock overlay is implemented per `510-clock-overlay`.) +From the chrome, the user reaches Settings with a live brightness slider and display-option controls owned by topic 500. Reset is reachable from Settings. All slideshow UI strings ship in English through localizable string catalogs, with a German localization also shipping via the same catalogs (shipped 2026-07-23 — see the amendment above); other device languages fall back to English. (The clock overlay is implemented per `510-clock-overlay`.) **Why this priority**: These controls round out a usable frame while keeping feature ownership clear and the default overlay-free. @@ -169,7 +169,7 @@ From the chrome, the user reaches Settings with a live brightness slider and dis - **FR-300-26**: Settings MUST be reachable from chrome with a live brightness slider whose behavior is owned by topic 400. - **FR-300-27**: Settings MUST surface topic 500 display-option controls, and those changes MUST affect the running slideshow. - **FR-300-28**: Reset MUST be reachable from Settings (not the chrome) and MUST delegate connection clearing to topic 200. -- **FR-300-30**: All slideshow UI strings MUST be localizable (string catalog, no hardcoded user-facing strings in views); the app ships English. Additional languages (German first) are deferred — see Roadmap. +- **FR-300-30**: All slideshow UI strings MUST be localizable (string catalog, no hardcoded user-facing strings in views); the app ships English source, with a German localization shipped 2026-07-23 (see the amendment above). Languages beyond English/German remain deferred — see Roadmap. - **FR-300-31**: Slideshow state, timers, image loading, cache, and data access MUST remain testable behind injected protocols, with no real server, clock, cache, or display hardware required for unit tests. - **FR-300-32**: The UI MUST never reveal or log API keys, broker credentials, shared-link passwords, or other secrets. - **FR-300-33**: Chrome bar insets from the screen edges MUST remain stable across device orientation and MUST NOT shift with the rendered image's framing — whether the photo is fit or fill, and whether Ken Burns is on or off. Ken Burns honors the active fit option (500, FR-500-20) and does not by itself switch fit/fill framing. @@ -199,9 +199,10 @@ Spec Kit feature. - **Auto-retry with backoff** (was FR-300-11): load/connection failures auto-retry with backoff for unattended recovery, beyond the existing manual retry. **Now specced** as sub-spec `310-slideshow-resilience` (FR-310-01…05) — planned pre-release. -- **German translations** (part of FR-300-30): a `de` localization for the string catalogs. All - source strings stay English (repo policy: English-only source); German ships as a translation - pass over the existing catalogs once scheduled. +- **German translations** (part of FR-300-30): a `de` localization for the string catalogs. + **Shipped 2026-07-23** across iOS + tvOS (PR #40) — no longer deferred; source strings stay + English (repo policy: English-only source) while German ships as a translation pass over the + existing catalogs. - **Periodic source refresh** (was FR-300-12): the active source asset list refreshes periodically so newly added Immich photos enter rotation without an app restart. **Now specced** as sub-spec `310-slideshow-resilience` (FR-310-06…11) — planned pre-release. diff --git a/specs/800-app-intents/spec.md b/specs/800-app-intents/spec.md index a2120e61..57155177 100644 --- a/specs/800-app-intents/spec.md +++ b/specs/800-app-intents/spec.md @@ -184,5 +184,5 @@ secrets, never server URLs with credentials, no photo bytes. the `AppIntent` conformances stay in the app target. - Minimum OS for intents matches the app's iOS 17 floor (App Intents is iOS 16+); iOS-18-only surfaces (Control Center controls) are Roadmap, gated at runtime. -- Localization: intent titles/phrases ship English-only like the rest of the app (FR-300-30 - policy). +- Localization: intent titles/phrases ship localized like the rest of the app — English plus + German since 2026-07-23, the phrases through `AppShortcuts.xcstrings` (FR-300-30).