PR #413 — SVGRenderer accepted a colorScheme but only ever used it for the cache key. One commit forwards it to the WebKit render and forces the pooled WKWebView's own appearance on every call. Reviewed against origin/main (2b10c6dc), strictly read-only.
render(source:colorScheme:…) called renderInternal(source:displayScale:containerWidth:), whose parameter list never had colorScheme. The SnapshotCache key was right; the thing it keyed was wrong.colorScheme through renderInternal and retryAfterTermination; applyColorScheme(_:to:) sets overrideUserInterfaceStyle (iOS) / NSAppearance (macOS) on the WKWebView at the top of every withWebView closure, so pool reuse across schemes cannot leave a stale appearance.maxConcurrent: 1 so the same pooled view serves light/dark/light/dark. Behavioural, deterministic w.r.t. the host's own appearance, and reported red against the pre-fix renderer.Ready to push
The production fix is correct and minimal: four reviewers checked propagation timing, the retry path, actor isolation, snapshot configuration and the iOS re-attach path and found no defect. On a git archive export of HEAD, make lint and make verify-test-isolation pass, and a targeted macOS xcodebuild test of SVGRendererTests + WebViewPoolTests passed independently of the author's run (28 total, 17 passed, 0 failed, 11 pre-existing skips; both T-1896 tests green in ~1 s each, via Tools/check-test-results.sh). Both new tests are async inside the @MainActor @Suite(.liveWebKit) suite, as CLAUDE.md requires. One caveat keeps this from an unqualified green: the two new tests are the first enabled tests anywhere in prismTests that drive a real WebViewPool render + takeSnapshot (every sibling is .disabled("Requires full app context with window")). macOS is now confirmed twice; the iOS Simulator (make test, part of the pre-push checklist) was skipped here on instruction and the CI macOS runner is unverified. CI's per-locale sweep for b3c46c85 is queued and will answer the CI half; the iOS half needs a make test before merge, or an #if os(macOS) guard on the two tests.
b3c46c85 Fix T-1896: SVG renderer ignores requested light or dark appearance Prism turns SVG pictures inside a markdown file into normal images by loading them into a tiny hidden web browser and taking a screenshot. Some SVGs contain a rule that says "if the reader is in dark mode, use these colours instead". Prism was telling its renderer which mode to use, but the message was being dropped at the door: the function that received it never passed it on to the part that takes the screenshot. So the hidden browser just used whatever mode the computer happened to be in. The fix passes the message all the way through and, right before each screenshot, tells the hidden browser explicitly: you are in light mode or you are in dark mode.
If you set Prism to dark mode while your Mac is in light mode (or the reverse), an adaptive SVG showed up in the wrong colours. Worse, the wrong picture was then remembered under the label for the mode you asked for, so it stayed wrong until you cleared it.
prism/Services/SVGRenderer.swift: colorScheme is now a parameter of renderInternal and retryAfterTermination, and a new private static func applyColorScheme(_:to:) is the first statement inside the pool.withWebView closure. On iOS it sets webView.overrideUserInterfaceStyle; on macOS webView.appearance = NSAppearance(named: .darkAqua / .aqua). prismTests/SVGRendererTests.swift adds two enabled live-render tests and a small pixel-sampling helper (crop to 1×1, redraw into an RGBA8 context so byte order and premultiplication of the source are irrelevant). A bugfix report, CHANGELOG entry and an agent-note update round it out.
WebKit resolves @media (prefers-color-scheme) from the view's effective appearance, not from anything the page declares, so the override has to be native and per-view. Placing it at the top of the closure means it runs before the template load on a fresh view and before injectInnerHTML on a reused one; the JS round trip that follows is an out-of-process await, which gives the appearance change a runloop turn before any layout is read, and the 100 ms + 50 ms reflow sleeps come after that. The retry path re-enters renderInternal with the same colorScheme, so a replacement view after WebContent termination is covered without special handling.
NSWindow keeps concurrent renders with different schemes independent (pool allows up to maxConcurrent views on one window)..disabled like their siblings. That gives real regression coverage but makes them the first enabled pool-based snapshots in the suite — verified on macOS only.The bug is a pure propagation miss with a misleading surface: SnapshotCache.svgKey and every caller already carried colorScheme, so the API looked wired end-to-end and only the private call inside SVGRenderer dropped it. The fix is the smallest correct shape: no template change, no injected CSS, no color-scheme meta (which only advertises support and would not force resolution). applyColorScheme is static and side-effect-free beyond the two property writes; it executes inside the closure passed to pool.withWebView from a @MainActor method with no declared hop, so isolation is inherited and the AppKit/UIKit writes are legal. NSAppearance(named:) is optional-returning but never nil for the two system names; a nil would mean "inherit", i.e. the pre-fix behaviour, not a crash.
None on the public surface. MermaidRenderer controls theme through injected config rather than view appearance, so there is no shared pool-level hook this should have lived in; a pool-level withWebView(colorScheme:) would be premature with one consumer. The isNew closure parameter remains legitimately unused (template state is tracked by ObjectIdentifier). The tests set a precedent: an app-hosted prismTests on macOS can complete a live WebViewPool render + takeSnapshot because the pool self-creates its offscreen NSWindow; iOS has no equivalent and needs a live UIWindowScene via selectIOSWindow.
WebViewPoolSnapshotTests/MermaidRendererTests render case are .disabled("Requires full app context with window"). If the iOS Simulator host has no foreground-active scene at the moment attachToWindowHierarchy runs, the tests throw noWindowHierarchy and fail make test; on a CI macOS runner the offscreen NSWindow still needs a window server. Neither was verified.SVGRenderer nor WebViewPool has a deinit; each test's renderer, pool, view(s) and (macOS) window are released by ARC with WebKit tearing down asynchronously. Pre-existing, but this PR is what moves the suite from zero to six live renders, and T-2219's budget exists because of exactly this accumulation.default: on a non-frozen ColorScheme switch compiles clean today but forgoes the @unknown default nudge if Apple adds a case.prism/Services/SVGRenderer.swift
Why it matters. This is the actual fix. WebKit resolves prefers-color-scheme from the view's effective appearance, so the override must be native and per-view; placing it first in the withWebView closure covers fresh views, reused views, and the post-termination replacement view alike.
What to look at. prism/Services/SVGRenderer.swift: applyColorScheme(_:to:) and its call at the top of the withWebView closure in renderInternal
prism/Services/SVGRenderer.swift
Why it matters. The retry after WebContent termination re-enters renderInternal; if the parameter were dropped there the bug would survive on exactly the path that replaces every pooled view.
What to look at. prism/Services/SVGRenderer.swift: renderInternal(source:colorScheme:…), both catch arms, retryAfterTermination(source:colorScheme:…)
prismTests/SVGRendererTests.swift
Why it matters. The only behavioural proof of the fix, and the first enabled tests in the suite that complete a real WebViewPool render + snapshot. The second test's maxConcurrent: 1 pins the pool-reuse case specifically.
docs/agent-notes/webview-pool.md
Why it matters. Corrects a blanket assumption future sessions would otherwise inherit, and records the two caveats (headless CI, iOS needs a real UIWindowScene) that bound how far the correction goes.
What to look at. docs/agent-notes/webview-pool.md: new section at the end of Testing
@media (prefers-color-scheme) follows the view's effective appearance, independent of any HTML/CSS the page declares. A color-scheme: light dark declaration only advertises support. Source: bugfix report, Alternatives Considered.
WebViewPool hosts up to maxConcurrent views on one offscreen NSWindow; a window-level appearance would leak between concurrent renders with different schemes. Source: bugfix report.
The pool reuses views across calls that may request a different scheme each time. Source: commit message, code comment, report.
The author measured that an app-hosted macOS run can complete a live render because the pool creates its own offscreen window. Two caveats are recorded: unverified on a headless CI runner, and iOS has no self-created window. Source: docs/agent-notes/webview-pool.md, report.
A same-value write may still trigger a media-query re-evaluation in WebKit, but the render path already spends two JS round trips, 150 ms of sleeps and a snapshot; a guard would add stored state for an unmeasurable saving. Not stated by the author; the efficiency reviewer's judgement agrees with the choice.
(inferred — not stated by the author.)Makes the read independent of the snapshot's own byte order and premultiplication. Source: doc comment on centerPixelColor(of:).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | prismTests/SVGRendererTests.swift — platform coverage of enabled live renders | lightAndDarkRendersResolveDifferentColors and colorSchemeAppliesAcrossPooledReuse are the first enabled tests in prismTests that complete a WebViewPool render + takeSnapshot; every sibling (SVGRendererTests.renderProducesImage, MermaidRendererTests live cases, WebViewPoolSnapshotTests.captureSnapshot*) is .disabled("Requires full app context with window"). Verified on macOS only. On the iOS Simulator (make test, part of the pre-push checklist) attachToWindowHierarchy needs a foreground-active UIWindowScene via selectIOSWindow; on CI's macOS runner the offscreen NSWindow needs a window server. The author flags the CI half in the agent-note but not the iOS half, and neither is confirmed. | Not a code defect, so not applied (review is read-only). Before merge: run `make test` on the Simulator, and let the queued per-locale sweep for b3c46c85 finish. If iOS fails or flakes, wrap the two tests in #if os(macOS) (or a .disabled trait conditioned on iOS) with a tracking ticket rather than reverting to blanket .disabled. |
| minor | SVGRenderer / WebViewPool — no deinit, per-test WebKit teardown by ARC | Neither class has a deinit and the new tests never call renderer.clear(); each test leaves its renderer, pool, pooled WKWebView(s) and (macOS) offscreen NSWindow to ARC, with WebContent teardown asynchronous. Pre-existing, but this PR moves the suite from zero to six live renders, and the .liveWebKit budget exists because of exactly this accumulation (T-2219). | Suggest `defer { renderer.clear() }` in both new tests, or a `deinit { pool.clear() }` on SVGRenderer as a follow-up. Not applied. |
| minor | prismTests/SVGRendererTests.swift — cgImage(from:) duplicates an existing platform branch | The #if canImport(UIKit) image.cgImage / image.cgImage(forProposedRect:context:hints:) branch already exists in prism/Services/ImageCache.swift:26-35 (estimatedMemoryCost) and prismTests/ImageLoaderTests.swift:786 (ImageLoaderTestHelpers.pixelDimensions(of:)). This adds a third copy. | Hoist a single PlatformImage.cgImage accessor (test support or production) and use it from all three sites. Not applied. |
| minor | prismTests/SVGRendererTests.swift:119 — default: on a non-frozen enum | The switch over ColorScheme uses `default:` recording an Issue. Compiles clean and no SwiftLint rule objects, but ColorScheme is a non-frozen SwiftUI enum; `@unknown default:` would produce a compiler nudge if a case is added. | Use `@unknown default:`. Not applied. |
| nit | prismTests/SVGRendererTests.swift:98 — redundant expectation | #expect(lightPixel != darkPixel) is implied by the two threshold expectations before it (red>150/blue<100 vs blue>150/red<100 cannot be equal). | Harmless; drop or keep. Not applied. |
| nit | docs/agent-notes/webview-pool.md — correction appended, stale bullet left standing | The new section says the blanket `.disabled because it needs a window` story is stale for macOS, but the earlier bullet in the Testing list still states it as an absolute; a skimming reader meets the stale claim first. | Amend the earlier bullet in place with a pointer to the new section. Not applied. |
| nit | prism/Services/SVGRenderer.swift — unconditional appearance write per render | Setting overrideUserInterfaceStyle / appearance to an unchanged value may still trigger a prefers-color-scheme re-evaluation. Negligible against the render's existing sleeps, JS round trips and snapshot. | No change recommended; recorded so the trade-off is visible. |
Source: local run at 2026-09-06T02:05:00+10:00 · snapshot b3c46c853b1ab2ddfe24b6cffc31d45099ecd726
Baseline: none
Execution: passed · JUnit: none · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/SVGRenderer.swift b/prism/Services/SVGRenderer.swiftindex ddef7d71..aa5ca1c5 100644--- a/prism/Services/SVGRenderer.swift+++ b/prism/Services/SVGRenderer.swift@@ -3,6 +3,12 @@ import WebKit import OSLog import SwiftUI +#if os(iOS)+import UIKit+#elseif os(macOS)+import AppKit+#endif+ /// Renders SVG content to rasterized images using WKWebView. /// /// Delegates pool management to `WebViewPool` and the timeout race, JSON-encoded@@ -70,6 +76,7 @@ final class SVGRenderer { ) { @MainActor in try await self.renderInternal( source: source,+ colorScheme: colorScheme, displayScale: displayScale, containerWidth: containerWidth )@@ -85,12 +92,20 @@ final class SVGRenderer { private func renderInternal( source: String,+ colorScheme: ColorScheme, displayScale: CGFloat, containerWidth: CGFloat, retryCount: Int = 0 ) async throws -> PlatformImage { do { return try await pool.withWebView { webView, _ in+ // Force the WebView's own appearance to the REQUESTED colorScheme+ // before every render, not just on creation — the WebView is+ // pooled and reused across calls that may request different+ // schemes, and its default appearance otherwise follows the+ // hosting window/system, not what Prism asked to render (T-1896).+ Self.applyColorScheme(colorScheme, to: webView)+ // Load template if this WebView hasn't successfully loaded it yet. // Uses ObjectIdentifier tracking rather than isNew alone, because // isNew only indicates creation — a prior template load could have@@ -157,13 +172,13 @@ final class SVGRenderer { } } catch let error as WebViewPoolError where error == .processTerminated { return try await retryAfterTermination(- source: source, displayScale: displayScale,+ source: source, colorScheme: colorScheme, displayScale: displayScale, containerWidth: containerWidth, retryCount: retryCount ) } catch let error as WKError where error.code == .webContentProcessTerminated { // Process terminated during renderer-owned JS evaluation (Decision 8) return try await retryAfterTermination(- source: source, displayScale: displayScale,+ source: source, colorScheme: colorScheme, displayScale: displayScale, containerWidth: containerWidth, retryCount: retryCount ) } catch let error as WebViewPoolError {@@ -172,7 +187,7 @@ final class SVGRenderer { } private func retryAfterTermination(- source: String, displayScale: CGFloat,+ source: String, colorScheme: ColorScheme, displayScale: CGFloat, containerWidth: CGFloat, retryCount: Int ) async throws -> PlatformImage { if retryCount == 0 {@@ -181,13 +196,29 @@ final class SVGRenderer { templateReady.removeAll() Self.logger.info("Retrying SVG render after process termination") return try await renderInternal(- source: source, displayScale: displayScale,+ source: source, colorScheme: colorScheme, displayScale: displayScale, containerWidth: containerWidth, retryCount: 1 ) } throw SVGRenderError.retryFailed } + /// Forces `webView`'s effective appearance to match the requested `colorScheme`,+ /// overriding whatever the hosting window/system currently reports.+ ///+ /// This is what makes CSS `@media (prefers-color-scheme: dark)` inside the+ /// SVG source resolve to the appearance Prism asked to render rather than the+ /// offscreen render window's (macOS) or the app's (iOS) actual appearance.+ /// Must run on EVERY render, not just WebView creation — the pool reuses+ /// WebViews across calls that may request a different scheme each time (T-1896).+ private static func applyColorScheme(_ colorScheme: ColorScheme, to webView: WKWebView) {+ #if os(iOS)+ webView.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light+ #elseif os(macOS)+ webView.appearance = NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua)+ #endif+ }+ // MARK: - Dimension Extraction /// Pass-1 JS: read intrinsic SVG dimensions (attributes → viewBox → bounding rect).
diff --git a/prismTests/SVGRendererTests.swift b/prismTests/SVGRendererTests.swiftindex 06d6baaa..9109eca9 100644--- a/prismTests/SVGRendererTests.swift+++ b/prismTests/SVGRendererTests.swift@@ -58,6 +58,70 @@ struct SVGRendererTests { #expect(image.size.height > 0) } + // MARK: - Color Scheme (T-1896)++ /// SVG whose fill responds to `prefers-color-scheme`: red in light, blue in+ /// dark. Used to prove the RASTERIZED output follows the colorScheme Prism+ /// requested, not whatever appearance the offscreen render window/host+ /// happens to report.+ private static let adaptiveFillSource = """+ <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">+ <style>+ rect { fill: #ff0000; }+ @media (prefers-color-scheme: dark) {+ rect { fill: #0000ff; }+ }+ </style>+ <rect x="0" y="0" width="40" height="40"/>+ </svg>+ """++ @Test("Light and dark renders of an adaptive SVG produce different pixel colors (T-1896)")+ func lightAndDarkRendersResolveDifferentColors() async throws {+ let renderer = SVGRenderer()++ let lightImage = try await renderer.render(+ source: Self.adaptiveFillSource, colorScheme: .light, displayScale: 1.0+ )+ let darkImage = try await renderer.render(+ source: Self.adaptiveFillSource, colorScheme: .dark, displayScale: 1.0+ )++ let lightPixel = try Self.centerPixelColor(of: lightImage)+ let darkPixel = try Self.centerPixelColor(of: darkImage)++ // Before the fix, `colorScheme` was never forwarded to the render, so+ // both requests resolved `prefers-color-scheme` against the offscreen+ // render window's actual appearance and produced IDENTICAL pixels.+ #expect(lightPixel.red > 150 && lightPixel.blue < 100, "expected light render to resolve the red fill, got \(lightPixel)")+ #expect(darkPixel.blue > 150 && darkPixel.red < 100, "expected dark render to resolve the blue fill, got \(darkPixel)")+ #expect(lightPixel != darkPixel)+ }++ @Test("Requested color scheme is re-applied on every render, not just WebView creation (T-1896)")+ func colorSchemeAppliesAcrossPooledReuse() async throws {+ // maxConcurrent: 1 forces the same pooled WebView to be reused for+ // every call below, pinning that the appearance override happens on+ // EACH render rather than only when the WebView is first created.+ let renderer = SVGRenderer(maxConcurrent: 1)++ for scheme: ColorScheme in [.light, .dark, .light, .dark] {+ let image = try await renderer.render(+ source: Self.adaptiveFillSource, colorScheme: scheme, displayScale: 1.0+ )+ let pixel = try Self.centerPixelColor(of: image)++ switch scheme {+ case .light:+ #expect(pixel.red > 150 && pixel.blue < 100, "expected red fill for .light, got \(pixel)")+ case .dark:+ #expect(pixel.blue > 150 && pixel.red < 100, "expected blue fill for .dark, got \(pixel)")+ default:+ Issue.record("Unexpected scheme \(scheme)")+ }+ }+ }+ // MARK: - Timeout @Test("Render timeout after specified duration",@@ -285,4 +349,60 @@ struct SVGRendererTests { Issue.record("Expected renderFailed, got \(result)") } }++ // MARK: - Pixel Sampling (T-1896)++ private struct PixelColor: Equatable, CustomStringConvertible {+ let red: UInt8+ let green: UInt8+ let blue: UInt8+ let alpha: UInt8++ var description: String { "rgba(\(red), \(green), \(blue), \(alpha))" }+ }++ /// Extracts the underlying `CGImage` from a rasterized `PlatformImage`.+ private static func cgImage(from image: PlatformImage) -> CGImage? {+ #if canImport(UIKit)+ return image.cgImage+ #else+ return image.cgImage(forProposedRect: nil, context: nil, hints: nil)+ #endif+ }++ /// Reads the color of the pixel at the image's midpoint. Crops the source+ /// image down to that single pixel (in the image's own top-left-origin+ /// pixel space) then redraws it into a fresh RGBA8 context, so the result+ /// doesn't depend on the source's own byte order/premultiplication.+ private static func centerPixelColor(of image: PlatformImage) throws -> PixelColor {+ let cg = try #require(Self.cgImage(from: image), "Failed to extract CGImage from rendered snapshot")+ return try #require(+ Self.pixelColor(in: cg, x: cg.width / 2, y: cg.height / 2),+ "Failed to sample center pixel"+ )+ }++ private static func pixelColor(in cgImage: CGImage, x: Int, y: Int) -> PixelColor? {+ guard x >= 0, y >= 0, x < cgImage.width, y < cgImage.height,+ let cropped = cgImage.cropping(to: CGRect(x: x, y: y, width: 1, height: 1)) else {+ return nil+ }++ var pixel: [UInt8] = [0, 0, 0, 0]+ let colorSpace = CGColorSpaceCreateDeviceRGB()+ guard let context = CGContext(+ data: &pixel,+ width: 1,+ height: 1,+ bitsPerComponent: 8,+ bytesPerRow: 4,+ space: colorSpace,+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue+ ) else {+ return nil+ }+ context.draw(cropped, in: CGRect(x: 0, y: 0, width: 1, height: 1))++ return PixelColor(red: pixel[0], green: pixel[1], blue: pixel[2], alpha: pixel[3])+ } }
diff --git a/docs/agent-notes/webview-pool.md b/docs/agent-notes/webview-pool.mdindex bccf6a2f..7b2c530a 100644--- a/docs/agent-notes/webview-pool.md+++ b/docs/agent-notes/webview-pool.md@@ -60,3 +60,25 @@ WebViewPool is fully implemented. `SVGRenderer` and `BackgroundDiagramRenderer` - `UIWindowHostStateTests` (iOS-only, runs under `make test` on the Simulator — not `test-quick`) pins the real `UIWindow`/`UIWindowScene` derivation `MockAttachedHostState` cannot reach - `WindowAttachmentWiringTests` (`prismTests/WindowAttachmentWiringTests.swift`) is a structural source-scan pin confirming both `#if os(iOS)` call sites still call `reattachIfNeeded` - Test file: `prismTests/WebViewPoolTests.swift`++### A live render CAN actually run under `make test-quick` (T-1896)++The blanket "`.disabled` because it needs a window" story above is stale for+macOS. `prismTests` is app-hosted (`TEST_HOST` = `prism.app`), and on macOS+`WebViewPool.attachToWindowHierarchy` never depends on the host app's own+window — it self-creates its own offscreen `NSWindow` (`createRenderWindow()`).+Measured directly (temporarily un-disabling `SVGRendererTests.renderProducesImage`+and running it under `-destination 'platform=macOS'`): it renders and snapshots+successfully, in ~2-3s, on a normal interactive Mac. `SVGRendererTests` now has+two ENABLED live-render tests (`lightAndDarkRendersResolveDifferentColors`,+`colorSchemeAppliesAcrossPooledReuse`) that do a real `SVGRenderer.render` ++pixel sample, no `.disabled`.++Two caveats before enabling more of these:+- Not verified on a headless CI runner (GitHub Actions macOS runner without an+ interactive window server) — if CI can't do it, that's why the rest of the+ suite chose `.disabled` rather than a stale assumption, and a newly-enabled+ test could flake there even though it's solid locally.+- iOS has no self-created window — `attachToWindowHierarchy` needs a real+ `UIWindowScene`, so the "requires full app context" framing likely still+ holds there.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..fdd55ef8 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SVG diagrams that adapt to light/dark mode via CSS `@media (prefers-color-scheme: dark)` now rasterize using the appearance Prism actually asked for, instead of whatever the offscreen render window (macOS) or app (iOS) happened to be reporting at the time (T-1896). `SVGRenderer` accepted a `colorScheme` parameter but never used it past the cache key, so a light-appearance SVG could render with its dark-mode colors — or vice versa — while being cached under the *other* scheme's key, compounding the mismatch on the next lookup. The renderer now forces its WebView's own appearance (`overrideUserInterfaceStyle` on iOS, `NSAppearance` on macOS) to match the requested scheme before every render, not only when the WebView is first created — the pool reuses WebViews across calls that can request a different scheme each time. - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.
diff --git a/specs/bugfixes/svg-renderer-color-scheme/report.md b/specs/bugfixes/svg-renderer-color-scheme/report.mdnew file mode 100644index 00000000..4eb3daea--- /dev/null+++ b/specs/bugfixes/svg-renderer-color-scheme/report.md@@ -0,0 +1,185 @@+# Bugfix Report: SVG Renderer Ignores Requested Light or Dark Appearance++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`SVGRenderer.render(source:colorScheme:displayScale:)` accepted a `colorScheme`+parameter and callers/cache keys treated light and dark renders as distinct, but+the renderer never actually used the value past validating its arguments — it+was dropped on the way into `renderInternal`. The SVG template also forced no+appearance of its own. An SVG whose CSS branches on+`@media (prefers-color-scheme: dark)` therefore rasterized according to the+*hosting* WebView/window's actual appearance rather than the appearance Prism+asked it to render.++On macOS this was especially visible because `WebViewPool` attaches renderer+WebViews to its own offscreen `NSWindow`, whose appearance tracks the system,+not Prism's forced appearance setting — forcing Prism to Dark while macOS stays+Light could produce a light-palette rasterized SVG, which was then cached under+the *dark* `SnapshotCache` key, compounding the mismatch on the next lookup.++**Reproduction steps:**+1. Use an SVG whose internal CSS selects different fills under+ `@media (prefers-color-scheme: dark)`.+2. Set macOS to Light and force Prism's appearance to Dark (or vice versa).+3. Open/render the SVG in Prism.+4. Observe the diagram rasterize with the wrong palette for the requested+ appearance, and get cached under the appearance's own cache key regardless.++**Impact:** Any markdown document containing an adaptive SVG image renders it+with the wrong color scheme whenever Prism's forced appearance differs from the+system's, on both platforms (worse and more visible on macOS due to the+`WebViewPool`'s persistent offscreen render window).++## Investigation Summary++- **Symptoms examined:** `SVGRenderer.render` signature already accepted+ `colorScheme`; traced its use through `renderInternal`, `retryAfterTermination`,+ and the JS dimension/injection helpers — none of them referenced it.+- **Code inspected:** `prism/Services/SVGRenderer.swift`,+ `prism/Resources/svg-template.html`, `prism/Services/WebViewPool.swift`,+ `prism/Services/SnapshotCache.swift`.+- **Hypotheses tested:** Confirmed `svg-template.html` has no `color-scheme`+ meta/CSS and no script forcing an appearance; confirmed `WebViewPool` never+ sets an appearance on the WebViews it creates or attaches, and that its+ macOS offscreen `NSWindow` is a bare `NSWindow` whose appearance simply+ tracks the system default.++## Discovered Root Cause++**Defect type:** Missing propagation — a parameter accepted by the public API+was never forwarded to the code path that could act on it.++**Why it occurred:** `render(source:colorScheme:displayScale:containerWidth:)`+validates the source size and debounces, then calls+`renderInternal(source:displayScale:containerWidth:retryCount:)` — a+parameter list that never included `colorScheme` in the first place. Because+`SnapshotCache.svgKey` already keys on `colorScheme` and the call sites already+pass it through to `render`, the API *looked* wired up end-to-end; only the one+missing forward inside `SVGRenderer` broke the chain.++**Contributing factors:** WKWebView's CSS `prefers-color-scheme` resolution+follows the *view's* effective appearance (`NSAppearance` on macOS,+`overrideUserInterfaceStyle`/trait collection on iOS), which is independent of+any HTML/CSS the page itself declares — so even a template author remembering+to add `color-scheme: light dark` to the stylesheet would not have made+`@media (prefers-color-scheme: dark)` resolve to Prism's forced setting; the+fix has to happen on the native side, at the WebView.++## Resolution for the Issue++**Changes made:**+- `prism/Services/SVGRenderer.swift` — `render` now forwards `colorScheme` to+ `renderInternal`, which forwards it to `retryAfterTermination` on the+ process-termination retry path. A new `applyColorScheme(_:to:)` static+ helper sets `webView.overrideUserInterfaceStyle` (iOS) or `webView.appearance`+ (macOS) to match the requested scheme, and is called on **every** render —+ right after the WebView is checked out of the pool, before the template load+ check and before content injection — so a pooled WebView reused for a+ different scheme on a later call is not left with a stale appearance.++**Approach rationale:** Forcing the WebView's own appearance directly is the+one mechanism that actually changes what `prefers-color-scheme` resolves to+inside the page; it requires no change to the SVG template or any script+injection, and running it unconditionally on every render (not only on WebView+creation) is what closes the pool-reuse case the ticket called out explicitly.++**Alternatives considered:**+- **Baking `color-scheme: light dark` into `svg-template.html`:** This affects+ UA styling of native form controls/scrollbars but does not change how+ `@media (prefers-color-scheme: dark)` resolves — it doesn't force an+ appearance, it just tells the UA the page supports both. Rejected as+ insufficient on its own.+- **Setting the appearance on the shared offscreen `NSWindow` instead of the+ WebView:** Rejected because `WebViewPool` can host up to `maxConcurrent`+ WebViews on the same window at once; setting the window's appearance would+ make one in-flight render's scheme leak into a concurrent render for a+ different scheme on another pooled WebView. Setting `.appearance` on the+ `WKWebView` itself is scoped to that view.++## Regression Test++**Test file:** `prismTests/SVGRendererTests.swift`+**Test names:**+- `lightAndDarkRendersResolveDifferentColors` — renders an adaptive SVG+ (red fill in light, blue fill in dark via `@media (prefers-color-scheme: dark)`)+ under `.light` and `.dark`, samples the resulting snapshot's center pixel, and+ asserts they differ and match the requested scheme's expected color.+- `colorSchemeAppliesAcrossPooledReuse` — renders the same adaptive SVG four+ times (`.light, .dark, .light, .dark`) through a renderer configured with+ `maxConcurrent: 1`, forcing the same pooled WebView to be reused for every+ call, and asserts each render resolves the color for the scheme requested+ *that* call — pinning that the appearance override applies per-render, not+ only at WebView creation.++**What it verifies:** That `SVGRenderer.render`'s rasterized output follows the+`colorScheme` argument rather than the host window/system appearance, including+across WebView pool reuse.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' -testPlan prism \+ -only-test-configuration "en (base)" -parallel-testing-worker-count 1 \+ "-only-testing:prismTests/SVGRendererTests/lightAndDarkRendersResolveDifferentColors()" \+ "-only-testing:prismTests/SVGRendererTests/colorSchemeAppliesAcrossPooledReuse()"+```++Both tests are genuine live-WebKit renders (not `.disabled`) — verified+directly that this app-hosted unit test target can actually perform a live+`SVGRenderer.render` + `WKWebView.takeSnapshot` on macOS (see+`docs/agent-notes/webview-pool.md`, "A live render CAN actually run under+`make test-quick`"). Confirmed red/green manually: reverting the production+fix (`git stash` on `SVGRenderer.swift` alone) made both new tests fail with+messages like `expected red fill for .light, got rgba(0, 0, 255, 255)`; restoring+the fix made them pass.++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/SVGRenderer.swift` | Forward `colorScheme` through `renderInternal`/`retryAfterTermination`; add `applyColorScheme(_:to:)` and call it on every render |+| `prismTests/SVGRendererTests.swift` | Add two live-WebKit regression tests plus a small pixel-sampling test helper |+| `docs/agent-notes/webview-pool.md` | Note that a live SVG render actually works under `make test-quick` on macOS, contrary to the blanket "requires full app context" assumption the rest of the suite documents |+| `CHANGELOG.md` | `[Unreleased]/Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`lightAndDarkRendersResolveDifferentColors`,+ `colorSchemeAppliesAcrossPooledReuse`)+- [x] Targeted `prismTests/SVGRendererTests` suite passes (21 total, 17 passed,+ 4 pre-existing `.disabled` skips, 0 failed)+- [x] `make lint`+- [x] `make verify-test-isolation`+- [x] `make build-macos`+- [x] `make build-ios`+- [ ] Full `make test-quick` / `make test` — not run; this machine has several+ other fix-bug agents running concurrent `xcodebuild` invocations, and+ per-project guidance a full run is unreliable under that contention.+ Targeted suites were run instead, with `Tools/check-test-results.sh`+ confirming non-zero, all-passing execution.++**Manual verification:**+- Confirmed via `git stash` that the two new regression tests fail against the+ pre-fix `SVGRenderer.swift` with the exact wrong-color symptom described in+ the ticket, and pass again once the fix is restored.++## Prevention++**Recommendations to avoid similar bugs:**+- When a rendering API accepts a parameter purely to compute a cache key+ (`SnapshotCache.svgKey(... colorScheme ...)`), check that the same parameter+ is *also* threaded through to the actual render call — a cache key can be+ fully correct while the thing it's keying is wrong.+- For any WKWebView-based renderer, remember `prefers-color-scheme` follows the+ WebView's own effective appearance, not anything declared in the page's own+ HTML/CSS — forcing it must happen on the native side (`overrideUserInterfaceStyle`+ / `NSAppearance`), and must be re-applied on every use of a pooled/reused+ WebView, not just at creation.++## Related++- Transit ticket: T-1896
Run make test before merge and watch the queued per-locale sweep for b3c46c85. These are the first pool-mediated live snapshots the suite has ever executed outside a .disabled trait.
On a git archive export of HEAD: make lint passed, make verify-test-isolation passed, and xcodebuild test -destination 'platform=macOS' -only-testing:prismTests/SVGRendererTests -only-testing:prismTests/WebViewPoolTests passed — Tools/check-test-results.sh reports total=28 passed=17 failed=0 skipped=11, with Light and dark renders of an adaptive SVG produce different pixel colors and Requested color scheme is re-applied on every render both Passed (~1 s each). The iOS Simulator run was skipped on instruction. No JUnit emitter exists for this Xcode project, so the Tests card carries no structured data. Log: /Users/arjen/.claude/jobs/280f6453/review-inputs/macos-test.log.
Checked: applyColorScheme is synchronous and precedes injectInnerHTML, whose evaluateJavaScript is an out-of-process await, followed by 100 ms + 50 ms reflow sleeps before the snapshot. The second test's light/dark/light/dark sequence on one pooled view is the empirical pin.