Repository navigation
feat(monetization): host the banner in tabViewBottomAccessory with a GameRoot-owned lease on an iOS 26.1 floor - #1095
Conversation
Running in-flight decision log per agent-impl-notes-log convention: locked decisions from the dispatch, B-6 (#1029) gate evidence, scope adjudications, and PM (sly-bunting) rulings incl. the static-modifier suppression shape and ATT C-33 re-anchor conditions. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RootShellView gains a third generic `Accessory: View` param + a `bottomAccessory` ViewBuilder closure, attached via `.tabViewBottomAccessory` inside `#if os(iOS)` only (macOS has no such API — design.md §2.4.1 option A). The modifier attaches UNCONDITIONALLY; visibility is decided inside the accessory's own content, never by conditionally attaching/detaching the modifier (PM ruling — see meetings/2026-09-07_1024-banner-accessory.impl-notes.md). An `Accessory == EmptyView` convenience init keeps every existing call site (GameRoot, RootShellViewGenericityTests) compiling unchanged. GameShellKit stays zero-dependency — the closure is fully opaque to this module; GameAppKit supplies the real (monetization) content next. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add `BannerAccessoryView` (GameAppKit, iOS/iPadOS only): the same
`BannerSlotView` gate-then-provider flow, now wired as
`GameRoot`/`RootShellView`'s shared `tabViewBottomAccessory` content
instead of a per-screen inline slot. macOS gets `{ EmptyView() }`
(`makeBottomAccessory`, MakeGameApp+Helpers.swift) — no AdProvider/
AdGate/BannerSlotView type is even constructed there.
`TodayTabHost` drops its own banner slot and the adProvider/adGate/
attPrimer params that fed it — the accessory now covers Today,
Practice, and Settings (pushed onto a tab's stack, so it stays inside
the TabView) with ONE shared banner. The C-33 ATT primer anchor moves
with it: `bannerAccessoryFireOnAdContext` replaces the retired
`todayTabHostFireOnAdContext`, same ordering guarantee (unchanged
`BannerSlotView.resolveGateAndLoad`: onAdContext always precedes the
actual ad load).
TodayTabHostTests.swift (tested exactly this wiring) is replaced by
BannerAccessoryViewTests.swift on the new host; verified on the iOS
simulator (macOS `swift test` structurally excludes this iOS-only
suite, it never compiles into that build).
Part of #1024.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… start) Checkpoint of interrupted subagent work: Sudoku practice call site no longer passes banner:; shared accessory covers the tab. Remaining phase-3 sites (sudoku settings, minesweeper, TodayTabHost/ATT) follow. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both apps stop passing a `banner:` closure at their Practice/Settings composition sites (Sudoku Live+TabRoots.swift + LiveRouteFactory.swift; Minesweeper mirrors) — the shared `tabViewBottomAccessory` now covers the whole tab shell. The shells' `banner:` params stay (default to `EmptyView()`) as design.md §2.4's documented tab-content-bottom fallback; `SudokuAppComposition.themedBanner` / `LiveRouteFactory .bannerSlot` (MS) stay as that fallback's ready-made implementation, now with zero production callers, doc-noted why. Drops the now-orphaned `adProvider`/`adGate` parameters from both apps' `makeTabRoot` (only the retired Practice banner call used them) and updates every test call site: RouteFactoryTests / LiveRouteFactoryTests (makeTabRoot signature), TodayTabViewTests / MinesweeperTodayTabViewTests / ASCScreenshotEmitTests (TodayTabHost's simplified init — no more adProvider/adGate/attPrimer). The retired per-suite banner-visible coverage (TodayTabHost had its own banner slot pre-#1024) is deleted along with its now-dead snapshot fixtures — that surface is BannerAccessoryViewTests + the pre-existing BannerSlotView coverage now. All other TodayTabView/MinesweeperTodayTabView snapshot baselines are UNCHANGED and still pass byte-for-byte: every surviving baseline seeds the ad gate closed, so the banner region already rendered EmptyView before this move — removing the slot's own subtree produces identical pixels and structure dumps (verified via swift test, not re-recorded). Verified: SudokuKit filtered batch (32/32) + MinesweeperKit full suite (408/408) green; swiftlint --strict clean. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1024) PM condition: prove the primer fires before any ad-context/ad-load call, not just by reading source. `primerFiresBeforeAnyAdLoad` renders the real `BannerAccessoryView` in a `UIHostingController` inside a key `UIWindow` and records, into one shared actor log, the moment ATT's `isNotDetermined()` check runs vs. the moment the reload coordinator's `refreshBanner()` actually starts a load — asserting the log order directly (no timing-race poll loop). This only pumps on a real run-loop host: confirmed via `xcodebuild test -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:GameAppKitTests/BannerAccessoryViewTests` (4/4 pass). `TodayTabHostTests`' documented headless-macOS-`NSWindow` harness limitation (an actor-hop inside `.task` never resumes there) does not apply to an iOS Simulator XCTest host, which boots a real `UIApplication` run loop. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T contract (#1024) design.md §2.4/§2.4.1: U-10 (AdMob-in-accessory) and U-13 (macOS banner exclusion) both resolve — accessory path shipped, option A shipped as final, degradation fallback documented but unused. §3.6.1: C-33's ATT anchor moves a second time, from the Today tab's own banner slot to the ONE shared accessory banner slot. screen-contracts.md: the HOME-retirement banner + the ATT-PRIMER section both re-anchored to `BannerAccessoryView`, with an explicit before/after reachability table (Today-gated → tab-agnostic) since the accessory's tab-agnostic mount genuinely changes when the primer can first fire, not just where the code lives. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ivers (#1024) B-6 bonus finding (#1029): the fixed-size 320×50 test creative doesn't cover the tabViewBottomAccessory capsule's rounded ends when the SDK stretches its own view to fill the accessory. `LiveAdMobBridge` now sets the native `BannerView.backgroundColor` to `.clear` so `BannerAccessoryView`'s themed SwiftUI background shows through the gap instead of the SDK's opaque fill. Updates the #1024 impl-notes log with final phase status. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1024 CR) CR round-1 cosmetic findings (sonnet reviewer): MakeGameApp.swift and GameConfig.swift doc comments still described TodayTabHost as carrying the banner slot + C-33 ATT anchor; both moved to BannerAccessoryView / tabViewBottomAccessory in this branch. Comment-only change. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PM ruling: the #1029 spike proved AdMob-in-tabViewBottomAccessory works as an API; that isn't proof our own wiring (RootShellView's generic bottomAccessory param -> .tabViewBottomAccessory { bottomAccessory() }) actually threads a caller's content through, and replacing coverage removed elsewhere in #1024 is part of the change. RootShellViewBottomAccessoryRenderTests (new, GameShellKit, #if os(iOS)) renders the real shell with a sentinel accessory in a UIHostingController/UIWindow and finds SwiftUI's private "...BottomAccessory" container in the live hierarchy, asserting it has real content. A second test with an EmptyView accessory proves the container is genuinely empty in that case, ruling out "always has stray children" as a false-positive explanation for the first test's pass. Accessibility-identifier lookup was tried first and is documented as a dead end in the file: `.accessibilityIdentifier(_:)` on plain Text does not surface through UIAccessibilityIdentification or UIAccessibilityContainer's count/index methods when walked in-process in a headless XCTest host. Verified via `xcodebuild test -scheme GameShellKit -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` — 2/2 new tests pass, full suite 106/106 on iOS Sim, 116/116 unaffected on macOS (file is `#if os(iOS)`-excluded there). Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PM-approved: lets the tabViewBottomAccessory banner be sim-verified showing a REAL rendered ad, not just an empty/gated slot. New UITestAlwaysOpenAdGateStateStore (UITestFakeSeams.swift) reports an unconditionally-open AdGateState (ancient firstLaunchAt, no purchase, no dismissal, no clock-tamper baseline); resolveAdGateStore swaps it in when the new arg is present and -uitest-fake-ad-gate-repoll is not. Strictly additive: the new check is inserted BEFORE the existing repoll guard, whose lines stay byte-identical (git diff proves it). resolveAdProvider is completely untouched, so the real live AdProvider (AdMob SDK, Google's public DEBUG test creative per LiveAdMobBridge) still loads for real -- only the gate's read path is faked. Release-unreachable: swift build -c release succeeds, and `strings` over every compiled .o in the Release GameAppKit.build directory returns zero hits for any "uitest-" string. Part of #1024. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… model (#1080) Merge origin/main (728ef19) into session/brisk-stork-isio. Resolution rules from #1080: - #1024's removal of the inline Today/Practice/Settings banner slots wins: every conflict hunk in MakeGameApp, TodayTabHost, both Live+TabRoots, both LiveRouteFactory(+Helpers) and both TodayTabViewTests takes the #1024 side; main's non-conflicting hunks in those files (the `BannerSlotView( isSuppressed:)` rewrite of the unused fallback helpers) are kept. - The four `snapshotIPhoneLightBannerVisible` fixtures main re-added are dropped with the test that produced them (Today no longer hosts a slot). - `TodayTabHostTests.swift` comes back from main: it pins the `makeBannerSession` ATT wiring, not TodayTabHost's retired slot. - `BannerAccessoryView` is rewritten onto the session model: a plain `BannerSlotView(isSuppressed: false, …)` that reads `\.bannerSession` like every other slot — no provider / gate / primer parameters; C-33 lives on the session's `onAdContext` hook. `makeBottomAccessory()` loses its parameters and `BannerAccessoryViewTests` (which called the deleted init and a hook `makeBannerSession` already covers) is removed. - `MarkerProvider` test fixture gains `awaitReady()` and the handle-returning `refreshBanner()` the #1062 protocol requires. - No `MonetizationBootSignal` / `bootSignal` reintroduced (zero hits). - No board file touched by hand; board slots auto-merged clean. Verified: `swift test` AppMonetizationKit 180/180, GameAppKit 95/95. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…accessory with isEnabled (#1079, #1080) Owner decision #1079 option 1: instead of an empty 48pt accessory capsule whenever the ad gate denies (signed out, Remove Ads, dismissed today), the shell now uses `tabViewBottomAccessory(isEnabled:content:)`, which is `@available(iOS 26.1, *)` in the iOS 26 SDK — so the iOS deployment floor moves from 26.0 to 26.1. macOS stays 26.0 and still has zero `tabViewBottomAccessory` hits (design.md §2.4.1 option A). - Project.swift: all four app targets `iOS: "26.1"`. - Every package `.iOS(.v26)` → `.iOS("26.1")` (synced versioning; the macOS-only ASCRegisterKit is untouched). - RootShellView (GameShellKit, still zero-dependency): new `bottomAccessoryIsEnabled: Bool` on the designated init, applied as `.tabViewBottomAccessory(isEnabled:)` inside the unchanged `#if os(iOS)` attach point. The modifier is still attached unconditionally — `isEnabled` is the SDK's own visibility switch, not a conditional attach. The `Accessory == EmptyView` convenience init passes `false`. - GameRoot: passes `bannerSession.isVisible`, read inside `shellContent` (evaluated from `body`) so `@Observable` tracking re-renders the shell. - RootShellViewBottomAccessoryRenderTests: `isEnabled: false` with real sentinel content draws no accessory (iOS-only suite). - Docs: design.md §2.4 as-built addendum, foundations.md floor lines, SUBMISSION-CHECKLIST minimum-OS note, both v2.6 review-information docs, impl-notes follow-through section. Verified: swift test GameShellKit 124/124, GameAppKit 95/95 (macOS host); swiftlint --strict 0 violations on the touched Swift files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e ATT docs (#1080) Replaces the deleted BannerAccessoryViewTests with a render-level pin (PM condition on #1080; E2E-only pins do not count): - BannerAccessoryPinTests (GameAppKitTests): (a) gate open — the real RootShellView + BannerAccessoryView over a started session renders a non-empty accessory container, the \.bannerSession injection reaches the slot (onMissingSession never fires) and exactly one slot registers; (b) gate denied — no container or a zero-height empty one, no slot state; (c) macOS — makeBottomAccessory() is EmptyView. (a)/(b) run on the iOS Simulator; (c) on the macOS host. - makeBottomAccessory() returns `some View` instead of AnyView. - RootShellViewBottomAccessoryRenderTests: the isEnabled:false case now also requires zero height, not just no subviews. - screen-contracts ATT-PRIMER and three test headers stop naming the deleted BannerSlotView.resolveGateAndLoad / BannerAccessoryViewTests. Verified: swift test GameAppKit 96/96, GameShellKit 124/124 (macOS); xcodebuild iOS Simulator BannerAccessoryPinTests (a)(b) and RootShellViewBottomAccessoryRenderTests 3/3 green; mutation `isEnabled: true` turned both suites red and was restored; scan:design_contracts PASS; swiftlint --strict 0 violations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…so re-hosts stop re-requesting ads (#1080) `tabViewBottomAccessory` re-hosts its SwiftUI content natively on sheet dismissal, NavigationStack push/pop and some layout passes, without re-evaluating `GameRoot.body`. The accessory's `BannerSlotView` kept its `BannerSlotLease` in a `@StateObject`, so every re-host created a new lease. The old lease's `isolated deinit` disposed the loaded ad and the new one sent a fresh request. A simulator probe with request logging measured 8 accessory ad requests on one scripted run (cold launch, ATT dismiss, push, reminder sheet, pop, idle) on both iPhone and iPad, against 2 on main's never-re-hosted inline slots. With this change the same script sends 1. AppMonetizationKit, minimal public surface: - `BannerSlotLease` is public with `public init()`. `id` and `attach(to:)` stay internal. - `BannerSlotRegistration` attaches `external ?? own`. The self-owned `@StateObject` lease still exists but is never attached when an external lease is supplied, and a comment explains why it must stay. - One additional `BannerSlotView(lease:…)` init. The existing init and `BannerSessionModel` keying are unchanged: one lease per placement. - `BannerSlotBandLayout` moved to its own file with no behaviour change, `private` becoming `internal`, to keep BannerSlotView.swift under the 400-line limit. - `LiveAdMobBridge`'s representable gets only an invariant comment. The new host adopts the retained `BannerView` before the old host is dismantled, so a future `dismantleUIView` must never `removeFromSuperview` it. GameAppKit: - `GameRoot` owns `@State accessoryLease` for the scene's lifetime and injects it via the new `\.bannerAccessoryLease` environment entry. - `BannerAccessoryView` uses the external lease. A missing injection calls `onMissingAccessoryLease` (assertionFailure) and renders nothing. There is no silent fallback. - GameShellKit is unchanged and still has zero dependencies. Tests, each with an executed mutation that turned red: - T1 `BannerSlotExternalLeaseTests`: two sequential slots sharing one lease make 1 register and 1 refresh. Mutation: registration ignores the external lease. - T2 `BannerAccessoryReHostTests` (iOS): push+pop on the real `GameRoot` keeps the same slot id and refresh count 1. The mutation (self-owned lease) goes red, which shows the harness really re-hosts. - T4 `BannerAccessoryMissingLeaseTests`: a missing lease fires the hook. - Round-2 `BannerAccessoryPinTests` (a)/(b)/(c)/(d1)/(d2) are also included here. The (a)/(b) cases now host the real `GameRoot`, so the `isVisible`→`isEnabled` flip is pinned through Observation. - T2 lives in its own suite. The two pin disjoint behaviour: lease identity across a re-host versus capsule visibility. Folding T2 into the pin file would have pushed it past 400 lines. Docs: - #1058 design doc: "Externally owned lease (#1080)" section. - design.md §2.4: as-built note. - screen-contracts ATT-PRIMER: the trigger is corrected to `BannerSessionModel.start()` from `GameRoot.onAppear`. - impl-notes: probe summary, the PM ruling on the residual repaint gap (tracked in #1094), and teardown classified as hygiene (single-scene app; no per-re-host lease accumulation). Verified: - `swift test`: AppMonetizationKit 181, GameAppKit 96, GameShellKit 124, all pass. - iOS Simulator GameAppKitTests: 97 pass. - `swiftlint --strict`: 0 violations. - `scan:admob` and `scan:design_contracts`: PASS. - Sudoku and Minesweeper iOS Simulator builds succeed. part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st the hook (#1080) T4 previously asserted only that `onMissingAccessoryLease` fires when `BannerAccessoryView` has no `\.bannerAccessoryLease`. A regression that re-added a fallback to the self-owned `BannerSlotView` init next to that hook call would have stayed green. That fallback would silently bring back the per-re-host re-request this PR fixes. (PM request on #1080.) The test now hosts `BannerAccessoryView` over a started, gate-open `BannerSessionModel` backed by a fresh `FakeAdProvider`, with the session injected and the lease left nil, and asserts all three: - the hook fired; - `session.slots` is empty (nothing registered or loaded); - `refreshCallCount == 0`. The suite is `.serialized` because it swaps a static. Mutation executed on the iOS Simulator: rendering the self-owned `BannerSlotView(isSuppressed:…)` in the nil branch turns it red. `session.slots` holds a `.loaded` slot, and `refreshCallCount == 0` fails. Restored and green again. Full GameAppKitTests iOS run: 97 tests pass. `swiftlint --strict`: 0 violations. part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inesweeper Today tab (#1080) The main merge (68f840a) combined the wrong sides of one test. `MinesweeperTodayTabViewTests.swift` came from #1024, where `TodayTabHost` has no banner slot, so no `\.bannerSession` environment is written. The view-structure `.txt` baselines came from main (#1062), whose version of the test wraps the host in that extra environment modifier. Result: all five `assertViewStructure` checks failed on line 1 (the root type string) in `swift test --package-path Packages/MinesweeperKit`. Found while running both apps' packages for #1080. Evidence that this corrects a merge and does not re-record a behaviour change: - The failing assertions are `assertViewStructure` only; every `.image` snapshot passed. - The five PNG baselines are the same blobs at 7b423da (#1024), 728ef19 (main) and HEAD. - The test's actual structure output matches the #1024 `.txt` baselines at 7b423da byte-for-byte, and differs from the main-side baselines only on line 1. So the five `.txt` files are restored from 7b423da. `MinesweeperTodayTabViewTests` now passes. SudokuKit's `TodayTabViewTests` already passed, since its test file and baselines agree. part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge origin/main (c2959d0) into session/brisk-stork-isio. Main advanced by two commits after the first #1080 merge (68f840a): - aeb80f0 test(sudoku): freeze the session clock in the Ready-window test (#1092) - c2959d0 chore(l10n): remove proven-orphan keys and relabel live stale keys in both catalogs (#1093) A read-only `git merge-tree` dry run was clean. The five files main touched (both app catalogs, the SudokuAppCompositionTests L10n fixture, BoardLeaveOrPauseStateTests.swift, mise-tasks/scan/l10n) do not overlap with any file this branch changes. L10n check: this branch adds no localized string literals. None of the 14 Sudoku keys #1093 removed is referenced by branch-added code. part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stash
|
| Check | Result |
|---|---|
Changed lines in the stash (git stash show -p) |
215 |
Lines naming bootSignal or MonetizationBootSignal |
117 |
| The other 98 lines | The old side of the same call sites, plus import MonetizationCore for the type |
git grep -c -E 'MonetizationBootSignal|bootSignal' in Packages/ at this PR's HEAD |
0 files |
Same grep at origin/main |
0 files |
The type the stash edits was deleted by #1062, so nothing in it can apply. Moot. The stash is dropped right after this comment. The drop command and its result are in the Leader's report to the PM.
Evidence index (#1080)These are the key numbers from the PR body, with where each measurement lives. Evidence files are local and uncommitted, under
The throwaway probe patches are archived in E-c request attributionThe probe logs do not tag a request with its slot. Each request below is attributed from three things: the iPhone,
iPad,
Each device therefore made 1 accessory request, at cold launch, and 1 board request. Lease counting rule. The "Re-host churn" row counts only self-owned leases that were never attached: iPhone 23 inits / 22 deinits, iPad 16 / 15. The raw unique lease ids in the same files are iPhone 25 / 23 and iPad 18 / 16. The difference is exactly the two attached leases in the tables above. The accessory's lease never deinits, by design. The board's lease deinits once. End of each log. Both logs end with "Child process terminated with signal 15". That line is the script stopping its own log stream:
None of these steps navigates, so no probe events are expected. The files alone cannot rule out output lost from a buffer when the stream was killed. No claim in this index depends on those steps. The h2 results come from separate continuous per-device logs, |
…R gate part-1 findings (#1080) PM gate on PR #1095, part 1. Dead fallback removal (both apps, mirror principle): - Delete Sudoku `LiveRouteFactory.themedBanner()` and Minesweeper `LiveRouteFactory+Helpers.bannerSlot()`. The accessory path shipped in #1024 and was confirmed stable in #1079, so the tab-content-bottom fallback is obsolete. This also removes the resurrected pre-#1062 comment in the Minesweeper helpers file (B1). - Drop the `banner:` parameter and `Banner` generic from `SudokuUI.PracticeHubView`, `MinesweeperUI.MinesweeperPracticeHubView`, `GameAppKit.SettingsView` and `SettingsUI.SettingsScreen`; the per-app `SettingsView` typealiases become non-generic. - The Minesweeper helpers file now imports UIKit explicitly under `canImport(UIKit)`; `UIApplication` had only resolved through the removed SwiftUI import, which only the iOS app build exposed. Tests: - `BannerSlotCollapsedHeightTests`: drop the two rows for the deleted helpers (Sudoku `themedBanner`, Minesweeper `bannerSlot`); the Today and Board rows are unchanged. - `BannerSlotDarkBandRegressionTests`: doc comment only, notes the removal. - No test was deleted and no snapshot baseline changed. Other findings: - M1: `mise-tasks/new_game/scaffold` generates iOS 26.1, matching Project.swift. - m1: comments no longer point at private memory files; they cite `meetings/2026-09-11_1058-slot-model-design.md` "Externally owned lease (#1080)" or CLAUDE.md. - m2: design.md 2.4.1 now says only that the macOS `#else` never builds `BannerAccessoryView`; `AdProvider`, `AdGate` and `BannerSlotView` are still constructed on macOS. - m3: `EnvironmentValues.bannerAccessoryLease` is internal and `MonetizationUI` is an internal import in that file. - design.md 2.4 and the #1024 impl notes record the fallback removal. Verification: swift test AppMonetizationKit 181, GameAppKit 96, GameShellKit 124, MinesweeperKit 412 (all pass); SudokuKit SudokuAppCompositionTests 69, SudokuUITests without settings suites 376, audio/reminder/IAP settings 14, SettingsViewAppStoreRowsTests 4, SettingsViewWindowSnapshotTests 2, SettingsViewTests 15 (all pass, each settings suite run alone); swiftlint --strict 0 violations on 19 files; scan:hygiene, scan:design_contracts, scan:admob pass; Sudoku and Minesweeper iOS simulator builds succeed. Part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tings shells (#1080) PM gate on PR #1095, part 1 follow-up rulings. - `PracticeHubShellView` (GameShellKit) and `SettingsShellView` (SettingsKit) lose their `banner:` parameter and `Banner` generic. The previous commit removed their only forwarders (both Practice hub views and `GameAppKit.SettingsView` / `SettingsScreen`), so nothing fed these slots. The single-child `VStack(spacing: 0)` wrapper that existed only to seam the content against the banner is gone too; the modifiers now sit on the `ScrollView` and the `Form` directly. GameShellKit stays zero-dependency. - `BannerSlotCollapsedHeightTests`: delete the "TodayTabHost (Today tab, both apps)" row and its header bullet. `TodayTabHost` has constructed no `BannerSlotView` since #1024, so the row pinned a host that no longer exists. The table now has 4 cases. - Both `Live+TabRoots.swift` Today-case comments no longer claim `TodayTabHost` carries its own slot and is the ATT anchor. They now say the shared `tabViewBottomAccessory` covers the tab, the accessory slot's registration is the ad context that triggers the ATT primer, and the Daily hub `banner:` param is dead and tracked in #1096. - `DailyHubShellView`, `DailyHubView` and `MinesweeperDailyHubView` are untouched; they were dead before this PR and #1096 owns them. No snapshot baseline changed and no test was deleted; the Practice hub and Settings snapshot suites pass unchanged on both apps. Verification: swift test AppMonetizationKit 181, GameAppKit 96, GameShellKit 124, SettingsKit 51, MinesweeperKit 412 (all pass); SudokuKit SudokuAppCompositionTests 69, SudokuUITests without settings suites 376, audio/reminder/IAP settings 14, SettingsViewAppStoreRowsTests 4, SettingsViewWindowSnapshotTests 2, SettingsViewTests 15 (all pass, each settings suite run alone); swiftlint --strict 0 violations; scan:hygiene, scan:design_contracts, scan:admob pass; Sudoku and Minesweeper iOS simulator builds succeed. Part of #1024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
PM merge record (head
Known limits carried forward: #1091 (the ATT primer does not gate the first request), #1094 (0.6-1.9 s repaint gap after an accessory re-host), and teardown retention seen only in the test harness (hygiene; no per-re-host accumulation: self-owned lease inits/deinits 23/22 iPhone, 16/15 iPad). Follow-up: #1096. |
What
The banner moves from per-screen inline slots into the shared
tabViewBottomAccessory, on top of the #1062 banner session model. The shell now drives the accessory withtabViewBottomAccessory(isEnabled:), so no empty capsule is drawn when the ad gate denies. The iOS deployment floor rises to 26.1 for that API (owner decision on #1079, option 1).The accessory path is what ships. #1029 B-6 PASSED: a real AdMob
BannerViewrenders inside the accessory at non-zero size and survives placement changes. The tab-content-bottom fallback stays reachable and documented, but it is not wired.Merge of
main(post-#1062)origin/main(728ef19) is merged into the #1024 branch following the #1080 rules.BannerAccessoryViewis a plainBannerSlotView(isSuppressed: false, …)that reads\.bannerSessionlike every other slot. It takes no provider, gate or primer parameters. The ATT primer (C-33) stays on the session'sonAdContexthook.MonetizationBootSignal/bootSignal. Zero hits inPackages/.snapshotIPhoneLightBannerVisiblefixtures are dropped with the Today slot test that produced them.mainbefore opening (0a6739d). Main gained test(sudoku): freeze the session clock in the Ready-window test #1092 (Ready-window test clock) and chore(l10n): remove proven-orphan keys and relabel live stale keys in both catalogs #1093 (orphan l10n keys removed). The merge was clean; none of the five files main touched overlap with this branch. The branch adds no localized strings, and none of the 14 keys chore(l10n): remove proven-orphan keys and relabel live stale keys in both catalogs #1093 removed is referenced by branch code.MinesweeperTodayTabViewTests.swiftwith main's view-structure.txtbaselines. Main's test version still wrapped the host in a\.bannerSessionmodifier, so all fiveassertViewStructurechecks failed on line 1 (the root type). The five PNG baselines are identical blobs at 7b423da, 728ef19 and HEAD. The actual structure output matches feat(monetization): move banner to tabViewBottomAccessory, with tab-bottom fallback #1024's.txtbyte-for-byte, so those files were restored. This corrects a merge-side mix-up; nothing was re-recorded.iOS floor 26.1 and
isEnabledProject.swift: all four app targetsiOS: "26.1". macOS stays26.0..iOS(.v26)becomes.iOS("26.1"). The macOS-only ASCRegisterKit is untouched.RootShellViewtakesbottomAccessoryIsEnabled: Bool. The modifier is still attached unconditionally inside#if os(iOS).isEnabledis the SDK's own display switch, not a conditional attach.GameRootpassesbannerSession.isVisible, read frombody, so@Observablere-renders the shell when the gate changes.Built Debug simulator apps report:
A workspace generated before the floor change fails to build with "compiling for iOS 26.0, but module … has a minimum deployment target of iOS 26.1". Run
mise run tuist:generateafter pulling this branch.macOS ships no banner (design §2.4.1 option A)
The macOS build contains no
tabViewBottomAccessorycall, because the only call site is inside#if os(iOS). Checked on the committed tree:On macOS,
makeBottomAccessory()returnsEmptyView.BannerAccessoryPinTests(c) pins this, andBannerAccessoryViewitself is#if os(iOS). The §2.4.1 options B and C are not implemented.Tests
Every pin below comes with a named mutation. Each mutation was run and turned the test red, then it was restored.
BannerAccessoryPinTests(a), iOS, realGameRootdismiss()collapses the capsule againBannerAccessoryView.bodybecomesEmptyView(); (d2)GameRootpassesbottomAccessoryIsEnabled: falseBannerAccessoryPinTests(b), iOSRootShellViewusesisEnabled: true; (d1)GameRootpassestrueBannerAccessoryPinTests(c), macOSmakeBottomAccessory()is structurallyEmptyView#if os(iOS)fails the macOS buildBannerAccessoryReHostTestsT2, iOSGameRootkeeps the same slot id and one requestBannerAccessoryViewgoes back to a self-owned lease (slot id changes, refresh 1→2)BannerSlotExternalLeaseTestsT1BannerAccessoryMissingLeaseTestsT4, iOS\.bannerAccessoryLease, over a started gate-open session, the hook fires, no slot registers or loads, andrefreshCallCount == 0BannerSlotView(a loaded slot appears and refresh becomes 1)RootShellViewBottomAccessoryRenderTests, iOSisEnabled: falsenothing is drawn (container absent, or zero height and empty)isEnabled: trueTest totals:
swift testAppMonetizationKitswift testGameAppKit (macOS)swift testGameShellKitTeardown has no pin: it is hygiene, see Known limits.
The four totals above were run on a7ae575, and each run exited 0 with 0
✘or "recorded an issue" lines. The two later commits touch none of those packages, so the results carry over:.txtbaselines.mise-tasks/scan/l10n, and SudokuKit tests only.Both apps' packages were run on 0a6739d. SudokuKit was run in batches, as the repo requires, because its full suite segfaults on this machine.
swift testMinesweeperKit (full)--filter SudokuAppCompositionTests--filter SudokuPersistenceTests--filter SudokuUITests, skipping the six settings suitesAudioSettingsSectionTests|ReminderSettingsSectionTests|SettingsIAPRowTestsSettingsViewAppStoreRowsTests,SettingsViewWindowSnapshotTests,SettingsViewTests(run 3 times, once with--no-parallel)Running the three half-B suites together in one invocation (
--filter "SettingsViewAppStoreRowsTests|SettingsViewTests|SettingsViewWindowSnapshotTests") crashesswiftpm-testing-helperon this machine. The crash is already on main and does not come from this branch:mainc2959d0 (throwaway worktree)It is the known local SudokuKit settings segfault. Running each suite on its own, as in the table above, is the effective verification. Logs:
build/evidence-1080/final-tests/merged/settingsB-{split,attribution}/.One unattributed macOS failure, not reproduced. During the pin rewrite, one full
swift testrun of GameAppKit ended "96 tests in 22 suites failed after 2.019 seconds with 1 issue". That output was piped throughtail -10, so the failing test's name and assertion were never captured. The ten surviving lines are all passing tests. SwiftPM writes no per-test log, so the detail cannot be recovered. It is not attributed to this branch or to main.✘,recorded an issueorfailedThe rerun logs are local in
build/evidence-1080/flake/gak-run-{1..15}.log.Simulator evidence
Debug builds, Google's public test banner unit (forced by the
LiveAdMobBridgeDEBUG init), BGM muted, own simulators only. Screenshots and crops are local underbuild/evidence-1080/and are not committed.iPhone 17 Pro, both apps, on the lease-fix build f929c73 (app code identical at 0a6739d)
Evidence files are local, under
build/evidence-1080/final/{sudoku,minesweeper}/. Each check was spot-verified by the Leader..expanded)isEnabledflips to false with Settings pushedisEnabledflips to false with a board presentedfullScreenCoverboard still presented (pause toggle and grid present), banner goneBuilt apps report
MinimumOSVersion26.1.Test-fill note. The ads shown are Google's rotating test creatives. Sudoku got the 468×60 letterboxed creative; Minesweeper got a different one with no letterbox.
Minesweeper mirror request probe (iPhone, f929c73)
The accessory is shared GameAppKit code, and the request-count probe had only run on Sudoku. So Minesweeper was run once more. Setup: a throwaway worktree at f929c73 with DEBUG
os_loglines at lease attach, lease deinit and ad request (build/evidence-1080/ms-mirror/ms-mirror-instrumentation.patch), and a single continuous log (ms-iphone.log).lease-attachlease-deinitad-request5A35E8FF…lease-deinitids in the log were never attached. They are the self-owned leases created and discarded on each re-host, as described in the lease note above.iPad regular width (E5)
iPad Pro 13-inch (M5), iOS 26.5, portrait. iPadOS puts the tab bar at the top, so the accessory sits at the bottom of the tab content. That matches Apple's
.expandeddefinition: "at the bottom of the tab's content view".In the open case the creative is 320 pt wide, leading-aligned at x = 46, and the ✕ sits in the gutter past its trailing edge. The denied state is the sim's natural one: no iCloud account, so the gate store is unreadable and the gate fails closed.
Test-fill observation, not a defect. On both iPad and iPhone, Sudoku runs received Google's "This is a 468x60 test ad" creative, which shows black letterbox bars above and below inside the 320×50 frame. The iPhone run is
build/evidence-1080/remount-probe/ec-head-iphone-reparent-att-1.png. The Minesweeper iPad run received a house creative that fills the frame cleanly. The bars are inside the creative, so the bridge's clear background cannot reach them, and a real fill is needed to judge production. Crops:build/evidence-1080/sudoku-ipad/E5-open-behindprimer-creative-fullres.png,build/evidence-1080/minesweeper-ipad/E5-open-creative-fullres.png..inlineis not applicableThe shipped shell never enters
.inlineon iPhone, so there is no.inlineevidence (PM ruling on #1080).tabViewBottomAccessory: "On iPhone, the placement of the bottom accessory depends on the tab bar size: when the tab bar is normal size, the accessory appears above it; when the tab bar is collapsed, the accessory displays inline."TabBarMinimizeBehavior.automatic: "On iOS, iPadOS, tvOS, and watchOS, the tab bar does not minimize."Known limits
maybePresentOnAdContext()presents the primer and returns without waiting for the user's choice, so the first load goes out while the primer is on screen. This is pre-existing from fix(monetization): restore cold-launch gate resolution + consent-before-request ordering #1062 and is tracked in fix(monetization): ATT primer does not gate the first ad request — maybePresentOnAdContext returns before the user decides #1091. Observation:build/evidence-1080/sudoku-ipad/remount-burst/stack-before.png(creative loaded behind the primer).Cause. UIKit re-hosts
tabViewBottomAccessory's content on sheet dismissal, push and pop. That reset the slot's@StateObjectlease, disposed the loaded ad and sent a new request.Measured on the same scripted run:
GameRoot-owned lease)Evidence.
build/evidence-1080/remount-probe/(E-a / E-c logs).BannerViewsurvives each re-host with no new request, but its creative area stays empty for about 0.6–2.5 s before painting, measured from simulator video.UIApplicationSceneManifest), soGameRoot, and the accessory lease it owns, lives for the whole process. Scene teardown is process death.Harness result. In a hosted-window diagnostic, releasing the hosting controller leaves the
GameRoot-owned lease alive.session.slotsstill holds that same lease id 5 s later, and nothing is disposed.Evidence.
build/evidence-1080/remount-probe/test-ec-teardown-v2.log.Open question. What retains the lease after the host is torn down is not identified. There is no production teardown path for it.
No accumulation per re-host. Across each whole E-c script (
ec-head-{iphone,ipad}.log), self-owned leases that were never attached deinit in step with their inits. The one left alive on each device is the live host's own lease.Counting rule: the first three columns leave out the two attached leases on each device. One is the accessory's
GameRoot-owned lease, which stays alive by design. The other is the board slot's lease, which deinits on leave. Adding them back gives the last column. The evidence index comment attributes each ad request to its slot and explains where each log ends.So old hosted content is released on every re-host. The retention seen in the harness is specific to harness teardown.
Other slots. The board, Practice and Settings slots keep self-owned leases and deinit normally. Examples: the board slot lease deinits on leave in E-c, and in main S3.
Correction. An earlier v1 run reported the lease as deallocated. That run never actually captured the lease, so it is superseded by v2.
Review so far
This is a pre-PR Leader-side code review of the lease-ownership commit only (
6d549fe7..f929c734). It is not the PM gate. Two independent reviewers read committed content throughgit show.BannerSlotBandLayoutwas moved to its own file. The move is byte-identical apart fromprivatebecominginternal, it stays under the 400-line limit, and it is documented in the file header. No change needed.Neither reviewer re-ran the T1/T2 red mutations. Both were run during implementation, and the evidence is recorded in impl-notes.
Gates
Run on 0a6739d, the final HEAD.
The
scan:admobwarning is not from this PR.git grep -n "canImport(GoogleMobileAds)"shows the same two non-bridge hits onorigin/main.Cleanup note: stash
1f79ad3fThe tagged stash "1058-default-removal-moot-2026-09-11" only removes the default value of a
bootSignal: MonetizationBootSignalparameter and adds that argument at call sites. That type no longer exists.bootSignal/MonetizationBootSignalimport MonetizationCorefor the typeMonetizationBootSignal|bootSignalinPackages/at HEAD andorigin/mainThe drop is recorded in a comment on this PR.
Closes #1080
part of #1024
part of #1014
🤖 Generated with Claude Code