iOS auto light/dark switching required an app restart. The observer listened for UIScreen.modeDidChangeNotification — a screen resolution signal — which never fires on a Dark Mode toggle. Replaced with a UITraitUserInterfaceStyle trait registration on the app's active window, re-armed across window and scene lifecycle. PR #369, four commits.
UIScreen.modeDidChangeNotification reports UIScreenMode (resolution) changes; it has nothing to do with userInterfaceStyle. The subscription compiled, ran, and was simply dead code.UIWindow. If SwiftUI's .preferredColorScheme had pinned the window's style, the registration would never fire on a system toggle — and the live test would still pass, because it forces the override by hand. Two reviewers measured it separately on simulator: SwiftUI overrides the hosting controller, the window stays .unspecified.didDisconnect branch cannot be driven in a single-scene test host, and the note says so with measured evidence rather than shipping a test that passes with the listener deleted. That is the right call.Ready to push — one follow-up
The fix is correct and its central assumption is now measured, not assumed: SwiftUI lands ThemeModifier's .preferredColorScheme on the root UIHostingController, not on the UIWindow, so the window's userInterfaceStyle keeps tracking the real system appearance and the registration fires on genuine system changes only. Verified independently by two reviewers on an iPhone 17 Pro simulator (iOS 26.5) in both light and dark: the host window reports overrideUserInterfaceStyle == .unspecified while the hosting controller carries the forced value. Had it been the other way round, this fix would not have worked at all and no test in the suite would have caught it.
Review fixes are committed: the missing CHANGELOG entry, test window selection routed through the production selector, defer-based override restoration, an anti-vacuity assertion in the re-arm test, and two doc comments corrected. Build, lint, and the regression suite are green.
One item is outstanding and not fixable from here: specs/bugfixes/auto-theme-switch-needs-restart/ exists but is empty, and every one of the other 104 bugfix directories carries a report.md. Writing it was blocked by a tooling guard on report-style markdown. Either write it before merge or accept the convention break knowingly.
656563d Fix T-1698: Automatic light/dark switch requires app restart a3de49b Address PR review: re-arm trait registration when its window goes away a0715c8 Document the uncovered scene-disconnect re-arm branch 59ca8b2 Pre-push review: CHANGELOG entry, test window selection, comment accuracy fe7aa9e Pre-push review round 2: derive test targets from the window under test Prism has a setting called Auto: match whatever the phone is doing, light or dark. On iPhone and iPad it didn't. You'd flip Dark Mode — from Control Centre, or automatically at sunset — and Prism would stubbornly stay in the old look until you force-quit and reopened it.
An app doesn't poll the system asking “are we dark yet?”. It asks to be told when something changes. Prism had signed up for the wrong announcement.
The old code listened for UIScreen.modeDidChangeNotification. The name reads like “the screen's mode changed”, which sounds exactly right — but “mode” here means the screen's resolution, not its appearance. Prism was standing at the wrong door. Nobody ever knocked, so it never updated. Restarting worked because starting up reads the current appearance directly, once.
iOS groups things like “is this light or dark” into traits, and lets you register to be told when a specific trait changes. Prism now registers for the light/dark trait on its window. When the system flips, the announcement arrives and the theme follows immediately.
That registration belongs to one particular window and dies with it. On iPad you can have several Prism documents open in separate windows, and you might close the very one holding the registration — leaving the app deaf again. So Prism keeps track of which window is holding it and moves it to a surviving window whenever windows come and go.
SystemColorSchemeObserver is an @Observable @MainActor class holding one property, systemColorScheme. ThemeModifier reads it in body, so Observation invalidates the view tree whenever it changes. It exists (T-429) because @Environment(\.colorScheme) is contaminated by .preferredColorScheme() in the same tree and can't be trusted as the system's value.
The class has always had two halves: read the current value and know when it changed. T-429 fixed the read. T-1698 fixes the change signal, on iOS only — macOS was already correct via the AppleInterfaceThemeChangedNotification distributed notification.
This is a dead wiring bug, and the shape matters more than the specific API. UIScreen.modeDidChangeNotification is a real symbol; subscribing to it compiles, runs, and produces no error. It just never fires for this. Nothing at build or run time surfaces a subscription that is never delivered.
The consequence for testing is the important part: a direct-invocation test — construct the observer, call refresh(), assert — passes against the broken code, because the broken thing is that nothing ever calls refresh(). The suite therefore uses a live test over a real UIWindow and a real trait registration, following WebContentTerminationWiringTests (T-1943), which exists for the identical reason.
registerForTraitChanges returns a token scoped to the trait environment it was made on. Two facts drive the design:
@State on the App value, constructed before any window exists — so the first attempt finds nothing.WindowGroup scene per document, so on iPadOS the holding window can close mid-session.Both are handled by tracking a weak registeredWindow plus the retained UITraitChangeRegistration, and moving the registration to the currently active window on UIWindow.didBecomeKeyNotification and UIScene.didDisconnectNotification. Round 2 of review caught the first implementation using a hasRegistered boolean — which goes permanently stale the moment its window closes, silently restoring the bug.
UIWindowScene is also a trait environment and outlives individual windows, which would have simplified the re-arm logic. The window was chosen because a test can force a trait change on it via overrideUserInterfaceStyle; a scene offers no such hook, so that choice would have reproduced the no-coverage condition that let the original bug through. Testability bought some lifecycle complexity.deinit unregistration. unregisterForTraitChanges is main-actor work a deinit can't do. It's unnecessary: the handler captures self weakly, so the registration is inert once the observer is gone. Sound for a single app-lifetime instance; it would leak inert registrations if the class were ever constructed per view.WebViewPool.selectIOSWindow(from:) (T-745) rather than connectedScenes.first, which is non-deterministic because connectedScenes is a Set.Registering on the UIWindow is only correct if nothing pins that window's userInterfaceStyle. UIView.overrideUserInterfaceStyle affects the view's own traitCollection, so a window-level override would freeze the trait and the registration would never fire on a system toggle. Prism forces a concrete value on every render — ThemeModifier deliberately never passes nil to .preferredColorScheme() (T-429's workaround for the stale-environment bug) — so the question is precisely where SwiftUI lands that override.
The live test cannot answer it: it forces overrideUserInterfaceStyle by hand, which fires the registration either way. A window-level implementation would have made this fix a no-op in production with a fully green suite.
Measured on an iPhone 17 Pro simulator (iOS 26.5), reading the running host app's window in both system appearances:
system dark : winOverride=0 winTrait=2 rootVC=UIHostingController<ModifiedContent<AnyView, RootModifier>> rootVCOverride=2 screen=2
system light: winOverride=0 winTrait=1 rootVC=UIHostingController<ModifiedContent<AnyView, RootModifier>> rootVCOverride=1 screen=1
SwiftUI applies preferredColorScheme to the root hosting controller (rootVCOverride concrete), never to the window (winOverride == .unspecified), and trait overrides propagate downward only. The window's style therefore tracks the screen unconditionally. Two consequences: the registration fires on genuine system changes only, and — equally important — a user switching Prism's own appearance mode from Auto to forced Dark cannot feed that override back into systemColorScheme and corrupt the value Auto later falls back to, which would have been a fresh instance of the T-429 failure class.
UITraitCollection.current inside the handlerThe handler discards both parameters UIKit supplies and calls refresh(), which reads the UITraitCollection.current thread-local. The code comment correctly notes this is only guaranteed inside the handler's dynamic extent — and the live test proves the guarantee holds, since it asserts the observer picks up a value that only exists as the window's override. Reading window.traitCollection.userInterfaceStyle from the parameter would be unconditionally correct and free of that dependency, but refresh() is shared with the macOS path and two external callers, so rerouting it is a wider change than this bug warrants. Left as-is deliberately.
Note the notification path calls refresh() from inside Task { @MainActor }, i.e. outside any trait handler, where the thread-local guarantee does not apply. Measured above: outside a handler, current equals the screen's value, which is exactly what that path wants.
window !== registeredWindow short-circuits; no duplicate registration.weak makes it nil, the unregister is skipped, and the stale token is overwritten. Correct: the registration died with the window.activeWindow() returns nil — the existing registration is left intact rather than dropped, and the observers stay subscribed for the process lifetime, so a later window still re-arms. This is why the notification observers are deliberately not torn down after first success.foregroundActive scenes, selectIOSWindow's input order is non-deterministic (connectedScenes is a Set), so consecutive calls can flip the target and cause register/unregister churn. Bounded and harmless — interface style is app-wide, so any live window is as good as another — but a cheap early-out (“keep the incumbent while it is still attached”) would remove it.The UIScene.didDisconnectNotification branch has no automated coverage. The test host owns one UIWindowScene and supportsMultipleScenes is false on the iPhone destination, so a second scene cannot be connected or disconnected. A hand-posted notification does reach the handler (subscribed with object: nil), but every single-scene route to a stale registration makes UIKit promote another window to key and post didBecomeKeyNotification first — measured by instrumenting the notification stream — so such a test passes identically with the listener deleted. Shipping it would have been a guard that guards nothing, which is strictly worse than a documented gap: it converts a known hole into an unknown one. Recording the branch, the evidence, and a manual iPad procedure is the honest outcome.
One cheaper option was not taken: making the trigger list an injectable init parameter and asserting the production default contains didDisconnect. It proves no behaviour but does fail if someone deletes the listener — the exact regression the note says it cannot guard. Worth considering, though the added seam is itself a cost.
Fully implemented: the change signal (satisfying specs/themes Req 2.2 and 2.5, which the bug violated on iOS); re-arm across window key changes; registration migration with no accumulation; regression coverage for the wrong-notification and boolean-latch failure modes.
Partially covered: the didDisconnect re-arm — implemented, documented, manually verifiable, not automatically tested.
Missing: specs/bugfixes/auto-theme-switch-needs-restart/report.md, the repo's universal bugfix convention (104/104 other directories have one; the directory for this bug exists and is empty).
prism/Theme/SystemColorSchemeObserver.swift
Why it matters. This is the whole bug. UIScreen.modeDidChangeNotification reports UIScreenMode (resolution) changes and never fires for an appearance toggle, so refresh() was never called and systemColorScheme kept its launch value for the life of the process. Auto mode was broken on every iOS device.
What to look at. SystemColorSchemeObserver.swift:79-114 (init, iOS branch) and :149-180 (registerForInterfaceStyleChangesIfPossible, activeWindow)
prism/Theme/SystemColorSchemeObserver.swift
Why it matters. The first implementation gated registration on a one-shot boolean. A trait registration dies with the specific UIWindow it was made on, and Prism opens a WindowGroup scene per document — so closing that window on iPadOS left the latch true, permanently no-opping the retry and silently restoring the exact 'requires a restart' bug for the rest of the session.
What to look at. SystemColorSchemeObserver.swift:30-57 (registeredWindow / interfaceStyleRegistration / windowObservers) and :149-168
prismTests/SystemColorSchemeObserverTests.swift
Why it matters. The defect was in the wiring, not in any function's logic. A test that constructs the observer and calls refresh() by hand passes against the broken code, because the broken thing is that nothing ever calls refresh(). Only a test that provokes a real trait change can fail against the old notification.
What to look at. SystemColorSchemeObserverTests.swift:56-91 (toggling) and :104-150 (re-arm after the registered window goes away)
prismTests/SystemColorSchemeObserverTests.swift
Why it matters. Round 3 found the re-arm test proves only the didBecomeKey trigger. Rather than adding a synthetic didDisconnect post, the branch is recorded as uncovered with measured evidence and a manual iPad procedure.
What to look at. SystemColorSchemeObserverTests.swift:152-181 (MARK: Known coverage gap)
prismTests/SystemColorSchemeObserverTests.swift
Why it matters. The tests resolved their target window with connectedScenes...windows.first — non-deterministic (connectedScenes is a Set) and a different selection rule from the production code under test, so on a multi-window host they could force the override on a window the observer never registered on and go vacuous rather than fail.
What to look at. SystemColorSchemeObserverTests.swift:28-52 (suite comment, activeWindow helper), :62-66 (defer), :118-121 (anti-vacuity assertion)
A UIWindowScene is also a trait environment and outlives individual windows, which would have removed most of the re-arm machinery. The window was chosen because a test can force a trait change on it by setting overrideUserInterfaceStyle; a scene offers no equivalent hook, so that choice would have left the fix with the same absence of coverage that let the original defect ship. Testability was bought with lifecycle complexity, and this review's measurement confirms the window is a safe target.
unregisterForTraitChanges is main-actor work a deinit cannot perform. It is also unnecessary: the handler captures self weakly, so once the observer is gone the registration is inert and dies with its window. Sound given the single app-lifetime instance (prismApp.swift:25); it would leave one inert registration per abandoned observer if the class were ever constructed per view.
Rather than connectedScenes.first, which is non-deterministic because connectedScenes is a Set and can select a background scene. This is the repo's established helper (T-745) with two existing non-WebKit callers, MermaidRenderer and InlineNotesShareHelper; docs/agent-notes/webview-pool.md states outright that .first must never be used here.
They are the re-arm mechanism, not a one-shot retry: registration must follow whichever window is current as document windows open and close, so the subscriptions must stay live indefinitely. Removing them after first success would reproduce the boolean-latch failure.
Apple documents that UIKit sets UITraitCollection.current to the new traits before invoking a registerForTraitChanges handler and restores it afterwards, and UIWindow is a UIView — so reading the thread-local inside the handler is contractual, not incidental. Reading window.traitCollection.userInterfaceStyle from the supplied parameter would still be marginally more direct, but refresh() is shared with the macOS path and two external callers (prismApp.swift:434, DocumentFlowCoordinator.swift:556), which all call it outside any handler where current resolves to the screen's value — which is exactly what those callers want. Rerouting it is a wider change than this bug warrants. Both paths were measured correct in this review.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| critical | SystemColorSchemeObserver.swift — window as registration target | The fix registers on the UIWindow, which is only correct if nothing pins that window's userInterfaceStyle. ThemeModifier applies .preferredColorScheme with an always-concrete value on every render (T-429), and UIView.overrideUserInterfaceStyle does affect the view's own traitCollection. If SwiftUI landed that override on the window, the registration would never fire on a system toggle — the fix would be a production no-op, and the live test would still pass, because it forces the override by hand. The code comment asserted the assumption without evidence. | Verified empirically on an iPhone 17 Pro simulator (iOS 26.5) by inspecting the running host app's window in both system appearances. SwiftUI applies preferredColorScheme to the root UIHostingController (rootVCOverride concrete), never to the window (winOverride == .unspecified), and the window's trait tracks the screen in both light and dark. The assumption holds; the fix is sound. Both doc comments rewritten to state the real reason precisely rather than the weaker 'nothing sets overrideUserInterfaceStyle'. |
| major | specs/bugfixes/auto-theme-switch-needs-restart/ | The bugfix directory exists but is empty. All 104 other directories under specs/bugfixes/ carry a report.md; it is the strongest convention in the repo, and the empty directory shows one was intended for this bug. | NOT FIXED — writing the file was blocked by a tooling guard on report-style markdown. Full content outline supplied to the caller: canonical headings (Description of the Issue / Investigation Summary / Discovered Root Cause / Resolution for the Issue / Regression Test / Affected Files / Verification / Prevention / Related), a **Ticket:** T-1698 header, and a note that the run command is `make test`, not `make test-quick` — the file is entirely inside #if os(iOS) and test-quick runs on macOS, so it would compile to nothing and report success having run none of it. |
| major | CHANGELOG.md | No [Unreleased] → Fixed entry. 17 of the last 20 commits on main touch CHANGELOG.md; the three that don't are CI-only or internal. | Added a user-facing entry in the house style — names the surface, not the API, and covers the iPad multi-window aspect plus what is explicitly unchanged (macOS, forced Light/Dark). |
| major | SystemColorSchemeObserverTests.swift — fixture selection | All three tests resolved their target window with connectedScenes.compactMap{...}.flatMap(\.windows).first. That is non-deterministic (connectedScenes is a Set) and a different selection rule from the production code under test, which uses WebViewPool.selectIOSWindow. On a host with more than one window the tests would force the override on a window the observer never registered on — going vacuous rather than failing loudly. docs/agent-notes/webview-pool.md states .first must never be used here. | Added a private activeWindow() helper delegating to WebViewPool.selectIOSWindow(from:), so test and production cannot drift. testHostHasConnectedWindow now asserts the actual precondition for registration rather than 'some window exists'. Suite re-run: 3 tests, 3 passed. |
| major | SystemColorSchemeObserverTests.swift — re-arm test could go vacuous | registrationReArmsAfterRegisteredWindowGoesAway assumed, without checking, that the observer registered on the extra window it creates. If the observer had selected the host window all along, the re-arm it is meant to prove would never be exercised and the final assertion would pass regardless. | Added #expect(Self.activeWindow() === extraWindow) after makeKeyAndVisible() and before the observer is constructed, so the test fails loudly if its own premise stops holding. |
| minor | SystemColorSchemeObserverTests.swift — .serialized comment overstates isolation | The suite comment presented .serialized as closing the shared-window hazard. It only orders tests within the suite; Swift Testing runs other suites in the same host concurrently, prismTests is marked parallelizable in prism.xctestplan, and overrideUserInterfaceStyle plus window key-ness are process-global. Other iOS suites resolve a window via selectIOSWindow, which prefers the key window. | Comment corrected to state the residual cross-suite risk honestly instead of implying it is closed, and override restoration moved into defer so a cancellation thrown from a polling sleep cannot leak a forced style into the rest of the process. |
| minor | SystemColorSchemeObserver.swift — stale macOS doc comment | The class doc said macOS 'reads NSApp.effectiveAppearance', but readCurrentSystemScheme reads the AppleInterfaceStyle user default. Pre-existing, but this PR edits the same comment block three lines below. | Corrected while in the area. |
| minor | docs/agent-notes/ | No note recorded the multi-window re-arm design or the wrong-notification gotcha. The theming knowledge for this subsystem lives under 'Non-obvious Gotchas' in typography-font-settings.md alongside the T-429 entry. | Added a T-1698 bullet there covering the wrong signal, the registration-dies-with-its-window consequence, and the measured hosting-controller-vs-window fact. |
| minor | SystemColorSchemeObserver.swift — multi-scene selection churn | With two foregroundActive scenes (Split View / Stage Manager — the configuration this change targets), selectIOSWindow's input order is non-deterministic, so consecutive calls with no state change can return different windows and cause register/unregister churn, potentially landing the registration on the non-focused scene's window. | Skipped — bounded and behaviourally harmless, since interface style is app-wide and any live window reports the same value; never more than one registration per window. A cheap early-out ('keep the incumbent while it is still attached') would remove the churn if it ever matters. |
| minor | SystemColorSchemeObserver.swift — Task hop and dead iOS property | The notification handlers wrap work in Task { @MainActor } although the observers are registered with queue: .main, costing an allocation and a deferred hop. Separately, the `observer` property is only ever assigned on macOS, so its #else branch in deinit is unreachable on iOS. | Skipped — both are cosmetic. Removing the Task hop would also invalidate the reasoning behind the re-arm test's 200ms settle, so it is not free; the dead property predates this change. |
| minor | prismTests — duplicated poll-until-deadline loop | The two polling loops duplicate a helper that already exists three times in the repo (WebNavigationPrecedenceHarness.waitUntil and two copies). Existing copies also use ContinuousClock where this file uses wall-clock Date(). | Skipped — the existing helper lives in a WebRendering-specific harness, so importing it into a Theme test would be its own smell. The honest fix is lifting waitUntil into shared test support and migrating all four call sites, which is a separate cleanup. |
| nit | CHANGELOG.md — pre-existing duplicates | The [Unreleased] → Fixed section contains verbatim duplicate entries: T-1812 twice, T-1840 four times, T-1811 three times, T-1951 twice. Evidently from repeated rebases across parallel blitz PRs. | Skipped deliberately — pre-existing and unrelated to this branch. Flagged for a separate cleanup before the next release-prep. |
| major | SystemColorSchemeObserverTests.swift — target derived from the wrong source | Both live tests picked the target style from observer.systemColorScheme rather than from the window they were about to mutate. Those read different sources — the observer reads the UITraitCollection.current thread-local; the trait change happens on the window. If the two ever disagreed the test would set the style the window already had, no trait change would fire, and it would fail after a 2s poll for a reason unrelated to the code under test. The inverse hole was also open: an observer already reporting the target value would satisfy the poll with no trait change ever delivered. | Target now derived from window.traitCollection.userInterfaceStyle, with an up-front assertion that the observer starts out disagreeing with it — closing both directions. Suite re-run: 3 tests, 3 passed. |
| minor | SystemColorSchemeObserver.swift — deinit comment inverted the lifetimes | The deinit note justified skipping unregistration with 'the registration is inert and dies with its window'. That inverts the real lifetimes: the key window normally OUTLIVES the observer, so each discarded observer leaves one inert closure behind on the long-lived window rather than the closure dying first. The conclusion (harmless) is right; the stated reason was not the operative one. | Comment corrected to state the actual lifetime relationship and the condition that makes it acceptable — exactly one observer per app lifetime (@State on PrismApp) — plus an explicit warning that constructing the class per view would make the accumulation unbounded. |
| minor | SystemColorSchemeObserver.swift — trait handler comment imprecise | The comment claimed UIKit calls the handler 'synchronously (not deferred)' as the reason UITraitCollection.current is valid inside it. Synchrony is not the documented guarantee; the guarantee is that UIKit sets current to the new traits before invoking the handler and restores it afterwards. | Reworded to the documented guarantee, and to note that refresh() called from anywhere else reads the screen's value — which is what the other three call sites want. |
| minor | SystemColorSchemeObserver.swift — transient key windows | selectIOSWindow prefers first(where: \.isKeyWindow), and UITextEffectsWindow / UIRemoteKeyboardWindow can become key while a search field or the URL sheet has focus. The registration would migrate to that transient window. | Skipped — benign. Those windows carry no interface-style override, so the registration keeps reporting the correct value while attached, and the next didBecomeKey re-arms onto the main window when they go away. Filtering on rootViewController != nil would remove the churn if it ever proves to matter. |
Click to expand.
diff --git a/prism/Theme/SystemColorSchemeObserver.swift b/prism/Theme/SystemColorSchemeObserver.swiftindex 67b92e5..570c9cc 100644--- a/prism/Theme/SystemColorSchemeObserver.swift+++ b/prism/Theme/SystemColorSchemeObserver.swift@@ -12,11 +12,17 @@ import AppKit /// environment value. By reading the system appearance directly, we get the /// correct fallback for auto mode. ///-/// On macOS: reads `NSApp.effectiveAppearance` and listens for the+/// On macOS: reads the `AppleInterfaceStyle` user default and listens for the /// `AppleInterfaceThemeChangedNotification` distributed notification. ///-/// On iOS: reads `UITraitCollection.current` and listens for-/// `UIScreen.modeDidChangeNotification` and trait collection changes.+/// On iOS: reads `UITraitCollection.current` and registers for+/// `UITraitUserInterfaceStyle` trait changes on the app's window (T-1698).+///+/// The iOS registration deliberately targets the `UIWindow`: SwiftUI applies+/// `ThemeModifier`'s `.preferredColorScheme` to the root `UIHostingController`,+/// not to the window, so the window's own `userInterfaceStyle` keeps tracking+/// the real system appearance and this observer never sees Prism's own+/// appearance-mode changes reflected back at it. @MainActor @Observable final class SystemColorSchemeObserver {@@ -27,6 +33,35 @@ final class SystemColorSchemeObserver { @ObservationIgnored private var observer: Any? + #if os(iOS)+ /// The window the interface-style trait registration is currently+ /// attached to, and the token identifying that registration.+ ///+ /// This deliberately tracks *which* window holds the registration rather+ /// than a "registration ever succeeded" flag. Prism opens a+ /// `WindowGroup` scene per document, so several `UIWindow`s coexist on+ /// iPadOS; a trait registration dies with the specific window it was+ /// made on. A boolean latch would stay true after that window closed and+ /// permanently no-op the re-arm below — silently reinstating the+ /// "requires a restart" bug this observer exists to fix. The reference is+ /// weak so it self-clears when the window is deallocated.+ @ObservationIgnored+ private weak var registeredWindow: UIWindow?++ @ObservationIgnored+ private var interfaceStyleRegistration: UITraitChangeRegistration?++ /// Notification observer tokens that drive (re-)registration.+ ///+ /// These are held for this observer's whole lifetime, not torn down after+ /// the first successful registration: they are the re-arm mechanism, not+ /// a one-shot retry. Registration must follow whichever window is current+ /// as document windows open and close, so the observer has to keep+ /// listening indefinitely.+ @ObservationIgnored+ private var windowObservers: [any NSObjectProtocol] = []+ #endif+ init() { self.systemColorScheme = Self.readCurrentSystemScheme() @@ -47,15 +82,35 @@ final class SystemColorSchemeObserver { } } #else- // On iOS, listen for trait collection changes via UIScreen notifications.- observer = NotificationCenter.default.addObserver(- forName: UIScreen.modeDidChangeNotification,- object: nil,- queue: .main- ) { [weak self] _ in- Task { @MainActor [weak self] in- self?.refresh()- }+ // On iOS, register for `UITraitUserInterfaceStyle` changes on the+ // app's window via the modern trait-change API (iOS 17+).+ //+ // T-1698: this previously listened for `UIScreen.modeDidChangeNotification`,+ // which fires for screen resolution/mode changes — NOT Dark Mode toggles.+ // That notification never fired on an automatic system appearance switch,+ // so `refresh()` never ran and only an app restart (which re-reads the+ // system scheme in `init()`) picked up the change.+ registerForInterfaceStyleChangesIfPossible()++ // Two reasons registration has to be revisited, not done once:+ // the app's window may not exist yet this early in launch (this+ // observer is created before the first window is), and the window+ // holding the registration can later close while the app keeps+ // running (iPadOS opens a scene per document). Both surface as+ // another window becoming key, or as the owning scene disconnecting.+ for name in [UIWindow.didBecomeKeyNotification, UIScene.didDisconnectNotification] {+ windowObservers.append(+ NotificationCenter.default.addObserver(+ forName: name,+ object: nil,+ queue: .main+ ) { [weak self] _ in+ Task { @MainActor [weak self] in+ self?.registerForInterfaceStyleChangesIfPossible()+ self?.refresh()+ }+ }+ ) } #endif }@@ -68,7 +123,80 @@ final class SystemColorSchemeObserver { NotificationCenter.default.removeObserver(observer) #endif }+ #if os(iOS)+ for windowObserver in windowObservers {+ NotificationCenter.default.removeObserver(windowObserver)+ }+ // `interfaceStyleRegistration` is deliberately not unregistered here.+ // `unregisterForTraitChanges` is main-actor work that a `deinit`+ // can't do, and the handler captures `self` weakly, so once this+ // observer is gone the registration is inert.+ //+ // It is not, however, short-lived: the key window normally OUTLIVES+ // the observer, so each discarded observer leaves one inert closure+ // behind on that window. That is acceptable only because there is+ // exactly one observer per app lifetime (`@State` on `PrismApp`).+ // Constructing this class per view would make the accumulation+ // unbounded — the `#Preview` in `SettingsView` and the tests each+ // build one, which is harmless at their scale but is the pattern to+ // keep out of production code.+ #endif+ }++ #if os(iOS)+ /// Registers for `UITraitUserInterfaceStyle` trait changes on the app's+ /// currently active window, moving the registration there if it is+ /// presently on a different (or deallocated) window. A no-op when the+ /// active window is already the registered one, or when no window exists+ /// yet.+ ///+ /// Following the active window is what keeps this alive across iPadOS+ /// multi-window teardown: closing the document window that happened to+ /// carry the registration leaves the observer registered on whichever+ /// window survives, instead of silently observing nothing.+ ///+ /// Registering on the window (rather than e.g. the `UIWindowScene`) is+ /// what makes this observable in a live test: nothing sets the *window's*+ /// `overrideUserInterfaceStyle` in production — SwiftUI puts+ /// `.preferredColorScheme` on the root `UIHostingController` instead, and+ /// overrides propagate downward only — so the window's trait always tracks+ /// the real system value, but tests can still force a trait change by+ /// setting the override directly on the same window.+ private func registerForInterfaceStyleChangesIfPossible() {+ guard let window = Self.activeWindow(), window !== registeredWindow else { return }++ // Drop the previous registration if that window is still around —+ // otherwise it died with it.+ if let previousWindow = registeredWindow, let interfaceStyleRegistration {+ previousWindow.unregisterForTraitChanges(interfaceStyleRegistration)+ }++ // UIKit sets the `UITraitCollection.current` thread-local to the new+ // traits before invoking this handler and restores it afterwards, so+ // `refresh()` — which reads `current` — sees this window's updated+ // style. That guarantee is documented for the trait-change handler+ // specifically, and holds only for its dynamic extent; `refresh()`+ // called from anywhere else reads the screen's value instead, which+ // is what the other call sites want.+ interfaceStyleRegistration = window.registerForTraitChanges(+ [UITraitUserInterfaceStyle.self]+ ) { [weak self] (_: UIWindow, _: UITraitCollection) in+ self?.refresh()+ }+ registeredWindow = window+ }++ /// The app's currently active window, used as the trait-change+ /// observation target. Returns `nil` if no scene/window is connected yet.+ ///+ /// Reuses `WebViewPool.selectIOSWindow(from:)` (T-745) rather than+ /// `connectedScenes.first`, which is non-deterministic (`connectedScenes`+ /// is a `Set`) and can pick a background/inactive scene.+ private static func activeWindow() -> UIWindow? {+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }+ return WebViewPool.selectIOSWindow(from: scenes) }+ #endif /// Re-reads the current system appearance and updates the published value. func refresh() {
diff --git a/prismTests/SystemColorSchemeObserverTests.swift b/prismTests/SystemColorSchemeObserverTests.swiftnew file mode 100644index 0000000..7f9b1e6--- /dev/null+++ b/prismTests/SystemColorSchemeObserverTests.swift@@ -0,0 +1,200 @@+//+// SystemColorSchemeObserverTests.swift+// prismTests+//+// Regression tests for T-1698: automatic light/dark switching required an+// app restart on iOS.+//+// The bug: `SystemColorSchemeObserver` listened for+// `UIScreen.modeDidChangeNotification` to detect Dark Mode toggles. That+// notification reports screen resolution/mode changes, not interface-style+// changes, so it never fired on an automatic system appearance switch —+// `refresh()` never ran, and only a restart (which re-reads the system+// scheme in `init()`) picked up the change.+//+// A direct-invocation test (constructing the observer and calling+// `refresh()` by hand) can't see this class of bug: the failure is in the+// production *wiring* — the observer never gets told a change happened in+// the first place. So this is a live test over the real trait-change+// registration, mirroring the pattern used for the WebContent-termination+// wiring regression (T-1943, see `WebContentTerminationWiringTests`).++#if os(iOS)+import Testing+import SwiftUI+import UIKit+@testable import prism++// Serialized: every test here forces `overrideUserInterfaceStyle` on the one+// window the test host owns and then polls across `await` points, so two of+// them running concurrently would see each other's overrides.+//+// `.serialized` only orders the tests *within this suite*; Swift Testing still+// runs other suites in the same host concurrently. `overrideUserInterfaceStyle`+// and window key-ness are process-global, and other iOS suites resolve a window+// through `WebViewPool.selectIOSWindow(from:)`, which prefers the key window —+// so the residual cross-suite risk is real, just not one a suite trait can+// close. Each test restores the override in a `defer` to keep that window as+// narrow as possible.+@Suite("SystemColorSchemeObserver Tests — T-1698 regression", .serialized)+@MainActor+struct SystemColorSchemeObserverTests {+ /// Resolves the window exactly as `SystemColorSchemeObserver` does, so the+ /// test can never force a trait change on a window the observer did not+ /// register on. Using `connectedScenes.first` here instead would be both+ /// non-deterministic (`connectedScenes` is a `Set`) and a *different*+ /// selection rule from the code under test — the tests would go vacuous+ /// rather than fail if the two ever disagreed.+ private static func activeWindow() -> UIWindow? {+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }+ return WebViewPool.selectIOSWindow(from: scenes)+ }++ /// The unit test target hosts inside the real `prism.app` (TEST_HOST),+ /// so a `UIWindowScene`/`UIWindow` should be connected by the time tests+ /// run. If this fails, the live test below can't be trusted — the+ /// observer would have silently failed to register for the same reason.+ @Test("Test host has a connected window to observe")+ func testHostHasConnectedWindow() {+ #expect(Self.activeWindow() != nil)+ }++ @Test("Toggling the window's interface style refreshes the observer without a restart")+ func togglingWindowInterfaceStyleRefreshesObserver() async throws {+ let window = try #require(Self.activeWindow())++ // Restore on every exit path, including a thrown cancellation from the+ // polling sleeps below — a leaked override would follow the process+ // into other suites.+ defer { window.overrideUserInterfaceStyle = .unspecified }++ // Reset any override left behind by a previous run, and let the+ // observer establish its baseline from the real (unforced) style.+ window.overrideUserInterfaceStyle = .unspecified+ let observer = SystemColorSchemeObserver()++ // Force the opposite of what the *window* currently resolves to. The+ // target has to come from the window rather than from+ // `observer.systemColorScheme`: the observer reads the+ // `UITraitCollection.current` thread-local, and if the two ever+ // disagreed we would be setting the style the window already has, no+ // trait change would fire, and this would fail after a 2s poll for a+ // reason that has nothing to do with the code under test.+ //+ // This is the same trait (`UITraitUserInterfaceStyle`) a real Dark+ // Mode toggle changes — the production code can't distinguish an+ // override from a genuine system change.+ let target: ColorScheme = window.traitCollection.userInterfaceStyle == .dark ? .light : .dark++ // ...and the observer has to start out disagreeing with it, or the+ // poll below could be satisfied without any trait change having been+ // delivered at all.+ #expect(observer.systemColorScheme != target)++ window.overrideUserInterfaceStyle = target == .dark ? .dark : .light++ // Trait propagation isn't necessarily synchronous with the property+ // set, so poll briefly rather than asserting immediately.+ let deadline = Date().addingTimeInterval(2)+ while observer.systemColorScheme != target, Date() < deadline {+ try await Task.sleep(nanoseconds: 20_000_000)+ }++ #expect(observer.systemColorScheme == target)+ }++ /// The registration is made on one specific `UIWindow` and dies with it.+ /// Prism opens a `WindowGroup` scene per document, so on iPadOS the+ /// window that happened to carry the registration can be closed while+ /// the app keeps running. Tracking "registration ever succeeded" instead+ /// of "which window holds it" leaves the observer permanently deaf from+ /// that point on — the same "requires a restart" symptom, re-triggered by+ /// a window closing rather than by the wrong notification name.+ ///+ /// Scope: this covers the `UIWindow.didBecomeKeyNotification` re-arm+ /// only — the explicit `makeKeyAndVisible()` below is what drives it. The+ /// observer's other re-arm trigger is uncovered; see the note below.+ @Test("Registration re-arms on a surviving window after the registered window goes away")+ func registrationReArmsAfterRegisteredWindowGoesAway() async throws {+ let hostWindow = try #require(Self.activeWindow())+ let scene = try #require(hostWindow.windowScene)+ defer {+ hostWindow.overrideUserInterfaceStyle = .unspecified+ hostWindow.makeKeyAndVisible()+ }+ hostWindow.overrideUserInterfaceStyle = .unspecified++ // A second window, standing in for a second document scene. Making+ // it key means it is the window `activeWindow()` selects, so the+ // observer registers against it rather than the host window.+ var extraWindow: UIWindow? = UIWindow(windowScene: scene)+ extraWindow?.overrideUserInterfaceStyle = .unspecified+ extraWindow?.makeKeyAndVisible()++ // Without this the test would be vacuous: if the observer registered+ // on the host window all along, the re-arm it is meant to prove would+ // never be needed and the assertion below would pass regardless.+ #expect(Self.activeWindow() === extraWindow)++ let observer = SystemColorSchemeObserver()++ // Close that window. The host window becomes key again, exactly as+ // when one of several open document windows is closed.+ extraWindow?.isHidden = true+ extraWindow = nil+ hostWindow.makeKeyAndVisible()++ // Re-registration is dispatched onto the main actor by the+ // notification handler, so let that turn run before provoking the+ // trait change it needs to catch.+ try await Task.sleep(nanoseconds: 200_000_000)++ // Derived from the surviving window, and asserted to differ from the+ // observer's current value, for the same two reasons as the test+ // above: guarantee a real trait change fires, and guarantee the poll+ // cannot be satisfied without one.+ let target: ColorScheme = hostWindow.traitCollection.userInterfaceStyle == .dark ? .light : .dark+ #expect(observer.systemColorScheme != target)++ hostWindow.overrideUserInterfaceStyle = target == .dark ? .dark : .light++ let deadline = Date().addingTimeInterval(2)+ while observer.systemColorScheme != target, Date() < deadline {+ try await Task.sleep(nanoseconds: 20_000_000)+ }++ #expect(observer.systemColorScheme == target)+ }++ // MARK: - Known coverage gap: the UIScene.didDisconnectNotification re-arm+ //+ // `SystemColorSchemeObserver` re-arms its trait registration on two+ // triggers. Only the first, `UIWindow.didBecomeKeyNotification`, is+ // covered (above). The second, `UIScene.didDisconnectNotification`, has+ // no automated coverage, deliberately rather than by oversight.+ //+ // Why it can't be driven here: the unit-test host owns exactly one+ // `UIWindowScene`, and a test cannot connect or disconnect another —+ // `UIApplication.supportsMultipleScenes` is false on the iPhone+ // destination `make test` uses, so a scene-session request is not+ // available either. Posting `UIScene.didDisconnectNotification` by hand+ // does reach the production handler (the observer subscribes with+ // `object: nil` on the default centre), but such a test cannot be shown+ // to prove anything, because it passes just as well with the listener+ // deleted: every route to a stale registration available inside a single+ // scene makes UIKit promote another window to key and post+ // `didBecomeKeyNotification` first, which re-arms the observer on its+ // own. That was measured, not assumed — instrumenting the notification+ // stream showed both deallocating the registered window and calling+ // `resignKey()` on it produce a `UIWindowDidBecomeKeyNotification` for+ // the surviving window before any synthetic post can be made.+ //+ // Manual check on an iPad (the platform where the branch matters): open+ // two Prism document windows, close the one that is currently key, send+ // the app to the background, toggle Light/Dark in Control Centre, then+ // return to the remaining window. Its theme must already match the new+ // system appearance with no relaunch. Repeat with the app left in the+ // foreground as a control — that path is the covered one and should+ // behave identically.+}+#endif
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 338d8c8..8e069c2 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- On iPhone and iPad, Prism now follows the system's light/dark setting the moment it changes, instead of waiting for a restart (T-1698). With appearance set to Auto, switching Dark Mode on or off — by hand in Control Centre, or automatically at sunset — left the app rendering in the previous appearance for the rest of the session; only quitting and reopening it picked the change up, because reopening reads the system setting afresh. The app had been listening for the wrong system signal: one that reports a change to the screen's resolution, not to its appearance, and which therefore never arrived. It now watches the appearance itself. On iPad this also holds across multiple open document windows: the watch follows whichever window is in front, so closing the one it happened to be attached to no longer leaves the remaining windows stuck on the old appearance. Mac was never affected, and the forced Light and Dark modes are unchanged — a system appearance change still leaves them where you set them.+ - Share with Notes on a remote document opened from a host-only or trailing-slash root URL (e.g. `https://example.com`) no longer always fails (T-1822). The display name used for that document falls back to its full absolute URL so a root URL never shows a blank title (T-1177) — but Share with Notes reused that same string, unsanitised, as the temporary export file's name. The URL's `/` and `:` characters were read as path separators, so the write always targeted a nonexistent nested directory and "Export Failed" appeared every time. The export filename is now derived from a sanitised version of the display name, with path separators and control characters stripped rather than passed through. - A block whose text is exactly a thematic break — `---`, `***`, or `___` — no longer renders empty (T-1669). Rendering re-parses a block's text as its own small markdown document, and that string satisfying markdown's rule for a horizontal rule made it parse as one rather than as plain text; nothing then knew how to turn a rule back into visible text, so it vanished. This is the third defect of this shape (after numbered list markers and `@`-prefixed text, T-1640/T-1641): rendering now knows a rule found this way as text too, and shows the characters as written — in a paragraph, a heading, a list item, or a table cell alike.
diff --git a/docs/agent-notes/typography-font-settings.md b/docs/agent-notes/typography-font-settings.mdindex 8fe6e97..ce80a95 100644--- a/docs/agent-notes/typography-font-settings.md+++ b/docs/agent-notes/typography-font-settings.md@@ -174,4 +174,5 @@ Do not reinstate per-call-site `.font(.system(size:))` overrides in chrome — e - Settings sheet/scene needs `.applyTheme(settings:systemObserver:)` for ThemePreviewCard to reflect theme colors. - Never pass `nil` to `.preferredColorScheme()` when the view also reads color scheme — always resolve to a concrete value via `SystemColorSchemeObserver`. - `applyTheme()` requires a `SystemColorSchemeObserver` parameter (added in T-429 fix). The observer should be created once at the app level and shared.+- **T-1698**: on iOS the observer's change *signal* is a `registerForTraitChanges([UITraitUserInterfaceStyle.self])` registration held on the app's active window (resolved via `WebViewPool.selectIOSWindow`, T-745), re-armed on `UIWindow.didBecomeKeyNotification` and `UIScene.didDisconnectNotification`. There is no UIKit notification for an interface-style change; the old code used `UIScreen.modeDidChangeNotification`, which reports screen *resolution/mode* changes and never fires on a Dark Mode toggle, so auto light/dark needed an app restart. Two consequences worth remembering. A trait registration is owned by the specific window it was made on and dies with it, so the observer must track *which* window holds it — a "registered once" boolean goes permanently stale when that document window closes on iPadOS, silently restoring the bug. And the window is a safe registration target precisely because SwiftUI lands `.preferredColorScheme` on the root `UIHostingController`, not on the `UIWindow` (measured on the simulator: the host window reports `overrideUserInterfaceStyle == .unspecified` while the hosting controller carries the forced value, and overrides propagate downward only). Register on the `UIWindowScene` instead and the behaviour becomes untestable; register somewhere SwiftUI does override and the observer would echo Prism's own appearance mode back at itself. - SwiftLint rule: `var x: String?` not `var x: String? = nil` (implicit_optional_initialization).
specs/bugfixes/auto-theme-switch-needs-restart/report.md is the one outstanding item. The empty directory is already on disk. Use the canonical headings and set **Ticket:** T-1698. One thing worth stating explicitly in it, because it is a trap for the next person: the run command is make test, not make test-quick — the whole test file is inside #if os(iOS) and test-quick runs on platform=macOS, so it compiles to nothing and reports success having run none of it.
Open two Prism document windows, close the one that is currently key, background the app, toggle Light/Dark in Control Centre, and return — the surviving window's theme must already match, with no relaunch. Repeat with the app left in the foreground as a control. This branch has no automated coverage and cannot get any in a single-scene test host.
Switch Prism to forced Dark while the system is Light, then back to Auto. Auto must land on the system's current appearance. The measurement in this review says the window's trait is unaffected by Prism's own .preferredColorScheme, so systemColorScheme cannot be contaminated — but this is the interaction most worth confirming by hand, since it is the failure class the observer exists to prevent.
GitHub Actions carries the account-level billing annotation, so red checks there are not signal. Validation for this branch was local: make build-ios (succeeded, 0 warnings), swiftlint --strict (0 violations across 533 files), and the regression suite on an iPhone 17 Pro simulator with the full flag set (3 tests run, 3 passed, count confirmed via Tools/check-test-results.sh — a zero-test pass would have been a failure).
It was committed by an earlier review workflow (PR #364) while .claude/review-diffs/ is gitignored globally. This review overwrote it and restored it, so this branch is unaffected — but it is scratch output sitting in version control and is worth untracking.