PR #321. Restores the OS Increase Contrast setting inside WebKit-rendered documents — a regression opened by the T-1542 rendering cutover, which moved the document body out of the SwiftUI view tree that carried HighContrastThemeColors.
applyTheme rather than getting its own command, so a theme key can never be paired with another theme's overrides — the override values are per-theme, so the two are genuinely not independent.variables:-overlay approach would have had, and it fails silently in the bad direction.DocumentCSSContrastRulesTests pins them together on RGB and alpha — the alpha axis added in bf28c3b closes the gap where an 85% → 8.5% slip would have kept the same channels and silently restored the translucency the ticket exists to remove.document.css as .prism-add-note and was still faded. Now overridden, tested, and recorded.compactMap, so a malformed colour yielded a short array that zip then compared as a prefix — passing on fewer than three channels.Ready to push
The design is right and the implementation matches it. Native pushes the contrast state on the existing applyTheme command; document.css owns the per-theme values — the same split the base palette already uses. All 25 colour cells match HighContrastThemeColors and the spec table exactly, on both the RGB and the alpha axis. Both platform builds pass with no new warnings, SwiftLint and stylelint are clean, and the targeted suites are green.
Review raised one major gap — the spec's sixth requirement (the deliberately-dimmed add-note affordance) was silently unmet on the web path — plus a missing decision-log record for three genuine design choices. Both are fixed in this review, along with a real weakening in the new CSS-guard test and two duplicated test helpers. Nothing outstanding blocks the merge; the remaining items are documented trade-offs, not defects.
One logistics note: the branch's merge commit merged an earlier origin/main. PR #320 (a12e6ca) landed afterwards and is not in the branch, so a rebase or merge is needed before the squash.
71d251f T-1829: Apply Increase Contrast to web-rendered documents bf28c3b T-1829: Guard contrast alpha and require explicit contrast on push seams ad4b689 Merge origin/main into the branch working-tree Fixes applied in this review iOS and macOS have an accessibility setting called Increase Contrast. When you turn it on, apps are supposed to swap washed-out colours for stronger ones. Prism did that for its own buttons and menus — but not for the actual document you were reading. Search highlights stayed see-through, footnote badges stayed pale, and the faintest tier of grey text stayed faint.
This change makes the document obey the setting too, on all five themes, and it happens instantly — you don't have to close and reopen the file, and you don't lose your place on the page.
Prism used to draw documents with Apple's own UI toolkit. Colours flowed down an invisible "environment" pipe that every part of the screen could read from, and an earlier ticket had plugged the high-contrast colours into that pipe.
Then Prism switched to drawing documents in a web view — essentially a small embedded browser. The document is now a web page, and a web page cannot see into that pipe. So the high-contrast colours simply stopped arriving.
The document surface is a WebPage served over a custom prism-doc:// scheme with JavaScript disabled for page content; all scripts are injected as user scripts, and the native↔JS bridge runs in an isolated WKContentWorld behind a generation-tagged allowlist. Native remains the source of truth; the page only renders and reports back.
The palette already crossed that bridge as state: applyTheme sends a theme key, prism-theme.js writes it to data-prism-theme on <html>, and document.css holds a :root[data-prism-theme=…] custom-property block per theme. This change extends exactly that shape with a second axis:
WebContrastMode (.standard / .increased) is added to the applyTheme case in OutboundBridgeCommand.DocumentScrollContent reads @Environment(\.colorSchemeContrast) and pairs it with the colorScheme-resolved theme key in a small Equatable value, WebPaletteFeed..onChange(of: paletteFeed) replaces the previous .onChange(of: settings.theme(for: colorScheme)), so both inputs push together.prism-theme.js mirrors it onto data-prism-contrast; five :root[data-prism-contrast="increased"][data-prism-theme=…] blocks in document.css carry the values.One command, not two. A separate applyContrast command would be a cleaner API surface, but the override values are per theme — prism-dark's high-contrast search highlight is not prism-light's. A split push therefore has a frame in which a newly-selected theme still wears the previous theme's overrides. Bundling them makes the pair atomic by construction, and it comes for free through the existing coalesced-snapshot replay after a WebContent-process recovery.
Tuple → struct. WebDocumentStateSnapshot.theme was a 4-member tuple with a hand-written Equatable that compared fields one by one. Adding a fifth field to that would have meant remembering to update the comparison — and forgetting would have made a contrast-only change compare equal, silently suppressing the push. Promoting it to a ThemePush struct gets the synthesised conformance and deletes 14 lines.
No default on contrast. The parameter is deliberately un-defaulted on all three push seams. A = .standard default is precisely the shape of the bug being fixed: a call site that drops the contrast state and still compiles.
The five override values now exist in two places — HighContrastThemeColors.swift and document.css. That is inherent to the native-state/CSS-values split, and it is mitigated rather than removed: DocumentCSSContrastRulesTests parses the stylesheet and compares every value against the native wrapper on both channels and alpha, parameterised over PrismTheme.allCases.
variables: overlay was the wrong toolapplyTheme already carries a variables: [String: String] overlay that prism-theme.js applies via root.style.setProperty. Shipping the five resolved HighContrastThemeColors values through it would have eliminated the Swift/CSS duplication outright and made the wrapper the single source of truth for both surfaces. It was rejected, correctly, on revert semantics: inline styles on the root element must be explicitly cleared, and applyVariables has no removal path. Turning the setting off would require pushing an "undo" set, and any token missed from that set persists — a failure that is invisible until someone notices a stale colour. Attribute removal has no such asymmetry; the cascade does the work.
The residual cost is honest and now recorded: searchMatchBackground, searchMatchCurrentBackground, footnoteBadgeBackground and footnoteBadgeForeground have zero native consumers outside prism/Theme/ after the cutover. Four fifths of the Swift "source of truth" now renders nothing itself and exists purely as the reference the CSS is diffed against.
Each per-theme block is (0,3,0) against the base blocks' (0,2,0), so it wins on specificity irrespective of source order. The grouped attribute-less arm — :root[data-prism-contrast="increased"] — is (0,2,0), a tie with the base theme blocks, and the original comment overstated this. It is also unreachable in practice: data-prism-contrast is written only by applyTheme, which sets data-prism-theme from the same payload, and production always sends a non-empty theme key. It survives as structural parallelism with the base palette's :root, :root[data-prism-theme="prism-light"] grouping; the comment and its pinning test now say so rather than claiming a live code path.
.onChange firing semanticsWorth verifying rather than assuming, because the key changed from an enum to a computed struct. Both old and new values are computed inside body, so Observation dependency registration on context.settings is unchanged, and @Environment(\.colorSchemeContrast) invalidates body exactly as colorScheme does. No initial: was set before and none now — the first push still comes solely from the .task, which reads the same paletteFeed with no await ahead of it. Firing count for theme-only changes is identical; the only added firings are contrast toggles, which is the point.
WebContrastMode(_ contrast: ColorSchemeContrast) uses contrast == .increased ? .increased : .standard rather than an exhaustive switch. That is the right shape for a non-frozen SwiftUI enum, and the fallback direction is the safe one — an unknown future case degrades to the standard palette rather than forcing high contrast on. The extension lives in a Views file, which keeps SwiftUI out of WebBridgeContract.swift; that file is deliberately Foundation-only, so this is the correct placement, not a layering violation.
snapshot.apply + coalescedCommands()); the full test_markReady → terminate → pendingCommands path is exercised only with .standard. Low risk, since the replay is field-agnostic once ThemePush synthesises Equatable.applyTheme unconditionally dispatches prism:retheme, and the mermaid driver responds by clearing data-prism-rendered on every rendered container and re-rendering. Contrast toggles now trigger that for no visual benefit. Rare event; diagrams consume none of the five tokens; guarding would add state to prism-theme.js for a saving nobody will observe. Recorded as Q5.FootnotePopoverWebPage has no bridge — it bakes RenderSettings.theme into the emitted HTML — and receives no contrast state. Its content is restricted to paragraphs, lists and blockquotes, which emit none of the five tokens, so the gap is currently unobservable. Recorded as Q4 and carved out of the CHANGELOG's "on every theme" framing..prism-footnote-badge[data-prism-search-current] pairs --prism-footnote-badge-fg against --prism-search-current, landing near 3.2:1 under increased contrast on light themes — below the spec's own ≥4.5:1 bar. The base palette's equivalent pairing is ≈3.25:1, so this branch neither improves nor regresses it. Separate ticket if anyone cares.prism/ViewModels/WebBridgeContract.swift
Why it matters. This is the load-bearing design decision. The override values are per-theme, so theme key and contrast variant are not independent — a split push would have a frame pairing one theme's palette with another's overrides. Bundling makes the pair atomic and inherits the coalesced-snapshot replay for free.
What to look at. WebBridgeContract.swift:161-194 — new WebContrastMode enum and the widened applyTheme case
prism/Resources/WebRenderer/document.css
Why it matters. The alternative — pushing the five resolved colours through applyTheme's existing `variables` overlay — would have removed the Swift/CSS duplication entirely. It was rejected on revert semantics, and that reasoning is the most interesting judgement call in the branch.
What to look at. document.css:214-268 — five per-theme increased-contrast blocks; prism-theme.js:32-38 — the attribute write/remove
prism/ViewModels/WebDocumentController.swift
Why it matters. Not cosmetic. The hand-written Equatable it deletes compared tuple fields one at a time; adding `contrast` without updating it would have made a contrast-only change compare equal, silently suppressing the push and the recovery replay — reintroducing the exact bug being fixed, one layer down.
What to look at. WebDocumentController.swift:498-530 — ThemePush struct replacing the tuple and the 14-line static ==
prism/Views/DocumentScrollContent.swift
Why it matters. Two separate observers over theme and contrast could interleave and push a mismatched pair. Grouping them into one Equatable value makes 'always pushed together' a property of the type rather than a discipline the next contributor has to remember.
What to look at. DocumentScrollContent.swift:53-56, 147-152, 308-343 — the environment read, the single .onChange, WebPaletteFeed and the ColorSchemeContrast mapping
prism/Resources/WebRenderer/document.css
Why it matters. The smolspec requires Increase Contrast to stop dimming the add-note affordance. It was written against AccessibleTableView's .opacity(0.4); the cutover retired that view and moved the affordance into document.css as .prism-add-note (0.35, and 0.5 on touch). The branch's Addendum restored five of six requirements and was silent about this one — a dimmed control is precisely what the setting exists to undo.
prismTests/WebRendering/DocumentCSSContrastRulesTests.swift
Why it matters. The hex branch of channels(fromCSS:) ended in compactMap, which drops unparseable pairs rather than failing. A malformed colour yielded a short array, #require accepted it, and the caller's zip then compared only the surviving prefix — a silently weakened assertion in the one test holding two copies of the palette together.
The override values are per-theme, so the theme key and its contrast variant must change atomically. A split push has a frame in which a newly-selected theme still wears the previous theme's overrides. Bundling also means the coalesced snapshot replays both after a WebContent recovery with no extra work. Follows the precedent of specs/webview-rendering/decision_log.md Decision 15, which grew applyTheme with mermaidConfig on the same reasoning.
The rejected alternative — shipping the five resolved colours through applyTheme's existing variables overlay — would have removed the Swift/CSS duplication outright. It loses on revert semantics: applyVariables writes inline styles on the root element and has no removal path, so turning the setting off needs an explicit undo set, and any token missed from it persists invisibly. Attribute removal restores the cascade exactly. Recorded as Decision 4 in this review, including the negative consequence that four of the five tokens now have no native consumer outside prism/Theme/.
A silently-.standard default is the exact shape of the regression being fixed — a push path that keeps compiling while dropping the state. Cost is churn across call sites and six test files, paid once. Noted asymmetry: mermaidConfig: String = "" sits on the same signature with a default, and the same argument would apply to it. Recorded as Q2.
Tuples have no synthesised Equatable. The hand-written == being deleted would have silently ignored a newly-added field, making a contrast-only change compare equal and suppressing both the push and the recovery replay. Recorded as Q3.
FootnotePopoverWebPage has no bridge — it bakes RenderSettings.theme into the emitted HTML — so contrast would have to be threaded through RenderSettings and BlockHTMLEmitter. Its content is restricted to paragraphs, lists and blockquotes, none of which consume the five overridden tokens, so the gap is unobservable today. Recorded as Q4 and carved out of the CHANGELOG. Revisit if the popover ever renders badges, comments or search highlights.
prism-theme.js dispatches prism:retheme on every applyTheme, and prism-mermaid.js responds by clearing data-prism-rendered and re-rendering every diagram. Contrast toggles now trigger that for no visual benefit. Guarding would add state to the theme script to save work on a rarely-toggled accessibility setting, and diagrams consume none of the overridden tokens — wasteful but correct. Recorded as Q5; a possible follow-up, deliberately not fixed here.
Recent bug tickets in this repo ship a specs/bugfixes/<name>/report.md. This one restores the same requirements the existing spec already states, against a surface that moved — splitting the token table across two documents would create exactly the drift the addendum exists to prevent. Recorded as Q1 so the departure from convention is a choice on the record rather than an omission.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | document.css — add-note affordance | The smolspec's sixth requirement (Increase Contrast must stop dimming the empty-state add-note glyph) was silently unmet. It was written against AccessibleTableView's .opacity(0.4); the cutover retired that view and moved the affordance into document.css as .prism-add-note (opacity 0.35, and 0.5 under @media (hover: none)). Neither was lifted under increased contrast, and the branch's Addendum restored five of six requirements without mentioning the sixth. | Added :root[data-prism-contrast="increased"] .prism-add-note { opacity: 1; } — at (0,3,0) it outranks both the base rule and the touch variant, which are each (0,1,0). Added addNoteAffordanceIsOpaqueUnderIncreasedContrast to the CSS guard suite, documented the requirement's relocation in the smolspec Addendum, marked the superseded tasks in tasks.md, and extended the CHANGELOG entry. |
| major | specs/a11y-increase-contrast/decision_log.md | Three decisions with genuine rejected alternatives and genuine consequences were argued only in code comments, with no decision-log entry: values-in-CSS vs. the existing `variables` overlay, riding on applyTheme vs. a separate command, and the un-defaulted `contrast` parameter. The repo's own precedent (webview-rendering Decision 15, which recorded the identical trade-off when applyTheme grew mermaidConfig) says these earn entries. | Added full Enhanced-Nygard Decision 4 for the state/values split (including the negative that four of five tokens now have no native consumer), plus a Quick Decisions table with Q1–Q5 covering the addendum-vs-bugfix-report choice, the un-defaulted parameter, the ThemePush refactor, the popover carve-out and the mermaid re-render. Table placed under the file header per the project's decision-log format. |
| major | DocumentCSSContrastRulesTests — hex parser | channels(fromCSS:) ended its hex branch in compactMap, which drops unparseable pairs instead of failing. A malformed colour produced a 2-element array that #require accepted, after which the caller's zip compared only the surviving prefix — a silently weakened assertion in the very test that holds the Swift and CSS copies of the palette together. The rgb() branch beside it already guarded on count. | Added the matching count guard so all three channels are required, with a comment naming the failure mode. |
| major | DocumentCSSContrastRulesTests — ruleBody helper | The new suite forked a weakened copy of a helper that already exists twice in prismTests/WebRendering/ (DocumentCSSTableRulesTests, DocumentCSSCodeBlockRulesTests). The originals loop over matches and reject a selector hit with a '}' between it and the next '{'; the fork took the first brace pair after the first textual match unconditionally, so a fragment occurring inside a prior rule body — or inside the section comment the branch adds directly above these blocks — would silently yield the wrong body. | Hardened the new copy to the siblings' implementation. Consolidating all three into a shared DocumentCSSTestSupport is the fuller fix but would touch two test files unrelated to this branch; left as a recommended follow-up rather than scope creep in a bugfix PR. |
| minor | DocumentCSSContrastRulesTests — duplicated alpha helper | nativeAlpha(_:) re-implemented ContrastChecker.alphaComponent (prismTests/WCAGContrastTests.swift), which already does the same cross-platform UIKit/AppKit sRGB resolution and is already consumed from another file. | Replaced with the existing helper; dropped the now-unnecessary #if os(iOS)/UIKit and AppKit imports. |
| minor | document.css — comment accuracy | Two claims in the section comment were wrong. 'Each block carries BOTH attributes so it outranks the matching base theme block regardless of source order' is false for the grouped attribute-less arm, which is (0,2,0) — a tie with the base blocks, decided by order. And the stated reason for that arm ('so a document that has not yet received an applyTheme still picks the overrides up') is self-contradictory: data-prism-contrast is written only by applyTheme, which sets data-prism-theme from the same payload. | Rewrote the comment with the actual specificities and an honest justification — the arm is defensive/structural parallelism with the base palette's grouping, not a reachable code path. Corrected the matching claim in the bareRootContrastBlockExists test comment and renamed the test accordingly. |
| minor | DocumentCSSContrastRulesTests — stringly-typed selector | The contrast selector hardcoded "increased" while correctly deriving the theme key from PrismTheme.rawValue. A rename of WebContrastMode.increased's raw value would leave Swift and JS agreeing on a new string while the CSS and this test agreed on the old one — caught only by the slowest live-page test. | Derived from WebContrastMode.increased.rawValue in both the per-theme selector helper and the bare-root test. |
| minor | CLAUDE.md | Line 53 documented the assembly as '(theme alone is view-fed, via applyTheme(themeKey:), because it depends on colorScheme)'. The signature is now applyTheme(themeKey:contrast:) and the view world feeds two inputs, grouped as WebPaletteFeed. | Reworded to the current signature and the palette-feed grouping, with the ticket reference. |
| minor | specs/a11y-increase-contrast/tasks.md, implementation.md | tasks.md still instructs editing prism/Views/AccessibleTableView.swift (deleted) and adding prismTests/TextualThemeAdapterTests.swift (the adapter no longer exists) — directly contradicting the Addendum the branch itself wrote. Pre-existing staleness from the cutover, but the branch touched the sibling document without reconciling it. | Added a status note marking tasks 3 and 4 superseded by the T-1542 cutover, pointing at where the add-note behaviour moved and at the Addendum. implementation.md left as the T-1045 historical record. |
| minor | prism-theme.js / prism-mermaid.js | applyTheme dispatches prism:retheme unconditionally, and the mermaid driver responds by clearing data-prism-rendered on every rendered container and re-rendering. Because contrast now rides on applyTheme, toggling Increase Contrast re-renders every diagram — none of whose colours the five overrides touch. On a diagram-heavy document that is hundreds of ms of pointless page-world work. | Not fixed. The event is rare (an accessibility setting), the re-render is correct if wasteful, and guarding means adding state to prism-theme.js on a path with existing tests — disproportionate risk for the saving. Recorded as decision-log Q5 as a candidate follow-up. |
| minor | FootnotePopoverView / RenderSettings | The footnote popover page has no bridge and bakes RenderSettings.theme into the emitted HTML. It receives no contrast state, so it renders the base palette under Increase Contrast — the one emitter surface the fix does not reach, and the CHANGELOG's 'on every theme' framing did not carve it out. | Not fixed. Popover content is restricted to paragraphs, lists and blockquotes, which emit none of the five overridden tokens, so the gap is currently unobservable. Documented in the smolspec Addendum, recorded as decision-log Q4, and the CHANGELOG now states popovers keep the standard palette. |
| minor | DocumentCSSContrastRulesTests — repeated file reads | loadDocumentCSS() is called per test case and normalized() runs a full-file regex per ruleBody lookup: 16 bundle reads and 21 regex passes over the same 37 KB string, all producing an identical result. | Skipped. The whole suite runs in ~0.07s per case; caching would be tidier but changes nothing observable, and the natural home for the fix is the shared-helper consolidation noted above. |
| nit | document.css — near-duplicate blocks | prism-dark and classic-dark are byte-identical; classic-light differs from prism-light only in --prism-text-tertiary. HighContrastThemeColors — the file this mirrors — already groups them (case .prismDark, .classicDark). | Skipped. The five base theme blocks are one-per-theme and this section deliberately parallels them; grouping would break that symmetry to save 10 declarations, and the anti-drift test already makes divergence loud. |
| nit | WebDocumentControllerFactory.pushInitialState | The function has no production caller — its only call site anywhere is HTMLCommentVisibilityLiveTests. The branch adds a required contrast: parameter plus a four-line rationale about 'every caller has to state its intent', which is guarding dead code. | Skipped. Pre-existing dead code since the T-1719 synchronizer took over; removing it is a separate cleanup, and the un-defaulted parameter is harmless and consistent with the two live seams. |
| nit | WebPaletteFeed visibility | Its two siblings in the same file, SearchStateKey and WebLoadKey, fill the same role (Equatable identity for an .onChange trigger) and are private nested types. WebPaletteFeed is file-scope and internal, apparently only so a test asserting synthesised Equatable on a two-field struct can reach it. | Skipped. Cosmetic, and the test does document the contrast-only-change requirement even if the assertion is thin. |
| nit | Branch is behind origin/main | The branch's merge commit merged an earlier origin/main; PR #320 (a12e6ca) landed afterwards and is not in the branch. The merge base is 6e2a112, not origin/main's tip. | Not a code issue. Rebase or merge origin/main before the squash-merge. No conflict is expected — the two changes touch disjoint files apart from the CHANGELOG. |
Click to expand.
diff --git a/prism/ViewModels/WebBridgeContract.swift b/prism/ViewModels/WebBridgeContract.swiftindex 90be817..d9b5b1d 100644--- a/prism/ViewModels/WebBridgeContract.swift+++ b/prism/ViewModels/WebBridgeContract.swift@@ -158,18 +158,40 @@ enum InboundMessageType: String, CaseIterable, Sendable { // MARK: - Outbound (native → JS) commands +/// The system Increase Contrast state, as the rendered document sees it (T-1829).+///+/// Native reads `Environment(\.colorSchemeContrast)` and pushes this with the theme;+/// prism-theme.js maps it onto the `data-prism-contrast` attribute and `document.css`+/// holds the per-theme override values — the same native/CSS split the base palette+/// uses, so only the STATE crosses the bridge.+enum WebContrastMode: String, Equatable, Sendable {+ case standard+ case increased+}+ /// A native→JS command. Each maps to one bridge function the controller invokes /// via `callJavaScript(in: bridgeWorld)`. Commands queue until the page posts /// `ready` for the current generation (design `load` ordering contract). enum OutboundBridgeCommand: Equatable, Sendable {- /// `theme` selects the CSS `data-prism-theme` block; `variables` overlays explicit- /// CSS custom properties; `mermaidConfig` is the per-theme mermaid config as a JSON+ /// `theme` selects the CSS `data-prism-theme` block; `contrast` selects the matching+ /// `data-prism-contrast` overrides (T-1829); `variables` overlays explicit CSS custom+ /// properties; `mermaidConfig` is the per-theme mermaid config as a JSON /// string (theme name + themeVariables, from MermaidThemeConfig.toJavaScriptConfig()). /// prism-theme.js forwards it on the `prism:retheme` DOM event; the page-world mermaid /// script JSON-parses it and re-renders diagrams in the new theme without a reload /// (Req 1.3). Empty string when no per-theme mermaid config applies (classic themes /// keep mermaid's built-in theme via the baseline config).- case applyTheme(theme: String, variables: [String: String], mermaidConfig: String)+ ///+ /// Contrast rides on this command rather than getting its own so the theme key and+ /// its high-contrast variant are applied atomically — the override values are+ /// per-theme, so a split push could momentarily pair one theme's palette with+ /// another's overrides.+ case applyTheme(+ theme: String,+ contrast: WebContrastMode,+ variables: [String: String],+ mermaidConfig: String+ ) case applyTypography(variables: [String: String]) case setCommentVisibility(Bool) case setSearchState(json: String)
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex 2dcab1b..7d45300 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -283,8 +283,13 @@ final class WebDocumentController { /// The JS-passable payload for a command (numbers/strings/arrays/dicts only). static func payload(for command: OutboundBridgeCommand) -> [String: Any] { switch command {- case .applyTheme(let theme, let variables, let mermaidConfig):- return ["theme": theme, "variables": variables, "mermaidConfig": mermaidConfig]+ case .applyTheme(let theme, let contrast, let variables, let mermaidConfig):+ return [+ "theme": theme,+ "contrast": contrast.rawValue,+ "variables": variables,+ "mermaidConfig": mermaidConfig,+ ] case .applyTypography(let variables): return ["variables": variables] case .setCommentVisibility(let visible):@@ -312,8 +317,20 @@ final class WebDocumentController { // MARK: - Named native→JS commands (design WebDocumentController interface) - func applyTheme(theme: String, variables: [String: String], mermaidConfig: String = "") {- send(.applyTheme(theme: theme, variables: variables, mermaidConfig: mermaidConfig))+ /// Pushes the palette: the theme key plus the system Increase Contrast state+ /// (T-1829), which selects the stylesheet's per-theme high-contrast overrides.+ ///+ /// `contrast` is un-defaulted so no push path can drop it silently; every caller+ /// states whether the document is rendering under Increase Contrast.+ func applyTheme(+ theme: String,+ contrast: WebContrastMode,+ variables: [String: String],+ mermaidConfig: String = ""+ ) {+ send(.applyTheme(+ theme: theme, contrast: contrast, variables: variables, mermaidConfig: mermaidConfig+ )) } func applyTypography(variables: [String: String]) {@@ -481,7 +498,17 @@ struct WebUserScript: Sendable { /// than a queued event history. Each setter overwrites the previous value, so a /// burst of theme changes before `ready` collapses to one applyTheme on flush. struct WebDocumentStateSnapshot: Equatable, Sendable {- var theme: (name: String, variables: [String: String], mermaidConfig: String)?+ /// The last-pushed palette: theme key, Increase Contrast state (T-1829), explicit+ /// variable overlay, and the per-theme mermaid config. A value type rather than a+ /// tuple so the snapshot's `Equatable` synthesises.+ struct ThemePush: Equatable, Sendable {+ var name: String+ var contrast: WebContrastMode+ var variables: [String: String]+ var mermaidConfig: String+ }++ var theme: ThemePush? var typography: [String: String]? var commentVisibility: Bool? var searchStateJSON: String?@@ -493,29 +520,15 @@ struct WebDocumentStateSnapshot: Equatable, Sendable { /// The last scroll target to restore after layout settles (Req 2.2). var scrollTargetBlockID: String? - /// Equatable can't synthesise for a tuple; compare the fields explicitly.- static func == (lhs: WebDocumentStateSnapshot, rhs: WebDocumentStateSnapshot) -> Bool {- lhs.theme?.name == rhs.theme?.name- && lhs.theme?.variables == rhs.theme?.variables- && lhs.theme?.mermaidConfig == rhs.theme?.mermaidConfig- && lhs.typography == rhs.typography- && lhs.commentVisibility == rhs.commentVisibility- && lhs.searchStateJSON == rhs.searchStateJSON- && lhs.detailsExpandedIDs == rhs.detailsExpandedIDs- && lhs.tableModes == rhs.tableModes- && lhs.noteIndicatorsJSON == rhs.noteIndicatorsJSON- && lhs.inlineNotesJSON == rhs.inlineNotesJSON- && lhs.sectionCollapsedIDs == rhs.sectionCollapsedIDs- && lhs.scrollTargetBlockID == rhs.scrollTargetBlockID- }- /// Folds a command into the snapshot, overwriting any previous value of the /// same kind. Transient page-scroll commands (scrollByPage, scrollToEdge) are /// not part of restorable state and are not retained. mutating func apply(_ command: OutboundBridgeCommand) { switch command {- case .applyTheme(let theme, let variables, let mermaidConfig):- self.theme = (theme, variables, mermaidConfig)+ case .applyTheme(let theme, let contrast, let variables, let mermaidConfig):+ self.theme = ThemePush(+ name: theme, contrast: contrast, variables: variables, mermaidConfig: mermaidConfig+ ) case .applyTypography(let variables): typography = variables case .setCommentVisibility(let visible):@@ -546,7 +559,10 @@ struct WebDocumentStateSnapshot: Equatable, Sendable { var commands: [OutboundBridgeCommand] = [] if let theme { commands.append(.applyTheme(- theme: theme.name, variables: theme.variables, mermaidConfig: theme.mermaidConfig+ theme: theme.name,+ contrast: theme.contrast,+ variables: theme.variables,+ mermaidConfig: theme.mermaidConfig )) } if let typography { commands.append(.applyTypography(variables: typography)) }
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex adacc5e..9944aaf 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -20,8 +20,9 @@ // modes, note indicators/inline notes, typography, comment visibility — // pushed on change so the controller's coalesced snapshot can replay // native truth exactly after reload/WebContent recovery.-// - Theme: pushed through `applyTheme(themeKey:)`, fed by the hosting view-// (the effective theme depends on the view-world colorScheme).+// - Theme: pushed through `applyTheme(themeKey:contrast:)`, fed by the hosting+// view (the effective theme depends on the view-world colorScheme, and the+// Increase Contrast state is a view-world environment value). // // Change reaction runs through the Observation framework, NOT SwiftUI // `.onChange`, so the wiring exists independent of any view being mounted —@@ -106,12 +107,21 @@ final class WebDocumentStateSynchronizer { synchronize() } - /// Pushes the effective theme. Called by the hosting view, which owns the- /// colorScheme-resolved theme key (initial push and on change) — theme is- /// the one view-fed domain because the view world owns colorScheme.- func applyTheme(themeKey: String) {+ /// Pushes the effective palette: the theme key plus the system Increase Contrast+ /// state (T-1829). Called by the hosting view, which owns both — the theme key is+ /// colorScheme-resolved and `colorSchemeContrast` is a view-world environment value+ /// — so this stays the one view-fed domain (initial push and on change).+ ///+ /// Only the contrast STATE crosses the bridge; `document.css` holds the per-theme+ /// override values, matching how the theme key selects a palette block.+ ///+ /// `contrast` carries no default: this is the live initial-push seam, and a+ /// silently-`.standard` default would let a new call site drop Increase Contrast+ /// while still compiling — the T-1829 regression, reintroduced.+ func applyTheme(themeKey: String, contrast: WebContrastMode) { controller.applyTheme( theme: themeKey,+ contrast: contrast, variables: [:], mermaidConfig: WebDocumentControllerFactory.mermaidThemeConfigJSON(for: themeKey) )
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 8549c33..e84f4b2 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -235,15 +235,22 @@ enum WebDocumentControllerFactory { /// Pushes the current theme, typography, and comment visibility onto the /// controller so they queue until `ready` and replay after recovery (Req 1.3/ /// 1.4/1.7/9.6). The CSS holds the per-theme colour blocks keyed by- /// `data-prism-theme`, so only the theme key + typography scale variables are- /// pushed — not every colour.+ /// `data-prism-theme` — and their Increase Contrast variants keyed by+ /// `data-prism-contrast` (T-1829) — so only the theme key, the contrast state, and+ /// the typography scale variables are pushed, not every colour.+ ///+ /// `contrast` carries no default on purpose. A silently-`.standard` default is the+ /// exact shape of the T-1829 regression — a push path that keeps compiling while+ /// dropping the contrast state — so every caller has to state its intent. static func pushInitialState( to controller: WebDocumentController, settings: AppSettings,- themeKey: String+ themeKey: String,+ contrast: WebContrastMode ) { controller.applyTheme( theme: themeKey,+ contrast: contrast, variables: [:], mermaidConfig: mermaidThemeConfigJSON(for: themeKey) )
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 60af6bc..92c01c6 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -50,6 +50,11 @@ struct DocumentScrollContent: View { /// Resolves the effective theme (light/dark) for the theme pushes. @Environment(\.colorScheme) private var colorScheme + /// The system Increase Contrast setting (T-1829). Read here rather than in the+ /// state synchronizer because it is a view-world environment value, like+ /// `colorScheme` — the two together form the palette the document renders in.+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast+ /// Image services, for the iOS sibling-image folder-access grant flow (Req 3.x). @Environment(\.imageServices) private var imageServices @@ -110,9 +115,11 @@ struct DocumentScrollContent: View { ) webRouter = made.router made.synchronizer.start()- // The theme stays view-fed: the effective key depends on colorScheme.+ // The theme stays view-fed: the effective key depends on colorScheme, and+ // the Increase Contrast state alongside it is a view-world environment value. made.synchronizer.applyTheme(- themeKey: context.settings.theme(for: colorScheme).rawValue+ themeKey: paletteFeed.themeKey,+ contrast: paletteFeed.contrast ) synchronizer = made.synchronizer // Keyboard/menu scrolling (T-1719): route the shared controller's@@ -137,11 +144,14 @@ struct DocumentScrollContent: View { ) webController = controller }- // Live theme updates without a reload (Req 1.3). Typography, comment- // visibility, notes, sections, details, and table modes re-push from the- // synchronizer's observation pass — no view .onChange needed.- .onChange(of: context.settings.theme(for: colorScheme)) { _, theme in- synchronizer?.applyTheme(themeKey: theme.rawValue)+ // Live theme + Increase Contrast updates without a reload (Req 1.3, T-1829).+ // Typography, comment visibility, notes, sections, details, and table modes+ // re-push from the synchronizer's observation pass — no view .onChange needed.+ // One .onChange over the whole palette feed so theme and contrast can never be+ // pushed out of step, and so a future view-world palette input joins by growing+ // `WebPaletteFeed` rather than adding another observer.+ .onChange(of: paletteFeed) { _, feed in+ synchronizer?.applyTheme(themeKey: feed.themeKey, contrast: feed.contrast) } // Search highlights (Req 6.1/6.2/6.3/7.2, T-1680): re-push whenever the // coordinator's debounced query, per-block counts (recomputed on block or@@ -298,9 +308,36 @@ struct DocumentScrollContent: View { let currentIndex: Int? } + /// The view-world palette inputs the rendered document needs: the+ /// colorScheme-resolved theme key and the system Increase Contrast state+ /// (T-1829). Both are pushed together by `applyTheme`.+ private var paletteFeed: WebPaletteFeed {+ WebPaletteFeed(+ themeKey: context.settings.theme(for: colorScheme).rawValue,+ contrast: WebContrastMode(colorSchemeContrast)+ )+ }+ /// Identity key for the web load trigger: a new value re-runs the load `.task`. private struct WebLoadKey: Equatable { let hasController: Bool let revision: UInt64 } }++/// The palette state the document surface feeds to the web renderer from the view+/// world. Grouped into one `Equatable` value so a single `.onChange` covers every+/// input and they are always pushed as a consistent pair (T-1829).+struct WebPaletteFeed: Equatable {+ let themeKey: String+ let contrast: WebContrastMode+}++extension WebContrastMode {+ /// Maps SwiftUI's `colorSchemeContrast` environment value onto the bridged mode.+ /// The environment value is two-state, but it is not a frozen enum, so anything+ /// other than `.increased` is treated as standard.+ init(_ contrast: ColorSchemeContrast) {+ self = contrast == .increased ? .increased : .standard+ }+}
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex ce157f1..2a75fdd 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -211,6 +211,77 @@ --prism-error: #FF453A; } +/* ---- Increase Contrast overrides (T-1829) ----+ *+ * Mirrors Theme/HighContrastThemeColors.swift, which applies the same five token+ * overrides to native chrome (T-1045, audit finding H5). Native pushes only the+ * contrast STATE (the data-prism-contrast attribute, carried on the applyTheme bridge+ * command); the per-theme values live here — the same native/CSS split the base+ * palette uses. Keeping them here also makes toggling the setting off exact: the+ * attribute is removed and the base block applies again, with no inline overrides left+ * on the root element.+ *+ * Each per-theme block carries BOTH attributes, so at (0,3,0) it outranks the matching+ * base theme block at (0,2,0) regardless of source order. The attribute-less arm grouped+ * with prism-light is (0,2,0) and only ever competes with the bare `:root` fallback at+ * (0,1,0), which it beats: it can match only if data-prism-contrast is set while+ * data-prism-theme is not. prism-theme.js sets both from the one applyTheme payload and+ * production always sends a theme key, so that is defensive only — it mirrors the base+ * palette's `:root, :root[data-prism-theme="prism-light"]` grouping rather than covering+ * a state the app can currently reach.+ */+:root[data-prism-contrast="increased"],+:root[data-prism-contrast="increased"][data-prism-theme="prism-light"] {+ --prism-text-tertiary: #3F4880;+ --prism-search-match: #F5C36A;+ --prism-search-current: #D97706;+ --prism-footnote-badge-bg: #1E3A8A;+ --prism-footnote-badge-fg: #FFF;+}++:root[data-prism-contrast="increased"][data-prism-theme="refraction"] {+ --prism-text-tertiary: #3F4880;+ --prism-search-match: #F0A858;+ --prism-search-current: #C76A0C;+ --prism-footnote-badge-bg: #3A54A0;+ --prism-footnote-badge-fg: #FFF;+}++:root[data-prism-contrast="increased"][data-prism-theme="prism-dark"] {+ --prism-text-tertiary: #A6B4DC;+ --prism-search-match: rgb(245 195 106 / 85%);+ --prism-search-current: rgb(245 166 56 / 95%);+ --prism-footnote-badge-bg: #1E3A8A;+ --prism-footnote-badge-fg: #93C5FD;+}++:root[data-prism-contrast="increased"][data-prism-theme="classic-light"] {+ --prism-text-tertiary: #3F3F50;+ --prism-search-match: #F5C36A;+ --prism-search-current: #D97706;+ --prism-footnote-badge-bg: #1E3A8A;+ --prism-footnote-badge-fg: #FFF;+}++:root[data-prism-contrast="increased"][data-prism-theme="classic-dark"] {+ --prism-text-tertiary: #A6B4DC;+ --prism-search-match: rgb(245 195 106 / 85%);+ --prism-search-current: rgb(245 166 56 / 95%);+ --prism-footnote-badge-bg: #1E3A8A;+ --prism-footnote-badge-fg: #93C5FD;+}++/* The add-note affordance is deliberately dimmed (0.35, or 0.5 on touch — see the+ * `.prism-add-note` rules below), which is the sixth requirement of the smolspec: under+ * Increase Contrast it must not be faded. The requirement was written against the retired+ * native AccessibleTableView glyph (`.opacity(0.4)`); the cutover moved the affordance+ * into this stylesheet, so the override belongs here too. At (0,3,0) this outranks both+ * the base rule and the `@media (hover: none)` variant, which are each (0,1,0). Not a+ * custom property — the dimming is an opacity on the element, not a palette token. */+:root[data-prism-contrast="increased"] .prism-add-note {+ opacity: 1;+}+ /* ---- Base layout ---- */ html { -webkit-text-size-adjust: 100%;
diff --git a/prism/Resources/WebRenderer/prism-theme.js b/prism/Resources/WebRenderer/prism-theme.jsindex 6dc1781..6e8c5ce 100644--- a/prism/Resources/WebRenderer/prism-theme.js+++ b/prism/Resources/WebRenderer/prism-theme.js@@ -21,9 +21,21 @@ // ---- Theme (Req 1.3) ------------------------------------------------- // applyTheme sets data-prism-theme (selecting a :root variable block) and // overlays any explicit variable overrides, then re-themes in-page mermaid.+ //+ // Contrast (T-1829) rides on the SAME command as the theme key so the palette+ // and its high-contrast variant are always applied in one step — a separate+ // command could leave a frame where a newly-selected theme still wore the+ // previous theme's contrast overrides. "increased" mirrors SwiftUI's+ // colorSchemeContrast; anything else clears the attribute back to the base+ // palette. The stylesheet owns the per-theme override values. bridge.registerCommand("applyTheme", function (payload) { if (!payload) { return null; } if (payload.theme) { root.setAttribute("data-prism-theme", payload.theme); }+ if (payload.contrast === "increased") {+ root.setAttribute("data-prism-contrast", "increased");+ } else {+ root.removeAttribute("data-prism-contrast");+ } applyVariables(payload.variables); // Mermaid re-theme: dispatch a DOM event the media script listens for so a // theme change re-renders diagrams in the new theme without a reload.
diff --git a/prismTests/WebRendering/DocumentCSSContrastRulesTests.swift b/prismTests/WebRendering/DocumentCSSContrastRulesTests.swiftnew file mode 100644index 0000000..69a92f8--- /dev/null+++ b/prismTests/WebRendering/DocumentCSSContrastRulesTests.swift@@ -0,0 +1,279 @@+//+// DocumentCSSContrastRulesTests.swift+// prismTests+//+// Stylesheet guard for the Increase Contrast regression (T-1829).+//+// The T-1542 WebKit cutover moved document rendering into WebKit, but the+// high-contrast token overrides T-1045 added natively (`HighContrastThemeColors`)+// never reached the page: `document.css` carried only the base per-theme variable+// blocks and nothing bridged the OS contrast state. Search highlights, footnote+// badges, and tertiary text stayed translucent with Increase Contrast enabled.+//+// The fix keeps the palette in CSS (the established split — native pushes the+// theme KEY, the stylesheet owns the colours) and adds one+// `[data-prism-contrast="increased"]` block per theme. These tests pin those+// blocks and guard them against drifting away from `HighContrastThemeColors`,+// which is the native source of truth for the same five tokens.+//+// Rule-pin, not a rendered-contrast proof: the live WebPage harness never applies+// the emitted stylesheet (see specs/readable-tables-restoration/decision_log.md+// Decision 2), so computed colours cannot be asserted here. Visual verification is+// manual, per specs/a11y-increase-contrast/smolspec.md.+//++import SwiftUI+import Testing+@testable import prism++@Suite("document.css Increase Contrast overrides (T-1829)")+@MainActor+struct DocumentCSSContrastRulesTests {++ /// The five tokens `HighContrastThemeColors` overrides, paired with the CSS+ /// custom property each maps onto.+ private static let overriddenTokens: [(css: String, native: (any ThemeColors) -> Color)] = [+ ("--prism-search-match", { $0.searchMatchBackground }),+ ("--prism-search-current", { $0.searchMatchCurrentBackground }),+ ("--prism-footnote-badge-bg", { $0.footnoteBadgeBackground }),+ ("--prism-footnote-badge-fg", { $0.footnoteBadgeForeground }),+ ("--prism-text-tertiary", { $0.textTertiary }),+ ]++ /// Loads the bundled `document.css` from the same `Bundle.main` location the+ /// scheme handler serves it from.+ private static func loadDocumentCSS() throws -> String {+ let url = try #require(+ Bundle.main.url(forResource: "document", withExtension: "css"),+ "bundled document.css must be present in the test host"+ )+ return try String(contentsOf: url, encoding: .utf8)+ }++ private static func normalized(_ css: String) -> String {+ css.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)+ }++ /// The declaration body of the first *rule* whose selector text contains+ /// `selectorFragment`, or nil when no such rule exists.+ ///+ /// Matches the implementation in the sibling `document.css` guards+ /// (`DocumentCSSTableRulesTests`, `DocumentCSSCodeBlockRulesTests`): a hit is only a+ /// selector if no `}` sits between it and the next `{`, so a fragment appearing inside+ /// a previous rule body — or inside the section comment above these blocks — is+ /// skipped rather than silently yielding the wrong body.+ private static func ruleBody(containing selectorFragment: String, in css: String) -> String? {+ let flat = normalized(css)+ var searchStart = flat.startIndex+ while let selectorRange = flat.range(of: selectorFragment, range: searchStart..<flat.endIndex) {+ guard let openBrace = flat.range(of: "{", range: selectorRange.upperBound..<flat.endIndex) else {+ return nil+ }+ let priorClose = flat.range(of: "}", range: selectorRange.upperBound..<openBrace.lowerBound)+ if priorClose == nil,+ let closeBrace = flat.range(of: "}", range: openBrace.upperBound..<flat.endIndex) {+ return String(flat[openBrace.upperBound..<closeBrace.lowerBound])+ }+ searchStart = selectorRange.upperBound+ }+ return nil+ }++ /// The value of `property` inside a declaration body, trimmed.+ private static func value(of property: String, in body: String) -> String? {+ for declaration in body.split(separator: ";") {+ let parts = declaration.split(separator: ":", maxSplits: 1)+ guard parts.count == 2 else { continue }+ if parts[0].trimmingCharacters(in: .whitespaces) == property {+ return parts[1].trimmingCharacters(in: .whitespaces)+ }+ }+ return nil+ }++ /// The contrast-block selector for a theme key. Both halves are derived — the theme+ /// key from `PrismTheme.rawValue`, the contrast value from `WebContrastMode.rawValue`+ /// (the string prism-theme.js writes) — so a rename on either side fails here rather+ /// than leaving Swift and JS agreeing on a value the stylesheet never got.+ private static func contrastSelector(for themeKey: String) -> String {+ ":root[data-prism-contrast=\"\(WebContrastMode.increased.rawValue)\"][data-prism-theme=\"\(themeKey)\"]"+ }++ /// The RGB channels of a CSS colour (`#RGB`, `#RRGGBB`, or `rgb(r g b / a%)`).+ /// Alpha is a separate axis, handled by `alpha(fromCSS:)` against+ /// `ContrastChecker.alphaComponent`: `Color.hexString` drops it, so the two cannot be+ /// compared from one source.+ private static func channels(fromCSS value: String) -> [Int]? {+ let trimmed = value.trimmingCharacters(in: .whitespaces)+ if trimmed.hasPrefix("#") {+ var digits = String(trimmed.dropFirst())+ if digits.count == 3 { digits = digits.map { "\($0)\($0)" }.joined() }+ guard digits.count == 6 else { return nil }+ // compactMap would DROP an unparseable pair, leaving a short array that the+ // caller's `zip` then compares as a prefix — a silently weakened assertion.+ // Require all three channels, like the rgb() branch below.+ let parsed = stride(from: 0, to: 6, by: 2).compactMap { offset in+ let start = digits.index(digits.startIndex, offsetBy: offset)+ let end = digits.index(start, offsetBy: 2)+ return Int(digits[start..<end], radix: 16)+ }+ return parsed.count == 3 ? parsed : nil+ }+ guard trimmed.hasPrefix("rgb("), trimmed.hasSuffix(")") else { return nil }+ let inner = trimmed.dropFirst(4).dropLast()+ let parsed = inner.split(separator: "/")[0]+ .split(separator: " ")+ .compactMap { Int($0) }+ return parsed.count == 3 ? parsed : nil+ }++ /// The alpha of a CSS colour as a 0…1 fraction. A bare hex literal is opaque; an+ /// `rgb(r g b / a)` slash component is read either as a percentage (`85%`) or as a+ /// fraction (`0.85`). Returns nil for anything else, so a malformed value fails the+ /// guard instead of silently reading as fully opaque.+ private static func alpha(fromCSS value: String) -> Double? {+ let trimmed = value.trimmingCharacters(in: .whitespaces)+ if trimmed.hasPrefix("#") { return 1 }+ guard trimmed.hasPrefix("rgb("), trimmed.hasSuffix(")") else { return nil }+ let components = trimmed.dropFirst(4).dropLast().split(separator: "/")+ guard components.count == 2 else { return components.count == 1 ? 1 : nil }+ let raw = components[1].trimmingCharacters(in: .whitespaces)+ if raw.hasSuffix("%") {+ guard let percent = Double(raw.dropLast()) else { return nil }+ return percent / 100+ }+ return Double(raw)+ }++ // MARK: - Per-theme blocks exist and override every token++ @Test("Every theme has an Increase Contrast block overriding all five tokens", arguments: PrismTheme.allCases)+ func perThemeContrastBlockOverridesEveryToken(theme: PrismTheme) throws {+ let css = try Self.loadDocumentCSS()+ let selector = Self.contrastSelector(for: theme.rawValue)+ let body = try #require(+ Self.ruleBody(containing: selector, in: css),+ "document.css must define \(selector) so Increase Contrast reaches the rendered document"+ )+ for token in Self.overriddenTokens {+ let overridden = Self.value(of: token.css, in: body)+ #expect(+ overridden != nil,+ "\(selector) must override \(token.css) (T-1829)"+ )+ }+ }++ @Test("The default (attribute-less) :root arm is grouped with prism-light")+ func bareRootContrastBlockExists() throws {+ let css = try Self.loadDocumentCSS()+ // Defensive only, and deliberately so: `data-prism-contrast` is written *by*+ // applyTheme (prism-theme.js), which sets `data-prism-theme` from the same+ // payload, and production always sends a non-empty theme key — so a document+ // carrying the contrast attribute but no theme attribute is not currently+ // reachable. The grouping mirrors the base palette's+ // `:root, :root[data-prism-theme="prism-light"]` shape so the two sections stay+ // structurally parallel; this pins that shape, not a live code path.+ let body = try #require(+ Self.ruleBody(+ containing: ":root[data-prism-contrast=\"\(WebContrastMode.increased.rawValue)\"],",+ in: css+ ),+ "document.css must group the bare :root contrast selector with prism-light"+ )+ #expect(Self.value(of: "--prism-search-match", in: body) != nil)+ }++ // MARK: - The dimmed add-note affordance (smolspec requirement 6)++ @Test("Increase Contrast un-dims the add-note affordance")+ func addNoteAffordanceIsOpaqueUnderIncreasedContrast() throws {+ let css = try Self.loadDocumentCSS()+ // The smolspec requires the dimmed add-note glyph to stop being faded under+ // Increase Contrast. It was written against the retired native+ // AccessibleTableView `.opacity(0.4)`; the cutover moved the affordance into+ // document.css (`.prism-add-note { opacity: 0.35 }` plus a 0.5 touch variant),+ // so the override has to live here.+ let selector = ":root[data-prism-contrast=\"\(WebContrastMode.increased.rawValue)\"] .prism-add-note"+ let body = try #require(+ Self.ruleBody(containing: selector, in: css),+ "document.css must un-dim .prism-add-note under Increase Contrast (T-1829)"+ )+ #expect(Self.value(of: "opacity", in: body) == "1")+ }++ // MARK: - Anti-drift against the native source of truth++ @Test(+ "Contrast block values track HighContrastThemeColors",+ arguments: PrismTheme.allCases+ )+ func contrastValuesMatchNativeOverrides(theme: PrismTheme) throws {+ let css = try Self.loadDocumentCSS()+ let selector = Self.contrastSelector(for: theme.rawValue)+ let body = try #require(Self.ruleBody(containing: selector, in: css))+ let native = HighContrastThemeColors(theme: theme)+ for token in Self.overriddenTokens {+ let cssValue = try #require(+ Self.value(of: token.css, in: body),+ "\(selector) must define \(token.css)"+ )+ let cssChannels = try #require(+ Self.channels(fromCSS: cssValue),+ "\(token.css) in \(selector) must be a hex or rgb() colour, got \(cssValue)"+ )+ let nativeChannels = try #require(+ Self.channels(fromCSS: token.native(native).hexString)+ )+ // Per-channel tolerance of 1: `Color.hexString` truncates rather than+ // rounds (245/255 * 255 = 244.999… → 244), so an exact match would fail+ // on values that are in fact identical. Any real drift is a different+ // colour by far more than one level.+ let drifted = zip(cssChannels, nativeChannels).contains { abs($0 - $1) > 1 }+ #expect(+ !drifted,+ "\(token.css) in \(selector) (\(cssValue)) has drifted from HighContrastThemeColors"+ )+ // Alpha is its own axis and needs its own comparison: the RGB check above+ // reads channels only, so the dark themes' `.opacity(0.85)` / `.opacity(0.95)`+ // search highlights would otherwise be entirely unguarded — an `85%` → `8.5%`+ // slip keeps the same channels and would sail through, silently restoring the+ // translucency T-1829 exists to fix.+ let cssAlpha = try #require(+ Self.alpha(fromCSS: cssValue),+ "\(token.css) in \(selector) must carry a readable alpha, got \(cssValue)"+ )+ #expect(+ abs(cssAlpha - ContrastChecker.alphaComponent(token.native(native))) <= 0.005,+ """+ \(token.css) in \(selector) (\(cssValue)) has drifted in opacity from \+ HighContrastThemeColors+ """+ )+ }+ }++ // MARK: - The overrides actually change something++ @Test(+ "Contrast blocks differ from the theme's base values",+ arguments: PrismTheme.allCases+ )+ func contrastValuesDifferFromBase(theme: PrismTheme) throws {+ let css = try Self.loadDocumentCSS()+ let contrastBody = try #require(Self.ruleBody(containing: Self.contrastSelector(for: theme.rawValue), in: css))+ let baseSelector = ":root[data-prism-theme=\"\(theme.rawValue)\"]"+ let baseBody = try #require(Self.ruleBody(containing: baseSelector, in: css))+ // Search highlights and the footnote badge background are the tokens the audit+ // called out as translucent; they must not be reused verbatim under increased+ // contrast on any theme.+ for property in ["--prism-search-match", "--prism-search-current", "--prism-footnote-badge-bg"] {+ let base = Self.value(of: property, in: baseBody)+ let increased = Self.value(of: property, in: contrastBody)+ #expect(+ base != increased,+ "\(property) must change under Increase Contrast on \(theme.rawValue)"+ )+ }+ }+}
diff --git a/prismTests/WebRendering/WebContrastBridgeTests.swift b/prismTests/WebRendering/WebContrastBridgeTests.swiftnew file mode 100644index 0000000..20df6ea--- /dev/null+++ b/prismTests/WebRendering/WebContrastBridgeTests.swift@@ -0,0 +1,147 @@+//+// WebContrastBridgeTests.swift+// prismTests+//+// Regression tests for T-1829: the system Increase Contrast setting was ignored by+// web-rendered documents.+//+// T-1045 wired Increase Contrast into the native theme environment+// (`HighContrastThemeColors`), but the T-1542 WebKit cutover moved the document body+// into a WKWebView that only ever received the theme KEY — `applyTheme` sent+// `variables: [:]` and nothing observed `colorSchemeContrast`. Search highlights,+// footnote badges, and tertiary document text stayed translucent.+//+// The fix carries the contrast STATE on the existing `applyTheme` command (so the+// theme key and its high-contrast variant can never be applied out of step), maps it+// onto `data-prism-contrast` in prism-theme.js, and keeps the per-theme override+// values in `document.css` (pinned by DocumentCSSContrastRulesTests).+//++import SwiftUI+import Testing+import WebKit+@testable import prism++@Suite("Increase Contrast reaches the rendered document (T-1829)")+@MainActor+struct WebContrastBridgeTests {++ private func sampleBlocks() -> [MarkdownBlock] {+ [+ .heading(level: 1, text: "Doc"),+ .paragraph(markdown: "Body text with a footnote.[^1]"),+ ]+ }++ // MARK: - Environment mapping++ @Test("colorSchemeContrast maps onto the bridged contrast mode")+ func environmentMapping() {+ #expect(WebContrastMode(ColorSchemeContrast.increased) == .increased)+ #expect(WebContrastMode(ColorSchemeContrast.standard) == .standard)+ }++ @Test("The palette feed pairs the theme key with the contrast state")+ func paletteFeedEquatable() {+ let standard = WebPaletteFeed(themeKey: "prism-dark", contrast: .standard)+ let increased = WebPaletteFeed(themeKey: "prism-dark", contrast: .increased)+ // A contrast-only change must be a distinct value, or the view's single+ // .onChange would never re-push when the user toggles the OS setting.+ #expect(standard != increased)+ }++ // MARK: - Command payload++ @Test("applyTheme's JS payload carries the contrast state")+ func payloadCarriesContrast() {+ let increased = WebDocumentController.payload(+ for: .applyTheme(theme: "prism-dark", contrast: .increased, variables: [:], mermaidConfig: "")+ )+ #expect(increased["contrast"] as? String == "increased")++ let standard = WebDocumentController.payload(+ for: .applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: "")+ )+ #expect(standard["contrast"] as? String == "standard")+ }++ // MARK: - Recovery replay (Req 9.6)++ @Test("The coalesced snapshot replays the contrast state after a WebContent recovery")+ func snapshotReplaysContrast() {+ var snapshot = WebDocumentStateSnapshot()+ snapshot.apply(.applyTheme(theme: "prism-dark", contrast: .increased, variables: [:], mermaidConfig: ""))+ #expect(snapshot.theme?.contrast == .increased)+ #expect(snapshot.coalescedCommands().contains(+ .applyTheme(theme: "prism-dark", contrast: .increased, variables: [:], mermaidConfig: "")+ ))++ // Turning the setting off must overwrite, not linger.+ snapshot.apply(.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: ""))+ #expect(snapshot.theme?.contrast == .standard)+ }++ @Test("A contrast-only change is a distinct snapshot")+ func contrastChangesSnapshotIdentity() {+ var standard = WebDocumentStateSnapshot()+ standard.apply(.applyTheme(theme: "prism-light", contrast: .standard, variables: [:], mermaidConfig: ""))+ var increased = WebDocumentStateSnapshot()+ increased.apply(.applyTheme(theme: "prism-light", contrast: .increased, variables: [:], mermaidConfig: ""))+ #expect(standard != increased)+ }++ // MARK: - Synchronizer push++ @Test("The state synchronizer pushes the contrast state with the theme")+ func synchronizerPushesContrast() async {+ // The production assembly — the same entry point DocumentScrollContent uses.+ let session = DocumentSession(+ url: URL(fileURLWithPath: "/tmp/t1829-contrast-fixture.md"),+ content: "# Doc\n\nBody text."+ )+ await session.parseContent()+ let made = WebDocumentStateSynchronizer.makeAssembly(+ session: session,+ settings: AppSettings(),+ coordinator: DocumentLayoutCoordinator(),+ notesManager: NotesManager()+ )+ made.synchronizer.applyTheme(themeKey: "prism-dark", contrast: .increased)+ #expect(made.controller.latestSnapshot.theme?.name == "prism-dark")+ #expect(made.controller.latestSnapshot.theme?.contrast == .increased)++ made.synchronizer.applyTheme(themeKey: "prism-dark", contrast: .standard)+ #expect(made.controller.latestSnapshot.theme?.contrast == .standard)+ }++ // MARK: - Live page (accessibility smoke test)++ @Test("applyTheme toggles the document's contrast attribute live, without a reload")+ func liveContrastAttributeToggles() async throws {+ let harness = try await WebDocumentLiveHarness.make(blocks: sampleBlocks())+ let urlBefore = harness.page.url++ try await harness.send(+ .applyTheme(theme: "prism-dark", contrast: .increased, variables: [:], mermaidConfig: "")+ )+ try await Task.sleep(for: .milliseconds(100))+ var attr = try await harness.evalString(+ "return document.documentElement.getAttribute('data-prism-contrast');"+ )+ #expect(attr == "increased", "the stylesheet's high-contrast blocks key on this attribute")++ // Turning the OS setting back off must restore the base palette exactly —+ // the attribute is removed, so no override survives (nothing is written as an+ // inline style that would have to be unwound).+ try await harness.send(+ .applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: "")+ )+ try await Task.sleep(for: .milliseconds(100))+ attr = try await harness.evalString(+ "return document.documentElement.getAttribute('data-prism-contrast');"+ )+ #expect(attr == nil || attr == "")++ #expect(harness.page.url == urlBefore, "contrast changes must not reload the document")+ }+}
diff --git a/prismTests/WebRendering/WebDocumentControllerTests.swift b/prismTests/WebRendering/WebDocumentControllerTests.swiftindex b4d26f8..0437877 100644--- a/prismTests/WebRendering/WebDocumentControllerTests.swift+++ b/prismTests/WebRendering/WebDocumentControllerTests.swift@@ -57,7 +57,7 @@ struct WebDocumentControllerTests { #expect(generation.processGeneration == 0) let args = WebDocumentController.arguments(- for: .applyTheme(theme: "prism-dark", variables: ["--x": "1"], mermaidConfig: ""),+ for: .applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--x": "1"], mermaidConfig: ""), generation: generation ) let tag = args["generation"] as? [String: Any]@@ -282,7 +282,7 @@ struct WebDocumentControllerTests { func commandsQueueUntilReady() { let controller = makeController() controller.setCommentVisibility(true)- controller.applyTheme(theme: "prism-dark", variables: [:])+ controller.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:]) #expect(controller.pendingCommands.count == 2) #expect(!controller.canDispatch(.setCommentVisibility(true))) }@@ -291,7 +291,7 @@ struct WebDocumentControllerTests { func readyFlushesQueue() { let controller = makeController() controller.setCommentVisibility(true)- controller.applyTheme(theme: "prism-dark", variables: [:])+ controller.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:]) controller.test_markReady() // Non-scroll commands are dispatched and drained from the queue. #expect(controller.pendingCommands.isEmpty)@@ -328,23 +328,23 @@ struct WebDocumentControllerTests { @Test("A burst of theme pushes coalesces to the latest in the snapshot") func snapshotCoalescesThemeBurst() { var snapshot = WebDocumentStateSnapshot()- snapshot.apply(.applyTheme(theme: "prism-light", variables: ["--a": "1"], mermaidConfig: ""))- snapshot.apply(.applyTheme(theme: "prism-dark", variables: ["--a": "2"], mermaidConfig: ""))+ snapshot.apply(.applyTheme(theme: "prism-light", contrast: .standard, variables: ["--a": "1"], mermaidConfig: ""))+ snapshot.apply(.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "2"], mermaidConfig: "")) let themes = snapshot.coalescedCommands().filter { if case .applyTheme = $0 { return true } else { return false } }- #expect(themes == [.applyTheme(theme: "prism-dark", variables: ["--a": "2"], mermaidConfig: "")])+ #expect(themes == [.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "2"], mermaidConfig: "")]) } @Test("Snapshot replay puts scroll restore last so it runs after theme/layout") func snapshotReplayOrdersScrollLast() { var snapshot = WebDocumentStateSnapshot() snapshot.apply(.scrollToBlock(domID: "b-9-0"))- snapshot.apply(.applyTheme(theme: "prism-dark", variables: [:], mermaidConfig: ""))+ snapshot.apply(.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: "")) snapshot.apply(.setSearchState(json: "{}")) let commands = snapshot.coalescedCommands() #expect(commands.last == .scrollToBlock(domID: "b-9-0"))- #expect(commands.first == .applyTheme(theme: "prism-dark", variables: [:], mermaidConfig: ""))+ #expect(commands.first == .applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: "")) } @Test("Transient page scroll commands are not retained in the snapshot")@@ -361,7 +361,7 @@ struct WebDocumentControllerTests { func terminationReplaysSnapshot() { let controller = makeController() // Establish some native truth, then become ready and drain it.- controller.applyTheme(theme: "prism-dark", variables: ["--a": "1"])+ controller.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "1"]) controller.setSearchState(json: "{\"q\":1}") controller.scrollTo(blockID: "b-3-0") controller.test_markReady()@@ -376,7 +376,7 @@ struct WebDocumentControllerTests { #expect(!controller.isLayoutSettled) // The coalesced snapshot is re-queued: theme, search, then scroll last. let queued = controller.pendingCommands- #expect(queued.contains(.applyTheme(theme: "prism-dark", variables: ["--a": "1"], mermaidConfig: "")))+ #expect(queued.contains(.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "1"], mermaidConfig: ""))) #expect(queued.contains(.setSearchState(json: "{\"q\":1}"))) #expect(queued.last == .scrollToBlock(domID: "b-3-0")) }
diff --git a/prismTests/WebRendering/WebThemeStateSyncTests.swift b/prismTests/WebRendering/WebThemeStateSyncTests.swiftindex 0099a08..e3f565c 100644--- a/prismTests/WebRendering/WebThemeStateSyncTests.swift+++ b/prismTests/WebRendering/WebThemeStateSyncTests.swift@@ -42,7 +42,12 @@ struct WebThemeStateSyncTests { func applyThemeNoReload() async throws { let harness = try await WebDocumentLiveHarness.make(blocks: sampleBlocks()) let urlBefore = harness.page.url- try await harness.send(.applyTheme(theme: "prism-dark", variables: ["--prism-accent": "#123456"], mermaidConfig: ""))+ try await harness.send(.applyTheme(+ theme: "prism-dark",+ contrast: .standard,+ variables: ["--prism-accent": "#123456"],+ mermaidConfig: ""+ )) try await Task.sleep(for: .milliseconds(100)) let themeAttr = try await harness.evalString(@@ -66,7 +71,7 @@ struct WebThemeStateSyncTests { + "document.addEventListener('prism:retheme', function(){ window.__rethemeSeen = true; }); return null;", contentWorld: harness.bridgeWorld )- try await harness.send(.applyTheme(theme: "refraction", variables: [:], mermaidConfig: ""))+ try await harness.send(.applyTheme(theme: "refraction", contrast: .standard, variables: [:], mermaidConfig: "")) try await Task.sleep(for: .milliseconds(100)) let seen = try await harness.evalBool("return window.__rethemeSeen === true;") #expect(seen == true)
diff --git a/prismTests/WebRendering/WebSearchWiringTests.swift b/prismTests/WebRendering/WebSearchWiringTests.swiftindex 43a505f..87be62b 100644--- a/prismTests/WebRendering/WebSearchWiringTests.swift+++ b/prismTests/WebRendering/WebSearchWiringTests.swift@@ -68,7 +68,10 @@ struct WebSearchWiringTests { ) let controller = made.controller made.synchronizer.start()- made.synchronizer.applyTheme(themeKey: settings.theme(for: .light).rawValue)+ made.synchronizer.applyTheme(+ themeKey: settings.theme(for: .light).rawValue,+ contrast: .standard+ ) WebDocumentControllerFactory.pushSearchState( to: controller, session: session,
diff --git a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swiftindex d1a8874..37b8124 100644--- a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift+++ b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift@@ -139,7 +139,8 @@ struct HTMLCommentVisibilityLiveTests { WebDocumentControllerFactory.pushInitialState( to: controller, settings: settings,- themeKey: PrismTheme.prismLight.rawValue+ themeKey: PrismTheme.prismLight.rawValue,+ contrast: .standard ) let url = WebDocumentControllerFactory.documentURL( session: session, parseRevision: session.parseRevision
diff --git a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swiftindex 40599da..f1cbc2d 100644--- a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift+++ b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift@@ -156,7 +156,7 @@ struct WebStateSynchronizerAssemblyTests { @Test("applyTheme pushes the theme through the synchronizer") func applyThemePushes() async throws { let assembly = await makeAssembly()- assembly.synchronizer.applyTheme(themeKey: "dark")+ assembly.synchronizer.applyTheme(themeKey: "dark", contrast: .standard) let pushed = await waitUntil { assembly.controller.latestSnapshot.theme?.name == "dark" }
diff --git a/specs/a11y-increase-contrast/smolspec.md b/specs/a11y-increase-contrast/smolspec.mdindex ce4c220..bd275d9 100644--- a/specs/a11y-increase-contrast/smolspec.md+++ b/specs/a11y-increase-contrast/smolspec.md@@ -70,3 +70,38 @@ Notes: - **Risk:** The proposed contrast-ratio target of ≥4.5:1 is WCAG AA for normal text. Apple's HIG-style Increase Contrast can go higher (7:1, WCAG AAA) for some users. | **Mitigation:** Aim for ≥4.5:1 minimum; if specific tokens land naturally at 7:1+ keep them there. Don't over-engineer to AAA for tokens that already meet AA. Verify with the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) using the hex values resolved against each theme's `background`. - **Assumption:** The user's OS reports `.increased` for `Environment(\.colorSchemeContrast)` when Increase Contrast is enabled in Accessibility settings. (Confirmed in Apple's documentation; the env value is two-state: `.standard` / `.increased`.) - **Prerequisite:** The existing 5 themes' default tokens render correctly. (Confirmed — no recent changes to theme color values affecting the five overridden tokens.)++## Addendum: the web renderer (T-1829)++The T-1542 WebKit cutover moved the document body out of the SwiftUI view tree, so the+environment-injected `HighContrastThemeColors` above no longer reached it — the five tokens+reverted to their base values inside the rendered document. `TextualThemeAdapter` (referenced+in "Key files" above) was removed by the cutover and no longer exists.++T-1829 restores the behaviour for the web path, following the same native/CSS split the base+palette uses — native pushes STATE, the stylesheet owns the VALUES:++- `DocumentScrollContent` reads `@Environment(\.colorSchemeContrast)` and pairs it with the+ colorScheme-resolved theme key in one `WebPaletteFeed`, pushed by a single `.onChange`.+- The state travels on the existing `applyTheme` bridge command as a `WebContrastMode`, so the+ theme key and its contrast variant are always applied together (the override values are+ per-theme). It is part of the coalesced snapshot, so a WebContent recovery replays it.+- `prism-theme.js` maps it onto `data-prism-contrast`; `document.css` carries one+ `[data-prism-contrast="increased"]` block per theme with the values from the table above.++`DocumentCSSContrastRulesTests` pins those CSS values against `HighContrastThemeColors`, so+the two representations of the same five tokens cannot drift apart.++**The dimmed add-note affordance.** The requirement above about `AccessibleTableView`'s+`.opacity(0.4)` empty-state glyph also moved: the cutover retired that view and the+affordance now lives in `document.css` as `.prism-add-note` (`opacity: 0.35`, and `0.5`+under `@media (hover: none)`). T-1829 adds+`:root[data-prism-contrast="increased"] .prism-add-note { opacity: 1; }` so the requirement+holds on the surface where the affordance actually exists. It is an element opacity, not a+palette token, so it sits outside the five-token table and outside the anti-drift test; it+has its own rule-pin instead.++**Not covered.** The footnote popover page (`FootnotePopoverWebPage`) has no bridge — it+bakes `RenderSettings.theme` into the emitted HTML — and receives no contrast state. Its+content is limited to paragraphs, lists and blockquotes, which consume none of the five+overridden tokens, so the gap is unobservable today. See decision-log Q4.
diff --git a/specs/a11y-increase-contrast/decision_log.md b/specs/a11y-increase-contrast/decision_log.mdindex 44f63b2..17002fa 100644--- a/specs/a11y-increase-contrast/decision_log.md+++ b/specs/a11y-increase-contrast/decision_log.md@@ -1,5 +1,15 @@ # Decision Log: A11y Increase Contrast Wiring +## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-07-26 | Record T-1829 as an addendum to this smolspec rather than a `specs/bugfixes/<name>/report.md` | The web-path work restores the *same* requirements this spec already states against a surface that moved; splitting the token table across two documents would create the drift the addendum exists to prevent |+| Q2 | 2026-07-26 | `contrast` carries no default on `WebDocumentController.applyTheme`, `WebDocumentStateSynchronizer.applyTheme`, and `WebDocumentControllerFactory.pushInitialState` | A silently-`.standard` default is the exact shape of the regression this ticket fixes: a push path that keeps compiling while dropping the contrast state. Cost is churn at call sites and 6 test files, paid once |+| Q3 | 2026-07-26 | `WebDocumentStateSnapshot.theme` becomes a `ThemePush` struct instead of growing the 4-member tuple | Tuples have no synthesised `Equatable`; the hand-written `==` it replaces would have silently ignored a newly-added field |+| Q4 | 2026-07-26 | The footnote popover page is **not** given a contrast path | It has no bridge, so contrast would have to be baked into `RenderSettings`/`BlockHTMLEmitter`. Its content is restricted to paragraphs, lists and blockquotes, none of which consume the five overridden tokens, so the gap is currently unobservable. Revisit if the popover ever renders badges, comments or search highlights |+| Q5 | 2026-07-26 | A contrast-only `applyTheme` push still dispatches `prism:retheme`, re-rendering in-page mermaid diagrams | Guarding the dispatch would add state to `prism-theme.js` to save work on a setting toggled rarely, and diagrams do not consume the overridden tokens, so the re-render is wasteful but correct. Tracked as a possible follow-up, not fixed here |+ ## Decision 1: Wrapper-with-per-theme-overrides instead of full high-contrast theme variants **Date**: 2026-05-20@@ -107,3 +117,42 @@ Under increased contrast, `textTertiary` MUST be darkened (light themes) or ligh - Per-theme hex values must be picked and visually verified; cannot fall back to a system semantic colour. ---++## Decision 4: Push the contrast STATE, keep the per-theme VALUES in `document.css`++**Date**: 2026-07-26+**Status**: accepted++### Context++The T-1542 WebKit cutover moved the document body out of the SwiftUI view tree, so the `HighContrastThemeColors` value `ThemeModifier` injects into the environment (Decision 1) no longer reached it — the five overridden tokens reverted to their base values inside the rendered document (T-1829).++The `applyTheme` bridge command already carries a `variables: [String: String]` overlay that could have shipped the five *resolved* `HighContrastThemeColors` colours to the page, making the Swift wrapper the single source of truth for both surfaces.++### Decision++Native pushes only the contrast STATE — a `WebContrastMode` that `prism-theme.js` maps onto a `data-prism-contrast` attribute. `document.css` carries one `[data-prism-contrast="increased"]` block per theme holding the values, mirroring `HighContrastThemeColors`. `DocumentCSSContrastRulesTests` pins the two representations together on both the RGB and the alpha axis.++### Rationale++- It is the split the base palette already uses: native pushes the theme KEY, the stylesheet owns the colours. A second, different mechanism for the contrast variant would be the odd one out.+- Toggling the setting off is then exact. Removing an attribute restores the base block with nothing to unwind; pushing values through `variables` writes inline styles on the root element that a later "off" push would have to explicitly clear, and any missed token would stick.+- The override values are per-theme, so the palette block and its contrast variant must change atomically — which the attribute gives for free, since both come off one command.++### Alternatives Considered++- **Push the resolved colours through the existing `variables` overlay**: Removes the duplication outright and keeps `HighContrastThemeColors` as the one source of truth. Rejected because clearing inline root styles on toggle-off is error-prone in exactly the direction that fails silently (a stale override survives), and because it diverges from the established native-state/CSS-values split.+- **A separate `applyContrast` bridge command**: Cleaner command surface. Rejected because a split push has a frame in which a newly-selected theme still wears the previous theme's overrides — the values are per-theme, so the two are not independent.+- **Bake `data-prism-contrast` into the emitted HTML like `RenderSettings.theme`**: Would require a re-emit and reload on every toggle, losing reading position — the opposite of the live-update requirement.++### Consequences++**Positive:**+- Toggling Increase Contrast is a single attribute write; no reload, no lost reading position, and it replays through the coalesced snapshot after a WebContent recovery.+- The base palette and its contrast variant are always applied together.++**Negative:**+- The five override values exist twice, in Swift and in CSS. Mitigated, not removed, by the anti-drift test.+- Four of the five tokens (`searchMatchBackground`, `searchMatchCurrentBackground`, `footnoteBadgeBackground`, `footnoteBadgeForeground`) now have no native consumer outside `prism/Theme/`, so the Swift "source of truth" for them renders nothing itself.++---
diff --git a/specs/a11y-increase-contrast/tasks.md b/specs/a11y-increase-contrast/tasks.mdindex ae79296..35522c4 100644--- a/specs/a11y-increase-contrast/tasks.md+++ b/specs/a11y-increase-contrast/tasks.md@@ -5,6 +5,13 @@ references: --- # A11y Increase Contrast Wiring (T-1045) +> **Status note (T-1829, 2026-07-26).** Tasks 3 and 4 are **superseded by the T-1542 WebKit+> cutover**: `prism/Views/AccessibleTableView.swift` and `TextualThemeAdapter` no longer+> exist. The dimmed affordance task 3 targeted now lives in `document.css` as+> `.prism-add-note` and is handled by T-1829's contrast override; task 4's adapter has no+> successor. See the "Addendum: the web renderer (T-1829)" section of `smolspec.md` for the+> work that replaced them.+ - [ ] 1. HighContrastThemeColors wrapper with pass-through and per-theme overrides <!-- id:t1045a --> - Add `prism/Theme/HighContrastThemeColors.swift` containing a struct that conforms to `ThemeColors`, takes a single `PrismTheme` in its initializer (deriving the stored `base: any ThemeColors` and `themeIdentity: PrismTheme` from it), and forwards every protocol property (~30) to `base` via computed properties. - Override `searchMatchBackground`, `searchMatchCurrentBackground`, `footnoteBadgeBackground`, `footnoteBadgeForeground`, and `textTertiary` via a private `switch` on `themeIdentity` using the per-theme hex table in `smolspec.md`.
diff --git a/CLAUDE.md b/CLAUDE.mdindex f9e5f00..ed32b0a 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -50,7 +50,7 @@ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftU 1. **Parse**: `swift-markdown` → AST → `MarkdownBlock` enum variants (`MarkdownBlockParser`), unchanged from before. T-1558 made the model lossless for nested blockquotes, ordered-list `start`, and rich blocks inside list items (see `specs/web-markdown-fidelity/`). 2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes. `emit` (and the model/service value types it reads) is `nonisolated`, so it runs off the MainActor: `WebDocumentControllerFactory.precomputeDocumentHTML` emits once per `parseRevision` on a `Task.detached` and caches the HTML on `DocumentSession`; the scheme handler serves that cache (synchronous on-main emit only on a miss). Its per-block/inline `HTMLSanitizer` (SwiftSoup) passes are serialized behind a shared `Mutex` because SwiftSoup keeps unsynchronized static pools (T-1681, `specs/offmain-html-emit/`). 3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`.-4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly` (theme alone is view-fed, via `applyTheme(themeKey:)`, because it depends on colorScheme).+4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly` (the palette alone is view-fed, via `applyTheme(themeKey:contrast:)`, because it depends on view-world environment values — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step; T-1829). 5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back. 6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search. 7. **Security**: `HTMLSanitizer` (over SwiftSoup) reduces raw HTML embedded in markdown to an allowlist subset on load (Req 1.8/8.1); its `plainText` feeds searchable text. Combined with `allowsContentJavaScript = false` and the served CSP, active-content vectors are blocked by construction.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c78d7e9..06bfe1f 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers keep the standard palette. - The saved reading position is no longer corrupted by content the document does not show (T-1851). Hidden content measures as sitting exactly at the top of the window, which beat every genuinely visible block, so the document reported a block the reader could not see as the block being read. That happened with any heading collapsed, and — because the carrier holding a document's YAML frontmatter is hidden the same way — on every document that starts with frontmatter, collapsed or not. Reopening the document, returning from raw source, or recovering from a rendering-process restart then landed on the wrong block or did nothing at all. Content the document does not lay out is now skipped when working out the reading position. - Opening or reloading a large or HTML-heavy document no longer freezes the UI (T-1681). Since the WebKit rendering cutover, the full document HTML — including a SwiftSoup sanitisation pass per raw-HTML block and per inline HTML run — was built synchronously on the main thread on every serve, so a big or markup-dense document blocked the app while it rendered, and re-built the HTML on every reload. The build now runs off the main thread and its result is cached per parse: the UI stays responsive, and reloads (external file change, URL refresh, WebContent-process recovery, the iOS folder-access retry) reuse the cached HTML instead of re-emitting. Very HTML-dense documents can still take a noticeable moment to appear; making that incremental is tracked separately. - Search matches are highlighted in the rendered document again (T-1680). Since the WebKit rendering cutover, searching counted matches and navigated natively but the page never showed a highlight — the native→web search-state feed was never connected. Matches now light up as you type, the current match gets its distinct emphasis and scrolls into view when navigating (including when a result is picked from the iPhone search overlay), footnote badges whose content matches are marked, and dismissing search clears the highlights.
The spec's ≥4.5:1 contrast target is verified by nothing in this branch. DocumentCSSContrastRulesTests is honest about why — the live WebPage harness never applies the emitted stylesheet (specs/readable-tables-restoration/decision_log.md Decision 2), so computed colours cannot be asserted. Toggle Increase Contrast on both iOS 26 and macOS 26 across all five themes and check search highlights, footnote badges, tertiary text and the add-note glyph. Pay particular attention to Refraction, whose gradient background interacts with the overrides.
.prism-footnote-badge[data-prism-search-current] pairs --prism-footnote-badge-fg against --prism-search-current, which under increased contrast on light themes is white on #D97706 ≈ 3.2:1 — below the spec's own bar. The base-palette equivalent is ≈3.25:1, so this branch neither improves nor regresses it; it is inherited from T-1045's token picks. Worth its own ticket rather than a fix here, but confirm it looks acceptable during the manual pass.
loadDocumentCSS, normalized and ruleBody now exist in three copies across prismTests/WebRendering/. This review hardened the new copy rather than consolidating, to avoid touching two test files unrelated to the bug. The directory already establishes the shared-support-file pattern (WebDocumentLiveHarness.swift, ParityFixtureSupport.swift) and the project uses file-system synchronized groups, so a DocumentCSSTestSupport.swift needs no pbxproj edit — a clean follow-up ticket.
WebContrastBridgeTests exercises snapshot.apply + coalescedCommands() directly with .increased. The full termination path (test_markReady → terminate → pendingCommands) is only driven with .standard, in the mechanically-updated WebDocumentControllerTests. Low risk now that ThemePush synthesises Equatable — the replay is field-agnostic — but a cheap test to add.