The bridge-less footnote popover page now bakes the document's typography and palette (theme key + Increase Contrast) into its emitted HTML, via a validated <html style> attribute in BlockHTMLEmitter. PR #339.
<html style> outranks document.css's :root fallbacks, and the served CSP already carries style-src 'unsafe-inline', so the inline attribute is not blocked.;/{/}/control characters and any value left inside an open string, comment, (/[ block, or dangling escape; the whole attribute is then HTML-escaped. Worst case for a slipped value is CSS in a page with script-src 'none' and allowsContentJavaScript = false.documentRenderSettings still passes neither palette nor typography, so the parseRevision-keyed HTML cache cannot go stale on a setting that changes without a reparse (offmain-html-emit Decision 1). Two tests assert the document root tag is exactly <html>.WebPage infrastructure and the document surface has liveTypographyVariablesApply; a computed-style assertion would close the last step between "attribute is in the HTML" and "the popover renders bigger".WebStateSynchronizerAssemblyTests/settingsChangesRePush() failed once in one of four locale configurations under parallel load (its poll hit the 2s timeout), then passed in isolation and on a full rerun of the same suite set.Ready to push
No blocking or major issues. Both platform builds are clean (make build-ios, make build-macos), SwiftLint reports 0 violations across 510 files, and 195 targeted tests across the emitter, typography, popover, contrast-CSS, security, parity and off-main-emit suites pass. The design is sound: one composition point for typography is preserved (the popover consumes WebDocumentControllerFactory.typographyVariables rather than re-resolving fonts), palette state travels as one WebPaletteFeed so theme and contrast cannot be applied out of step, and the document surface's parseRevision-keyed HTML cache is explicitly kept free of per-view state, pinned by two regression tests. Findings are all minor/suggestion-level and none were fixed in this review (report-only pass).
4b9c414 Fix T-1978: Footnote popovers ignore document typography settings f3a24f3 Address PR #339 review: bake contrast, tighten the CSS validator 14e5c40 Address PR #339 review round 2: supersede Q4, extend the CSS scanner working-tree No fixes applied — report-only review Prism shows a footnote's text in a small popover. That popover is drawn by the same web renderer as the document, but unlike the document it has no live "phone line" (bridge) back to the app — it gets one shot: whatever HTML the app hands it when it opens. Until now the app forgot to hand it two things: the reader's text settings (chosen body font, in-app Text Size slider, and the system's Larger Text / Text Size setting) and the system's Increase Contrast setting. So footnote text stayed at the default system font and size while the document around it followed the user's choices — and at accessibility text sizes it stayed small while everything else grew, which is an accessibility failure, not just a cosmetic mismatch.
The fix writes those settings straight into the popover's HTML when it is created: a style attribute on the page's root element carrying the font family, base size and scale, plus two data attributes carrying the theme name and, when it is on, the Increase Contrast state.
--prism-font-size; setting them inline on the page root overrides the stylesheet's defaults.The rendered document is a WebKit page fed by native state over a generation-tagged bridge. The footnote popover (FootnotePopoverWebPage) deliberately has no bridge: it is emitted fresh on every present and retains nothing. Anything the document receives via a bridge push therefore has to be baked into the popover's emitted HTML, and that is exactly the failure class here — T-1542 baked the theme key, T-1829 skipped contrast (Q4), T-1978 found the same hole for typography.
RenderSettings.theme: String? becomes RenderSettings.palette: WebPaletteFeed? (theme key + WebContrastMode as one value), and a new RenderSettings.typography: [String: String] transport slot carries the CSS custom properties.WebPaletteFeed moves out of the view layer (DocumentScrollContent) into WebBridgeContract, since both delivery paths — the bridge push and the bake — now consume it.BlockHTMLEmitter grows two pure helpers: paletteAttributes(for:) emitting data-prism-theme plus data-prism-contrast="increased" only under increased contrast (mirroring what prism-theme.js does with an applyTheme payload), and rootStyleAttribute(for:) emitting the typography variables as a validated, key-sorted, HTML-escaped style attribute.FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:) becomes a static, pure builder of the whole render input, reading @Environment(\.colorSchemeContrast) and @Environment(\.dynamicTypeSize) and delegating typography composition to WebDocumentControllerFactory.typographyVariables.Contrast and Dynamic Type parameters carry no defaults, matching the convention established by T-1828/T-1829: a silently defaulted .standard/.large is invisible at the call site and is precisely the regression shape these tickets keep hitting. The cost is churn at call sites, paid once. Keeping the document surface's settings free of both baked fields preserves the parseRevision-only HTML cache key.
rootStyleAttribute is the interesting part. A style attribute is a declaration list, so an unescaped ; in a value opens a second declaration and {/} are the rule-escape shape; those are rejected outright along with control characters. The subtler class is a value that does not end: an unterminated string, a /* comment, an open (/[, or a trailing \ would consume the ; separator and swallow every later declaration. That is content loss rather than injection (the attribute is HTML-escaped on the way out, and the page runs under script-src 'none' with allowsContentJavaScript = false), but it fails silently, so leavesNoOpenConstruct rejects it. Round 2 of review correctly replaced counter-based checks with a single-pass scanner, because the constructs nest: a quote inside a comment, a ( inside a string, and an escaped quote are literal text. The carry nulling on two-scalar tokens is what makes /*/ stay open and stops the closing / of /*…*/ from starting a new comment.
Declarations are emitted in sorted key order because Swift dictionary iteration order is not stable across dictionaries, and the emitter must be a pure function of its inputs for the golden-fixture and off-main byte-parity tests. The determinism test is written correctly: it builds two dictionaries with insertion orders reversed rather than emitting one dictionary twice (a single dictionary iterates identically within a process because the hash seed is fixed per process).
An inline style on <html> beats :root declarations in document.css without !important, matching what a bridge applyTypography push does (both end up as inline root custom properties). Only typography variables are baked, so they cannot collide with the palette variables that :root[data-prism-contrast="increased"] overrides. The served CSP carries style-src 'unsafe-inline' prism-doc:, which is what makes the attribute effective at all — a fact worth pinning with a live assertion (see findings).
reset() and the HTMLBox seed still emit with a default RenderSettings(), so the transient empty page carries no palette — pre-existing behaviour, unchanged.\/) is followed by *: the scanner clears escaped but keeps previous == "/", so it opens a phantom comment. The failure direction is safe (drop → stylesheet fallback), and no value the app composes can reach it.typographyVariables emits no --prism-font-mono, so footnote inline code stays monospace (Req 1.4).prism/Services/WebRendering/BlockHTMLEmitter.swift
Why it matters. This is the only new attack-adjacent surface in the change: it writes a UserDefaults-derived font family into a style attribute. It is also the only place a silent content-loss bug could hide (an unterminated construct swallowing later declarations).
What to look at. BlockHTMLEmitter.swift:186-306 (rootStyleAttribute, isSafeCustomProperty, leavesNoOpenConstruct)
prism/Services/WebRendering/RenderSettings.swift
Why it matters. Changes the emitter's public input shape and is the mechanism that makes 'theme without its contrast variant' unrepresentable in a bake.
What to look at. RenderSettings.swift:29-56, 108-120
prism/ViewModels/WebBridgeContract.swift
Why it matters. It is now consumed by both delivery paths (bridge push and bake) and by a nonisolated, Sendable emitter input, so its old home in DocumentScrollContent was wrong.
What to look at. WebBridgeContract.swift:172-183; DocumentScrollContent.swift (deletion of the old declaration)
prism/Views/FootnotePopoverView.swift
Why it matters. It is the seam that decides what a bridge-less page knows, and it is the value a future re-present key (T-1979) will observe.
What to look at. FootnotePopoverView.swift:45-80
prismTests/WebRendering/WebFootnotePopoverTypographyTests.swift
Why it matters. 485 lines covering emitter, feed, and end-to-end emitted HTML — including two tests that pin the document surface's root tag as exactly <html>, which is what protects the parseRevision-only HTML cache.
What to look at. WebFootnotePopoverTypographyTests.swift (whole file); TypographyDefaultsScope.swift
Q4's premise ("the popover's content consumes none of the five overridden tokens") did not hold: .prism-comment-inline is coloured --prism-text-tertiary, and an inline HTML comment inside a footnote paragraph survives contentBlocks filtering. The decision log records the supersession in place and rewrites Decision 4's rejected alternative to scope its rejection to the document surface. Correctly done — the rejection there (re-emit and reload on toggle loses reading position) genuinely does not carry over to a page re-emitted on every present.
Its HTML is cached per parseRevision (offmain-html-emit Decision 1), so any baked field that changes without a reparse would serve stale HTML. Enforced by documentRenderSettings passing neither, and pinned by documentSurfaceRootTagUnchanged and documentSurfaceBakesNoContrast.
The fix is recorded in the owning spec instead (specs/a11y-increase-contrast/ decision log + smolspec), matching the pattern used by the most recent comparable bugfix on main (e7605a7, T-1827/T-1828, which updated specs/font-settings/decision_log.md and an agent note). Consistent with recent practice.
The emitter is the last place before the bytes are written and is shared by every future baking caller, so the boundary sits there. The doc comment is explicit that it guarantees containment, not CSS well-formedness — an important distinction to keep, since a future variable could pass validation and still be meaningless CSS (it would then fall back to the stylesheet value).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | prismTests/WebRendering/WebFootnotePopoverTypographyTests.swift | Popover coverage stops at the emitted HTML string: no live-WebPage assertion that the baked <html style> actually changes the rendered computed style. The suite that owns the popover (WebFootnotePopoverTests) already has live-page infrastructure (WebPage + callJavaScript polling), and the document surface has liveTypographyVariablesApply for exactly this. A CSP change (style-src losing 'unsafe-inline') or a specificity regression would leave every one of these tests green while the popover renders at the fallback size — the same 'value composed natively but never reaching the page' shape as the bug being fixed. | Not fixed (report-only pass). Suggested: one live test presenting a footnote at .accessibility5 and asserting getComputedStyle(document.body).fontSize differs from the .large case. Risk today is low: the served CSP carries style-src 'unsafe-inline' prism-doc:, and inline root styles beat :root declarations. |
| minor | prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift | settingsChangesRePush() failed once, in the en-GB configuration only, during a 10-suite parallel run (2.17s vs ~0.25s in the passing configurations — its waitUntil poll hit the timeout while OffMainEmitTests.largeSampleEmitPerformance saturated the CPU in sibling processes). Not related to this PR: the branch does not touch the synchronizer. | Verified as a flake, not fixed: the suite passes in isolation (11/11) and the identical 10-suite run passed on rerun (76/76). Worth knowing about for CI stability; the poll timeout is load-sensitive. |
| minor | prism/Services/WebRendering/BlockHTMLEmitter.swift | paletteAttributes(for:) and rootStyleAttribute(for:) are internal but used only inside BlockHTMLEmitter.swift; no test calls them directly (the suite drives them through emit). They widen the emitter's surface beyond emit for no current caller. | Not fixed (report-only, and it is a judgement call — a future baking surface would want them). Could be private static. |
| nit | prism/Services/WebRendering/BlockHTMLEmitter.swift | leavesNoOpenConstruct over-rejects a value where an escaped solidus is followed by an asterisk (e.g. "a\\/*b"): the escaped branch clears the escape flag but still stores '/' in previous, so the next '*' opens a phantom comment and the value is dropped. In CSS the escaped '/' cannot start a comment. | Not fixed. The failure direction is safe (drop → stylesheet fallback, logged), and no value the app composes can contain a backslash-solidus sequence — cssQuotedFamily only ever escapes backslash and double-quote. |
| nit | specs/offmain-html-emit/decision_log.md | Decision 1 ("cache emitted HTML keyed by parseRevision alone, not RenderSettings") now has a new adjacent landmine: RenderSettings can carry per-view state (palette, typography) that changes without a reparse. Nothing in that spec points at the new constraint; the knowledge lives in RenderSettings' doc comments and two tests. | Not fixed (no spec files created per instructions). A one-line cross-reference in that decision log would put the warning where a future reader of the cache design looks. |
| nit | specs/font-settings/decision_log.md | Decision 18 establishes typographyVariables as the single composition point; this PR adds its second consumer (a baking, non-pushing one). The a11y spec records the palette side (Decision 5) but the font-settings log gains nothing, so the popover consumer is discoverable only from CLAUDE.md and the code comments. | Not fixed. Arguably adequate: the code cites Decision 18 at the call site, and CLAUDE.md's footnote section now spells out what the bridge-less page bakes. |
Click to expand.
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 7734b9f..6327e23 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -25,6 +25,7 @@ // import Foundation+import OSLog /// The emitted document and its source map. nonisolated struct EmittedDocument: Equatable, Sendable {@@ -138,13 +139,18 @@ nonisolated enum BlockHTMLEmitter { let imageAccessGrantable = "" #endif let grantAccessLabel = HTMLEscaping.escapeAttribute(settings.strings.grantFolderAccess)- // Bake the theme key onto <html> when supplied (the footnote popover page, which+ // Bake the palette onto <html> when supplied (the footnote popover page, which // has no live bridge to push applyTheme) so it renders the right palette rather // than the light :root default in dark mode (Req 1.3, T-1542). The document surface- // leaves it nil and lets its bridge push the theme on ready.- let htmlOpen = settings.theme.map {- "<html data-prism-theme=\"\(HTMLEscaping.escapeAttribute($0))\">"- } ?? "<html>"+ // leaves it nil and lets its bridge push the palette on ready.+ // Bake the typography custom properties the same way and for the same reason+ // (T-1978): a bridge-less page never receives `applyTypography`, so without this+ // the footnote popover renders at document.css's pre-bridge fallbacks (system+ // font, 17px, scale 1) while the document around it follows the user's body+ // font, app scale, and Dynamic Type size. The document surface passes none and+ // lets its bridge push them live.+ let htmlOpen = "<html\(paletteAttributes(for: settings.palette))"+ + "\(rootStyleAttribute(for: settings.typography))>" return """ <!DOCTYPE html> \(htmlOpen)@@ -164,6 +170,142 @@ nonisolated enum BlockHTMLEmitter { """ } + /// The `<html>` palette attributes (`data-prism-theme`, `data-prism-contrast`) for a+ /// baked palette, or `""` when there is none.+ ///+ /// Mirrors what `prism-theme.js` does with an `applyTheme` payload, so a baked page+ /// and a bridged one select the same CSS blocks: the theme key always, and+ /// `data-prism-contrast="increased"` only under Increase Contrast (the standard state+ /// is the absence of the attribute, per `document.css`'s override selectors).+ /// Baking the pair from one value keeps them in step by construction (T-1829).+ static func paletteAttributes(for palette: WebPaletteFeed?) -> String {+ guard let palette else { return "" }+ let theme = " data-prism-theme=\"\(HTMLEscaping.escapeAttribute(palette.themeKey))\""+ let contrast = palette.contrast == .increased ? " data-prism-contrast=\"increased\"" : ""+ return theme + contrast+ }++ /// The `<html>` inline `style` attribute carrying `variables` as root custom+ /// properties, or `""` when there is nothing safe to bake (T-1978).+ ///+ /// Inline styles outrank `document.css`'s `:root` fallbacks, so a baked value wins+ /// exactly the way a bridge `applyTypography` push does.+ ///+ /// Declarations are emitted in key order: the emitter is a pure function of its+ /// inputs (golden-fixture testable) and dictionary iteration order is not stable.+ ///+ /// Names and values are validated, not trusted. `--prism-font-family` derives from+ /// `AppSettings.bodyFontFamily`, a `UserDefaults` string a tampered or synced+ /// preference can set to anything.+ ///+ /// What the check guarantees: nothing baked here can escape its declaration or the+ /// attribute. A style attribute is a declaration list, so a `;` in a value would open+ /// a second declaration; `{`/`}` are the rule-escape shape; control characters are+ /// illegal in a CSS string; a value left inside an unterminated construct — a string,+ /// a `/*` comment, a `(`/`[` block, or a dangling `\` — would swallow the `;` that+ /// ends it and with it the declarations that follow (loss rather than injection); and+ /// the whole attribute is HTML-escaped on the way out.+ ///+ /// What it does NOT guarantee: that an arbitrary value is *meaningful* CSS. It is a+ /// containment boundary, not a CSS value parser — a value that needs quoting or+ /// escaping must already arrive quoted and escaped (see `WebTypography.cssQuotedFamily`,+ /// which is what makes the family safe today). A new variable added here inherits the+ /// containment, not the well-formedness: an unquoted value that trips these rules is+ /// dropped (and logged), degrading to `document.css`'s fallback.+ static func rootStyleAttribute(for variables: [String: String]) -> String {+ let declarations = variables+ .sorted { $0.key < $1.key }+ .filter { name, value in+ guard isSafeCustomProperty(name: name, value: value) else {+ // Dropping silently leaves a page at the stylesheet fallback with+ // nothing to diagnose from; the name alone localises the culprit+ // without logging a user's (possibly synced) preference value.+ logger.debug("Dropped unsafe root custom property: \(name, privacy: .public)")+ return false+ }+ return true+ }+ .map { "\($0.key): \($0.value)" }+ guard !declarations.isEmpty else { return "" }+ return " style=\"\(HTMLEscaping.escapeAttribute(declarations.joined(separator: "; ")))\""+ }++ private static let logger = Logger.prism(category: "BlockHTMLEmitter")++ /// The scalars a custom-property name may contain (beyond its `--` prefix).+ private static let allowedCustomPropertyNameScalars =+ CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))++ /// Whether `name`/`value` may be baked into the root style attribute.+ private static func isSafeCustomProperty(name: String, value: String) -> Bool {+ guard name.hasPrefix("--"), name.count > 2 else { return false }+ guard name.unicodeScalars.allSatisfy({+ allowedCustomPropertyNameScalars.contains($0)+ }) else { return false }++ guard !value.isEmpty else { return false }+ guard !value.unicodeScalars.contains(where: { scalar in+ CharacterSet.controlCharacters.contains(scalar)+ || scalar == ";" || scalar == "{" || scalar == "}"+ }) else { return false }+ return leavesNoOpenConstruct(value)+ }++ /// Whether `value` ends with every CSS construct that can span a `;` closed.+ ///+ /// A value that ends inside a string (an unbalanced `"` or `'`), inside a `/*` comment,+ /// inside a `(` or `[` block, or on a trailing `\` continues into whatever follows it in+ /// the attribute: the `; ` separating it from the next declaration is consumed as part of+ /// the unterminated construct, so the remaining declarations are swallowed. That is+ /// content loss rather than injection — the attribute is still HTML-escaped — but it+ /// silently disables every later variable, so it is rejected.+ ///+ /// The constructs nest, so this is a scanner rather than a set of counters: a quote inside+ /// a comment, a `(` inside a string, and an escaped quote are all literal text and must not+ /// be treated as delimiters. An unmatched closer (`)`, `]`) is rejected too — it cannot+ /// swallow anything, but a value whose brackets do not pair is not a value this bake+ /// understands.+ private static func leavesNoOpenConstruct(_ value: String) -> Bool {+ var openDelimiter: Unicode.Scalar?+ var openBlocks: [Unicode.Scalar] = []+ var escaped = false+ var inComment = false+ var previous: Unicode.Scalar?++ for scalar in value.unicodeScalars {+ // The scalar remembered for the next iteration. Nulled when this one completed a+ // two-scalar token, so neither half can pair again: `/*/` stays open, and the `/`+ // that closed `/*…*/` cannot start a second comment with a following `*`.+ var carry: Unicode.Scalar? = scalar+ if inComment {+ // `*/` closes; nothing inside a comment is a string or a block delimiter.+ if previous == "*" && scalar == "/" {+ inComment = false+ carry = nil+ }+ } else if escaped {+ escaped = false+ } else if scalar == "\\" {+ escaped = true+ } else if let delimiter = openDelimiter {+ if scalar == delimiter { openDelimiter = nil }+ } else if scalar == "\"" || scalar == "'" {+ openDelimiter = scalar+ } else if previous == "/" && scalar == "*" {+ inComment = true+ carry = nil+ } else if scalar == "(" || scalar == "[" {+ openBlocks.append(scalar)+ } else if scalar == ")" || scalar == "]" {+ let opener: Unicode.Scalar = scalar == ")" ? "(" : "["+ guard openBlocks.last == opener else { return false }+ openBlocks.removeLast()+ }+ previous = carry+ }+ return openDelimiter == nil && openBlocks.isEmpty && !escaped && !inComment+ }+ // MARK: - Per-block emission (total: never throws, never drops) private static func emitBlock(_ block: MarkdownBlock, domID: String, sourceIndex: Int, context: Context) -> String {
diff --git a/prism/Services/WebRendering/RenderSettings.swift b/prism/Services/WebRendering/RenderSettings.swiftindex 9e2a6a0..b1a3c9a 100644--- a/prism/Services/WebRendering/RenderSettings.swift+++ b/prism/Services/WebRendering/RenderSettings.swift@@ -26,12 +26,31 @@ nonisolated struct RenderSettings: Equatable, Sendable { /// it hidden via a CSS class the stylesheet collapses. var showHTMLComments: Bool - /// The theme key baked onto `<html data-prism-theme="…">` so the document- /// renders in the right palette before (or without) a bridge `applyTheme`.- /// The document surface leaves this nil — its live bridge pushes the theme on- /// ready — but the footnote popover page has no bridge, so it bakes the current- /// theme here to avoid rendering the light default in dark mode (Req 1.3, T-1542).- var theme: String?+ /// The palette baked onto `<html data-prism-theme="…" data-prism-contrast="…">` so+ /// the document renders in the right palette before (or without) a bridge+ /// `applyTheme`. The document surface leaves this nil — its live bridge pushes the+ /// palette on ready — but the footnote popover page has no bridge, so it bakes the+ /// current palette here to avoid rendering the light default in dark mode+ /// (Req 1.3, T-1542) or the standard palette under Increase Contrast (T-1829).+ ///+ /// Theme key and contrast travel as one `WebPaletteFeed`, the same pair the bridge+ /// push carries, so a bake can never carry one without the other.+ var palette: WebPaletteFeed?++ /// Typography custom properties baked onto `<html style="…">` for pages that have+ /// no bridge to push `applyTypography` (T-1978).+ ///+ /// The document surface leaves this empty — its bridge pushes typography live, and+ /// baking it would make the cached-per-`parseRevision` HTML depend on a setting that+ /// changes without a reparse. The footnote popover page is bridge-less, so without+ /// this it keeps `document.css`'s pre-bridge fallbacks (system font, 17px, scale 1)+ /// while the document around it renders at the user's body font, app scale, and+ /// Dynamic Type size.+ ///+ /// Values come from `WebDocumentControllerFactory.typographyVariables`, the single+ /// composition point for typography (font-settings Decision 18) — this is a transport+ /// slot, not a second resolver. The emitter validates and escapes what it bakes.+ var typography: [String: String] /// Catalog-resolved UI strings the emitter writes into chrome elements. /// Every string here originates from `String(localized:)` on the native side@@ -89,11 +108,13 @@ nonisolated struct RenderSettings: Equatable, Sendable { init( showHTMLComments: Bool = false,- theme: String? = nil,+ palette: WebPaletteFeed? = nil,+ typography: [String: String] = [:], strings: Strings = .fallback ) { self.showHTMLComments = showHTMLComments- self.theme = theme+ self.palette = palette+ self.typography = typography self.strings = strings } }
diff --git a/prism/ViewModels/WebBridgeContract.swift b/prism/ViewModels/WebBridgeContract.swiftindex 8cb2def..a6a5cd8 100644--- a/prism/ViewModels/WebBridgeContract.swift+++ b/prism/ViewModels/WebBridgeContract.swift@@ -169,6 +169,21 @@ enum WebContrastMode: String, Equatable, Sendable { case increased } +/// The palette state a rendered page needs: the colorScheme-resolved theme key and the+/// system Increase Contrast state. Grouped into one value so the two can never be+/// applied out of step — the contrast overrides are per-theme, so a split would pair+/// one theme's palette with another's overrides (T-1829).+///+/// Both delivery paths carry this pair whole: the document surface pushes it over the+/// bridge as one `applyTheme` (a single `.onChange` on this value covers every input),+/// and bridge-less pages bake it into the emitted HTML via `RenderSettings.palette`+/// (T-1978). It lives here, beside `WebContrastMode`, because both paths consume it;+/// the SwiftUI-dependent `ColorSchemeContrast` mapping stays in the view layer.+struct WebPaletteFeed: Equatable, Sendable {+ let themeKey: String+ let contrast: WebContrastMode+}+ /// 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).
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 1e80b26..752161d 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -206,7 +206,7 @@ enum WebDocumentControllerFactory { /// The `RenderSettings` for the document surface: the comment-visibility flag plus /// catalog-resolved chrome strings. Shared by the off-main precompute and the synchronous /// cache-miss fallback so both emit byte-identical HTML. (The footnote popover builds its- /// own settings with a `theme:`, not this.)+ /// own settings, baking `palette:` and `typography:` because it has no bridge; not this.) private static func documentRenderSettings(from settings: AppSettings) -> RenderSettings { RenderSettings(showHTMLComments: settings.showHTMLComments, strings: catalogStrings()) }
diff --git a/prism/Views/FootnotePopoverView.swift b/prism/Views/FootnotePopoverView.swiftindex 4c40969..1fdb637 100644--- a/prism/Views/FootnotePopoverView.swift+++ b/prism/Views/FootnotePopoverView.swift@@ -25,6 +25,8 @@ struct FootnotePopoverView: View { @Environment(\.themeColors) private var colors @Environment(\.colorScheme) private var colorScheme+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(AppSettings.self) private var settings private var definition: FootnoteDefinition? {@@ -32,12 +34,47 @@ struct FootnotePopoverView: View { } private var renderSettings: RenderSettings {- // Bake the active theme into the emitted HTML: the popover page has no live+ Self.renderSettings(+ settings: settings,+ colorScheme: colorScheme,+ contrast: WebContrastMode(colorSchemeContrast),+ dynamicTypeSize: dynamicTypeSize+ )+ }++ /// The popover page's complete render input, as one equatable value.+ ///+ /// Static and pure so it can be asserted without mounting the view — and so a later+ /// change can key a re-present on it (T-1979) rather than re-deriving the inputs.+ ///+ /// `contrast` and `dynamicTypeSize` carry no default, matching `typographyVariables`+ /// and `pushInitialState`: a silently `.standard`/`.large` default is invisible at the+ /// call site and is exactly the accessibility regression this fix closes.+ static func renderSettings(+ settings: AppSettings,+ colorScheme: ColorScheme,+ contrast: WebContrastMode,+ dynamicTypeSize: DynamicTypeSize+ ) -> RenderSettings {+ // Bake the active palette into the emitted HTML: the popover page has no live // bridge to push applyTheme, so without this it renders the light default in- // dark mode (white background, T-1542).+ // dark mode (white background, T-1542) and the standard palette under Increase+ // Contrast (T-1829). Theme key and contrast travel as the one pair the bridge+ // push also carries, so the popover can never wear one theme's contrast overrides. RenderSettings( showHTMLComments: settings.showHTMLComments,- theme: settings.theme(for: colorScheme).rawValue,+ palette: WebPaletteFeed(+ themeKey: settings.theme(for: colorScheme).rawValue, contrast: contrast+ ),+ // Same reason, for typography (T-1978): with no bridge to push+ // applyTypography the popover would keep document.css's fallbacks while the+ // document around it follows the user's body font, app scale, and Dynamic+ // Type size. Composed by the ONE typography composition point rather than+ // resolved again here — a second resolver is how these drift apart+ // (font-settings Decision 18).+ typography: WebDocumentControllerFactory.typographyVariables(+ settings: settings, dynamicTypeSize: dynamicTypeSize+ ), strings: WebDocumentControllerFactory.catalogStrings() ) }
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 1cf1ff0..cf0c484 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -422,14 +422,6 @@ struct WebSearchStateKey: Equatable { } } -/// 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
diff --git a/prismTests/WebRendering/WebFootnotePopoverTypographyTests.swift b/prismTests/WebRendering/WebFootnotePopoverTypographyTests.swiftnew file mode 100644index 0000000..aa36a93--- /dev/null+++ b/prismTests/WebRendering/WebFootnotePopoverTypographyTests.swift@@ -0,0 +1,485 @@+//+// WebFootnotePopoverTypographyTests.swift+// prismTests+//+// Regression tests for T-1978: the footnote popover ignored the user's Body Font,+// in-app text scale, and Dynamic Type size.+//+// The popover page is bridge-less by design (`FootnotePopoverWebPage`), so it never+// received `applyTypography` and kept `document.css`'s pre-bridge fallbacks (system+// font, 17px, scale 1) while the document around it rendered at the user's settings.+// At accessibility Dynamic Type sizes that is an accessibility failure, not just a+// visual mismatch.+//+// Expected: the typography variables — composed by the ONE composition point,+// `WebDocumentControllerFactory.typographyVariables` (font-settings Decision 18) — are+// baked into the emitted HTML as root custom properties.+//+// The same bridge-less-page failure class covered the Increase Contrast state, which+// reaches the document surface as `applyTheme`'s `contrast` → `data-prism-contrast`+// (T-1829): the popover baked only the theme key, so it rendered the standard palette+// with Increase Contrast on. It is baked as one `WebPaletteFeed` pair here.+//+// Layers covered:+// - Emitter: root custom properties are baked, deterministically ordered, escaped,+// and validated; the palette (theme key + contrast) is baked as one pair; the+// document surface (empty typography, no palette) is unchanged.+// - Feed: `FootnotePopoverView.renderSettings` composes settings + colour scheme ++// contrast + Dynamic Type into one equatable render input.+// - End to end: `FootnotePopoverWebPage.emitHTML` carries body font, app scale,+// an accessibility Dynamic Type size, and the Increase Contrast state.+//++import Foundation+import SwiftUI+import Testing+@testable import prism++@MainActor+struct WebFootnotePopoverTypographyTests {++ // MARK: - Helpers++ /// This suite mutates the typography preferences via `makeForTesting`, so it puts them+ /// back (they are process-global and would otherwise change what every later suite's+ /// `AppSettings()` reads). Shared with `WebTypographyBridgeTests` via+ /// `TypographyDefaultsScope`.+ private func withRestoredTypographyDefaults<T>(_ body: () throws -> T) rethrows -> T {+ try TypographyDefaultsScope.withRestored(body)+ }++ private static func installedFamily() throws -> String {+ try #require(FontUtilities.allFamilies.first, "the test host must have fonts")+ }++ private func footnoteData() -> FootnoteData {+ FootnoteData(+ definitions: [+ "1": FootnoteDefinition(identifier: "1", displayNumber: 1, content: "First footnote body."),+ ],+ referenceOrder: ["1"]+ )+ }++ /// The opening `<html …>` tag of an emitted document.+ private func htmlOpenTag(_ html: String) throws -> String {+ let start = try #require(html.range(of: "<html"))+ let end = try #require(html.range(of: ">", range: start.lowerBound..<html.endIndex))+ return String(html[start.lowerBound..<end.upperBound])+ }++ // MARK: - Emitter: baked root custom properties++ @Test("Emitted HTML bakes the typography variables as root custom properties")+ func emitsRootCustomProperties() throws {+ let settings = RenderSettings(typography: [+ "--prism-scale": "1.500",+ "--prism-font-size": "40.00px",+ "--prism-font-family": "\"Palatino\", \(WebTypography.systemFontStack)",+ ])+ let html = BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")], footnotes: .empty, settings: settings+ ).html++ let open = try htmlOpenTag(html)+ #expect(open.contains("--prism-scale: 1.500"))+ #expect(open.contains("--prism-font-size: 40.00px"))+ #expect(open.contains("--prism-font-family: "Palatino""))+ }++ @Test("Baked declarations are deterministically ordered (golden-fixture stable)")+ func bakedDeclarationsAreDeterministic() throws {+ // Dictionary iteration order is not stable; the emitter must be a pure function+ // of its inputs, so the same VARIABLES emit the same HTML however the dictionary+ // was built. Emitting one dictionary repeatedly would not test this: the hash+ // seed is fixed for the lifetime of a process, so a single dictionary iterates+ // the same way every time, sorted or not.+ let pairs = [+ ("--prism-font-family", WebTypography.systemFontStack),+ ("--prism-font-size", "17.00px"),+ ("--prism-scale", "1.000"),+ ]+ var forward: [String: String] = [:]+ for (name, value) in pairs { forward[name] = value }+ var reversed: [String: String] = [:]+ for (name, value) in pairs.reversed() { reversed[name] = value }++ func emit(_ variables: [String: String]) throws -> String {+ try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: variables)+ ).html)+ }+ let fromForward = try emit(forward)+ let fromReversed = try emit(reversed)+ #expect(fromForward == fromReversed)++ // …and the order is key order, pinned literally so the assertion cannot pass by+ // two identical accidents.+ let declarations = pairs.map { "\($0.0): \($0.1)" }.joined(separator: "; ")+ #expect(fromForward.contains(HTMLEscaping.escapeAttribute(declarations)))+ }++ @Test("The document surface (no baked typography) emits an unchanged root tag")+ func documentSurfaceRootTagUnchanged() throws {+ // The document surface leaves typography empty and lets its bridge push it, so+ // its cached-per-parseRevision HTML must not gain a style attribute.+ let html = BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")], footnotes: .empty, settings: RenderSettings()+ ).html+ let open = try htmlOpenTag(html)+ #expect(open == "<html>")+ }++ @Test("A baked palette and baked typography coexist on the root tag")+ func themeAndTypographyCoexist() throws {+ let html = BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(+ palette: WebPaletteFeed(themeKey: "prism-dark", contrast: .standard),+ typography: ["--prism-scale": "1.200"]+ )+ ).html+ let open = try htmlOpenTag(html)+ #expect(open.contains("data-prism-theme=\"prism-dark\""))+ #expect(open.contains("--prism-scale: 1.200"))+ }++ // MARK: - Emitter: baked contrast++ @Test("Increase Contrast is baked as data-prism-contrast, standard leaves it absent")+ func contrastIsBakedWithTheTheme() throws {+ // document.css selects its Increase Contrast overrides on+ // `:root[data-prism-contrast="increased"]`, and the standard state is the ABSENCE+ // of the attribute — the same mapping prism-theme.js applies to an applyTheme+ // payload. Baking only the theme key left the popover on the standard palette+ // under Increase Contrast: the same bridge-less-page failure class as T-1978.+ func open(_ contrast: WebContrastMode) throws -> String {+ try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(+ palette: WebPaletteFeed(themeKey: "prism-dark", contrast: contrast)+ )+ ).html)+ }+ let increased = try open(.increased)+ let standard = try open(.standard)+ #expect(increased.contains("data-prism-contrast=\"increased\""))+ #expect(!standard.contains("data-prism-contrast"))+ // Never one without the other: the theme key rides along in both states.+ #expect(increased.contains("data-prism-theme=\"prism-dark\""))+ #expect(standard.contains("data-prism-theme=\"prism-dark\""))+ }++ @Test("The document surface bakes no contrast attribute")+ func documentSurfaceBakesNoContrast() throws {+ // The document surface's HTML is cached per parseRevision; its bridge pushes the+ // palette live, so nothing palette-shaped may enter that cache.+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")], footnotes: .empty, settings: RenderSettings()+ ).html)+ #expect(!open.contains("data-prism-contrast"))+ #expect(!open.contains("data-prism-theme"))+ }++ @Test("The popover render input carries the Increase Contrast state")+ func renderSettingsCarryContrast() {+ withRestoredTypographyDefaults {+ let settings = AppSettings.makeForTesting()+ func rendered(_ contrast: WebContrastMode) -> RenderSettings {+ FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: contrast, dynamicTypeSize: .large+ )+ }+ #expect(rendered(.increased).palette?.contrast == .increased)+ #expect(rendered(.standard).palette?.contrast == .standard)+ // The theme key travels with it, as one pair (T-1829).+ #expect(rendered(.increased).palette?.themeKey+ == settings.theme(for: .light).rawValue)+ // …and the contrast state moves the equatable render input, so a later+ // re-present key (T-1979) observes it.+ #expect(rendered(.increased) != rendered(.standard))+ }+ }++ @Test("Popover content under Increase Contrast carries the attribute end to end")+ func popoverBakesContrastEndToEnd() throws {+ try withRestoredTypographyDefaults {+ let html = FootnotePopoverWebPage.emitHTML(+ footnoteId: "1",+ data: footnoteData(),+ settings: FootnotePopoverView.renderSettings(+ settings: AppSettings.makeForTesting(), colorScheme: .dark,+ contrast: .increased, dynamicTypeSize: .large+ )+ )+ let open = try htmlOpenTag(html)+ #expect(open.contains("data-prism-contrast=\"increased\""))+ }+ }++ @Test("Unsafe custom-property names and values are dropped, not baked")+ func unsafeVariablesAreDropped() throws {+ // `bodyFontFamily` is a UserDefaults string a tampered/synced preference can set+ // to anything, so the bake is validated rather than trusted. A style attribute is+ // a declaration list: a `;` in a value would start a second declaration.+ let settings = RenderSettings(typography: [+ "--prism-scale": "1.000",+ "color": "red", // not a custom property+ "--prism-evil": "red; position: fixed", // declaration injection+ "--prism-brace": "red } body { x: y", // rule-escape shape+ "--prism-newline": "red\nx: y", // control character+ "--prism bad name": "1", // illegal name+ ])+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")], footnotes: .empty, settings: settings+ ).html)++ #expect(open.contains("--prism-scale: 1.000"))+ #expect(!open.contains("color"))+ #expect(!open.contains("position: fixed"))+ #expect(!open.contains("--prism-brace"))+ #expect(!open.contains("--prism-newline"))+ #expect(!open.contains("--prism bad name"))+ }++ @Test("A value that leaves a CSS string open is dropped, not baked")+ func unbalancedQuoteIsDropped() throws {+ // An unterminated string continues into whatever follows it in the declaration+ // list, swallowing the remaining declarations. That is content loss rather than+ // injection (the attribute is still HTML-escaped), but it silently disables every+ // later variable, so the value is rejected and the page keeps the CSS fallback.+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: [+ "--prism-font-family": "\"Unclosed",+ "--prism-single-quote": "'Unclosed",+ "--prism-scale": "1.000",+ ])+ ).html)++ #expect(!open.contains("--prism-font-family"))+ #expect(!open.contains("--prism-single-quote"))+ // The well-formed sibling still bakes — one bad value does not void the rest.+ #expect(open.contains("--prism-scale: 1.000"))+ }++ @Test("A value ending on a dangling escape is dropped, not baked")+ func trailingBackslashIsDropped() throws {+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: [+ "--prism-font-family": "Georgia\\",+ "--prism-scale": "1.000",+ ])+ ).html)++ #expect(!open.contains("--prism-font-family"))+ #expect(open.contains("--prism-scale: 1.000"))+ }++ @Test("A value left inside a CSS comment or an open block is dropped, not baked")+ func unterminatedCommentOrBlockIsDropped() throws {+ // Same containment failure as the unbalanced quote above: the `; ` separating this+ // declaration from the next is consumed by the unterminated construct, so every+ // later variable is silently swallowed.+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: [+ "--prism-comment": "Georgia /* unterminated",+ "--prism-half-comment": "Georgia /*/",+ "--prism-paren": "rgb(0, 0",+ "--prism-bracket": "[line-start",+ "--prism-stray-close": "Georgia)",+ "--prism-scale": "1.000",+ ])+ ).html)++ #expect(!open.contains("--prism-comment"))+ #expect(!open.contains("--prism-half-comment"))+ #expect(!open.contains("--prism-paren"))+ #expect(!open.contains("--prism-bracket"))+ #expect(!open.contains("--prism-stray-close"))+ #expect(open.contains("--prism-scale: 1.000"))+ }++ @Test("Closed comments and balanced blocks still bake")+ func closedCommentsAndBalancedBlocksAreKept() throws {+ // The boundary rejects values that do not *end*, not values that contain the+ // characters: a completed comment and a balanced function are self-contained.+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: [+ "--prism-closed-comment": "Georgia /* note */",+ "--prism-function": "clamp(1rem, 2vw, 3rem)",+ "--prism-quoted-paren": "\"Georgia (Pro)\"",+ ])+ ).html)++ #expect(open.contains("--prism-closed-comment:"))+ #expect(open.contains("--prism-function:"))+ #expect(open.contains("--prism-quoted-paren:"))+ }++ @Test("Balanced strings and escaped quotes still bake")+ func balancedStringsAndEscapesAreKept() throws {+ // The check is a containment boundary, not a quote counter: an escaped quote and+ // an apostrophe inside a double-quoted family are literal text, and a family that+ // `cssQuotedFamily` produced must survive it.+ let quoted = try #require(WebTypography.cssQuotedFamily("Hoefler's \"Text\""))+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: ["--prism-font-family": quoted])+ ).html)++ #expect(open.contains("--prism-font-family:"))+ }++ @Test("A quoted family with a double quote stays inside the attribute")+ func quotedFamilyIsAttributeEscaped() throws {+ let family = try #require(WebTypography.cssQuotedFamily("Ev\"il"))+ let open = try htmlOpenTag(BlockHTMLEmitter.emit(+ blocks: [.paragraph(markdown: "Body")],+ footnotes: .empty,+ settings: RenderSettings(typography: ["--prism-font-family": family])+ ).html)+ // The raw quote never appears unescaped inside the attribute value.+ #expect(open.contains("""))+ #expect(!open.contains("Ev\"il"))+ }++ // MARK: - Feed: the popover's render input++ @Test("The popover render settings carry the composed typography variables")+ func renderSettingsCarryTypography() {+ withRestoredTypographyDefaults {+ let settings = AppSettings.makeForTesting()+ let rendered = FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .accessibility3+ )+ // Reuses the single composition point rather than resolving fonts again.+ #expect(rendered.typography == WebDocumentControllerFactory.typographyVariables(+ settings: settings, dynamicTypeSize: .accessibility3+ ))+ }+ }++ @Test("The render input is an equatable value that changes with every typography input")+ func renderInputIsEquatableAndInputSensitive() throws {+ try withRestoredTypographyDefaults {+ let settings = AppSettings.makeForTesting()+ let baseline = FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ )+ #expect(baseline == FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ ))+ // Dynamic Type (view-world) …+ #expect(baseline != FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .accessibility5+ ))+ // … the in-app scale …+ let scaled = AppSettings.makeForTesting(textSizeScale: 150)+ #expect(baseline != FootnotePopoverView.renderSettings(+ settings: scaled, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ ))+ // … and the body font all move the value, so a later render-state key+ // (T-1979) can observe it.+ let fonted = AppSettings.makeForTesting(bodyFontFamily: try Self.installedFamily())+ #expect(baseline != FootnotePopoverView.renderSettings(+ settings: fonted, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ ))+ }+ }++ // MARK: - End to end: emitted popover content++ @Test("Popover content follows the selected body font")+ func popoverFollowsBodyFont() throws {+ try withRestoredTypographyDefaults {+ let family = try Self.installedFamily()+ let settings = AppSettings.makeForTesting(bodyFontFamily: family)+ let html = FootnotePopoverWebPage.emitHTML(+ footnoteId: "1",+ data: footnoteData(),+ settings: FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ )+ )+ let open = try htmlOpenTag(html)+ #expect(open.contains("--prism-font-family:"))+ #expect(open.contains(HTMLEscaping.escapeAttribute(family)))+ }+ }++ @Test("Popover content follows the in-app text scale")+ func popoverFollowsAppScale() throws {+ try withRestoredTypographyDefaults {+ let settings = AppSettings.makeForTesting(textSizeScale: 150)+ let html = FootnotePopoverWebPage.emitHTML(+ footnoteId: "1",+ data: footnoteData(),+ settings: FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: .large+ )+ )+ let open = try htmlOpenTag(html)+ #expect(open.contains("--prism-scale: 1.500"))+ }+ }++ @Test("Popover content follows an accessibility Dynamic Type size")+ func popoverFollowsAccessibilityDynamicType() throws {+ try withRestoredTypographyDefaults {+ let settings = AppSettings.makeForTesting()+ @MainActor func emitted(_ size: DynamicTypeSize) throws -> String {+ try htmlOpenTag(FootnotePopoverWebPage.emitHTML(+ footnoteId: "1",+ data: footnoteData(),+ settings: FootnotePopoverView.renderSettings(+ settings: settings, colorScheme: .light,+ contrast: .standard, dynamicTypeSize: size+ )+ ))+ }+ let large = try emitted(.large)+ let accessible = try emitted(.accessibility5)++ #expect(large.contains(+ "--prism-font-size: \(WebTypography.fontSizeValue(dynamicTypeSize: .large))"+ ))+ #expect(accessible.contains(+ "--prism-font-size: \(WebTypography.fontSizeValue(dynamicTypeSize: .accessibility5))"+ ))+ // The failure the ticket describes: the popover stayed at the default size+ // while the document scaled up.+ #expect(large != accessible)+ }+ }++ @Test("The dismissed popover bakes nothing (isolation, Req 8.4)")+ func resetContentBakesNoTypography() throws {+ let html = FootnotePopoverWebPage.emitHTML(+ footnoteId: nil, data: .empty, settings: RenderSettings()+ )+ let open = try htmlOpenTag(html)+ #expect(open == "<html>")+ }+}
diff --git a/prismTests/WebRendering/TypographyDefaultsScope.swift b/prismTests/WebRendering/TypographyDefaultsScope.swiftnew file mode 100644index 0000000..ba44162--- /dev/null+++ b/prismTests/WebRendering/TypographyDefaultsScope.swift@@ -0,0 +1,55 @@+//+// TypographyDefaultsScope.swift+// prismTests+//+// Shared save/restore scope for the typography preferences, used by every suite that+// builds a non-default `AppSettings.makeForTesting()`.+//+// `AppSettings` persists through `@AppStorage`, i.e. `UserDefaults.standard`, which is+// process-global: a suite that leaves a non-default `bodyFontFamily` behind changes what+// EVERY later suite's `AppSettings()` reads. That destabilised+// `WebStateSynchronizerAssemblyTests`, which compares a pushed typography dict against a+// separately computed expectation. Restoring here keeps the mutation inside the test that+// wanted it.+//+// It lives in its own file because more than one suite churns these keys now+// (`WebTypographyBridgeTests` for the bridge push, `WebFootnotePopoverTypographyTests`+// for the baked popover) — a copy per suite is how the ownership comment goes stale.+//++import Foundation++@MainActor+enum TypographyDefaultsScope {++ /// The `UserDefaults.standard` keys `AppSettings` backs the typography preferences with.+ static let keys = ["bodyFontFamily", "monoFontFamily", "textSizeScale"]++ /// Runs `body` with the typography preferences restored afterwards.+ static func withRestored<T>(_ body: () throws -> T) rethrows -> T {+ let saved = save()+ defer { restore(saved) }+ return try body()+ }++ /// The async form, for suites that await a live page.+ static func withRestored<T>(_ body: () async throws -> T) async rethrows -> T {+ let saved = save()+ defer { restore(saved) }+ return try await body()+ }++ private static func save() -> [(String, Any?)] {+ keys.map { ($0, UserDefaults.standard.object(forKey: $0)) }+ }++ private static func restore(_ saved: [(String, Any?)]) {+ for (key, value) in saved {+ if let value {+ UserDefaults.standard.set(value, forKey: key)+ } else {+ UserDefaults.standard.removeObject(forKey: key)+ }+ }+ }+}
diff --git a/prismTests/WebRendering/WebTypographyBridgeTests.swift b/prismTests/WebRendering/WebTypographyBridgeTests.swiftindex 930ccff..ca7df68 100644--- a/prismTests/WebRendering/WebTypographyBridgeTests.swift+++ b/prismTests/WebRendering/WebTypographyBridgeTests.swift@@ -36,45 +36,16 @@ struct WebTypographyBridgeTests { try #require(FontUtilities.allFamilies.first, "the test host must have fonts") } - /// The `UserDefaults.standard` keys `AppSettings` backs the typography preferences- /// with. This suite is the one that churns them, so it owns putting them back.- private static let typographyDefaultsKeys = ["bodyFontFamily", "monoFontFamily", "textSizeScale"]-- /// Runs `body` with this suite's typography preferences restored afterwards.- ///- /// `AppSettings` persists through `@AppStorage`, i.e. `UserDefaults.standard`, which- /// is process-global: a suite that leaves a non-default `bodyFontFamily` behind- /// changes what EVERY later suite's `AppSettings()` reads. That destabilised- /// `WebStateSynchronizerAssemblyTests`, which compares a pushed typography dict- /// against a separately computed expectation. Restoring here keeps the mutation- /// inside the test that wanted it.+ /// Runs `body` with the typography preferences restored afterwards. More than one+ /// suite churns them now, so the scope lives in `TypographyDefaultsScope` rather than+ /// being owned (and copied) here. private func withRestoredTypographyDefaults<T>(_ body: () throws -> T) throws -> T {- let saved = Self.typographyDefaultsKeys.map { ($0, UserDefaults.standard.object(forKey: $0)) }- defer {- for (key, value) in saved {- if let value {- UserDefaults.standard.set(value, forKey: key)- } else {- UserDefaults.standard.removeObject(forKey: key)- }- }- }- return try body()+ try TypographyDefaultsScope.withRestored(body) } /// The async form of `withRestoredTypographyDefaults`, for the assembly tests. private func withRestoredTypographyDefaults<T>(_ body: () async throws -> T) async throws -> T {- let saved = Self.typographyDefaultsKeys.map { ($0, UserDefaults.standard.object(forKey: $0)) }- defer {- for (key, value) in saved {- if let value {- UserDefaults.standard.set(value, forKey: key)- } else {- UserDefaults.standard.removeObject(forKey: key)- }- }- }- return try await body()+ try await TypographyDefaultsScope.withRestored(body) } /// The `--prism-font-size` string the platform's reading baseline resolves to at
diff --git a/specs/a11y-increase-contrast/decision_log.md b/specs/a11y-increase-contrast/decision_log.mdindex 17002fa..6e27ceb 100644--- a/specs/a11y-increase-contrast/decision_log.md+++ b/specs/a11y-increase-contrast/decision_log.md@@ -7,7 +7,7 @@ | 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 |+| Q4 | 2026-07-26 | The footnote popover page is **not** given a contrast path | **Superseded by Decision 5** (2026-07-30, T-1978 / PR #339): the popover now bakes the contrast state. Original rationale: 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@@ -143,7 +143,7 @@ Native pushes only the contrast STATE — a `WebContrastMode` that `prism-theme. - **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.+- **Bake `data-prism-contrast` into the emitted HTML like the baked theme key (`RenderSettings.theme`, since renamed to `.palette`)**: Rejected *for the document surface* — it would require a re-emit and reload on every toggle, losing reading position, the opposite of the live-update requirement. It is the right answer for a page that has no bridge and is re-emitted on every present: the footnote popover bakes it (Decision 5). ### Consequences @@ -156,3 +156,44 @@ Native pushes only the contrast STATE — a `WebContrastMode` that `prism-theme. - 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. ---++## Decision 5: Bake the contrast state into the footnote popover, paired with the theme key++**Date**: 2026-07-30+**Status**: accepted (supersedes Q4)++### Context++Q4 left the footnote popover page (`FootnotePopoverWebPage`) without a contrast path: it has no bridge, so contrast would have to be baked, and its content — paragraphs, lists and blockquotes — consumes none of the five overridden tokens, so the gap looked unobservable.++T-1978 then hit the identical failure class for typography: a bridge-less page renders whatever it is *not* given at emit time, so it fell back to `document.css`'s pre-bridge defaults. Fixing that added `RenderSettings.typography` and a validated `<html style>` bake, which made the contrast bake nearly free. Q4's "unobservable" premise was also weaker than it read. One overridden token does reach the popover today: `.prism-comment-inline` is coloured `--prism-text-tertiary`, and an inline HTML comment inside a footnote paragraph survives `FootnotePopoverWebPage.contentBlocks` (only *block* comments are filtered out), so with Show HTML comments on it renders at the standard tertiary colour while the document behind it uses the high-contrast one. Beyond that one visible case, the popover already baked the theme key — a page carrying one half of `applyTheme`'s payload and not the other is the same drop-a-state shape Q2 and Q3 were written to prevent.++### Decision++The footnote popover bakes the contrast state alongside the theme key. `RenderSettings.theme` becomes `RenderSettings.palette`, carrying both as one `WebPaletteFeed` — the same value the `applyTheme` bridge push carries — and `BlockHTMLEmitter.paletteAttributes` emits `data-prism-theme` plus, under increased contrast, `data-prism-contrast="increased"`. `WebPaletteFeed` moves from the view layer into `WebBridgeContract` now that both delivery paths consume it.++### Rationale++- A bake that can hold a theme key without its contrast variant is precisely the regression the rest of this spec is about; carrying the pair in one value makes the two applicable only together, by construction.+- The stylesheet already owns the values (Decision 4), so the popover needs the state and nothing else — the bake is two attributes, not a second palette representation.+- Decision 4 rejected baking *for the document surface* because a toggle would force a re-emit and reload. The popover is the opposite case: it is emitted fresh on every present and has no reading position to lose, so the rejection does not carry over.+- It removes an accessibility gap that was argued away rather than measured: the standard palette inside a popover while the document around it is high contrast.++### Alternatives Considered++- **Leave the gap (Q4's position)**: Cheapest, and mostly invisible. Rejected because the premise did not hold (`.prism-comment-inline` reaches an overridden token in a popover) and because the surrounding change made the fix a few lines.+- **Give the popover a bridge**: Would fix contrast, typography, and live updates at once. Rejected as far out of scope for a bugfix — a second bridged surface means a second generation-tagged message pipeline and its own recovery path, for a sheet whose whole content fits in one emit.+- **Bake contrast as a separate `RenderSettings` field**: A smaller diff than renaming `theme` → `palette`. Rejected because two independent fields are exactly what allows one to be set without the other; the failure this spec keeps meeting.++### Consequences++**Positive:**+- The popover renders the same palette *and* contrast variant as the document behind it.+- Baked and bridged palette delivery now read from one type, so a future palette input is added in one place.+- Q4's "revisit if the popover ever renders badges, comments or search highlights" is answered ahead of the trigger.++**Negative:**+- The popover still does not update live: a sheet already open when contrast (or typography) changes keeps its baked values until reopened. Tracked as T-1979.+- `RenderSettings.theme` was renamed, so spec text and agent notes referring to it needed a sweep.++---
diff --git a/specs/a11y-increase-contrast/smolspec.md b/specs/a11y-increase-contrast/smolspec.mdindex bd275d9..2579c8c 100644--- a/specs/a11y-increase-contrast/smolspec.md+++ b/specs/a11y-increase-contrast/smolspec.md@@ -101,7 +101,25 @@ holds on the surface where the affordance actually exists. It is an element opac 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.+**The footnote popover (T-1978, supersedes Q4).** The popover page+(`FootnotePopoverWebPage`) has no bridge, so it cannot receive `applyTheme`. T-1829 left it+without a contrast path (Q4, on the grounds that its content consumed none of the five+tokens); T-1978 gave it one, baked into the emitted HTML the same way its theme key already+was:++- `RenderSettings.theme` became `RenderSettings.palette`, carrying the theme key and the+ `WebContrastMode` as one `WebPaletteFeed` — the same pair the bridge push carries, so a bake+ cannot hold one without the other.+- `BlockHTMLEmitter.paletteAttributes` emits `data-prism-theme` plus, under increased+ contrast, `data-prism-contrast="increased"`, so the popover selects the same `document.css`+ blocks as the bridged document.+- `FootnotePopoverView` reads `@Environment(\.colorSchemeContrast)` and composes the whole+ render input in `FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:)`.++The values still live only in `document.css`, so the native/CSS split and its anti-drift test+are unchanged. What the popover consumes of them is narrow: `.prism-comment-inline`+(`--prism-text-tertiary`) is the one overridden token its content can reach today, since block+comments are filtered out and paragraphs, lists and blockquotes use no other overridden token.+Baking the state regardless is what keeps the pair applicable only together. The popover does+not update live — a sheet open when the setting changes keeps its baked palette until+reopened (T-1979). See decision-log Decision 5.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 106b238..8b3f10d 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 +- Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings still shows the previous values until it is reopened; that is tracked separately (T-1979). - Search and reading-position restore no longer lose their place to content the document hides (T-1944). Three faults shared one cause: hidden content — the body of a collapsed section, or the carrier holding a document's YAML frontmatter — measures as a zero-size box sitting exactly at the top of the window, and several parts of the app read that as "visible". Stepping to a search match inside a collapsed section silently did nothing: the section expanded, but the app had already treated the invisible match as on screen and skipped the scroll; the match is now brought to its usual resting place a third of the way down the window as soon as the section opens. Reopening a document after collapsing the section you were reading left the page at the top instead of anywhere near your place; the restore now lands on the collapsed heading that hides the saved block — and a stale saved position pointing at the hidden frontmatter carrier now falls back to the top of the document instead of doing nothing. Hidden matches also no longer count as on-screen when deciding which highlights to draw, and expanding a section lights its highlights up immediately instead of waiting for the next scroll. The zero-size test now lives in one shared check used by every reader of section layout, so a future feature cannot quietly reintroduce the assumption. Matches inside nested collapsed disclosure blocks (`<details>`) are tracked separately (T-1930). - Stepping to a search match now scrolls there in one motion and leaves the match itself in view (T-1918). Two parts of the app both moved the page on the same step — one lining the top of the match's block up with the top of the window, the other placing the match about a third of the way down — so the page could visibly jump twice, and which position stuck depended on timing. When the block was taller than the window, the block-top alignment could even settle with the match below the fold. One owner scrolls now, resting the match about a third of the way down the window every time. Two more gaps closed with it: stepping to a match far outside the part of the document being highlighted found no highlight to scroll to and relied on the other, block-based jump to get anywhere near it; and with a single match found, pressing next or previous after scrolling away to read something else did nothing — it now brings the match back into view. A match inside a collapsed section initially still did not scroll; the hidden-content fix above (T-1944) closed that gap. - Searching for text that lives in a footnote's definition now highlights and scrolls to the right reference badge when the same footnote is referenced more than once (T-1853). Badges were looked up document-wide by footnote number, so stepping to the match belonging to a later reference always marked and scrolled to the document's first badge — the one actually selected never even showed as matched. Each reference's badge is now resolved within its own block, and repeated references inside a single block are told apart by position, so stepping through matches visits each badge in turn. Text that merely looks like a reference — `` `[^1]` `` written in inline code — still occupies its place in the match order but renders no badge, so stepping onto it marks the nearest following badge in the block, or the last one when none follows.@@ -27,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The **Body Font** you choose in Settings now applies to the document (T-1827), and iOS **Larger Text** (Dynamic Type) now scales it (T-1828). Since the WebKit rendering cutover the document was drawn at a fixed system font and a fixed base size: picking a body font moved only the preview in Settings, and raising Larger Text scaled the app's toolbars, sidebars, and panels while paragraphs, headings, lists, and tables stayed put. Body text, headings, lists, and tables now use the selected family — code blocks and inline code stay monospace — and the document's base size follows the system text size, combined with the in-app Text Size slider rather than replaced by it. Both follow changes live, without reloading the document, and both survive a WebKit process recovery. Because a size or family change reflows the text, where you were reading can shift on screen; re-anchoring the reading position across a reflow is tracked separately. A font that is no longer installed falls back to the system font instead of failing, and a font name is applied as text only, so it cannot alter the document's styling. On Mac, document text also returns to the 15pt reading size the app used before the rendering engine changed — the engine cutover had left it at the iOS size, which is larger than intended on a Mac and left no room below the Text Size slider's 80% minimum. Mac documents now follow the system Text Size setting as well. - Footnotes keep working after a one-line block of raw HTML (T-1877). Writing something like `<div>Text</div>` or `<details><summary>More</summary>Text</details>` on a single line silently switched off every footnote from that point on: references stayed as plain `[^1]` text instead of becoming badges, no popover was available, and the definitions themselves showed up as ordinary paragraphs. Footnote preprocessing was waiting for a closing tag that had already gone past on the same line, so it treated the rest of the document as raw HTML. Opening and closing tags are now balanced within the line, including nesting (`<div><div>x</div></div>`) and self-closing forms (`<div/>`). A tag genuinely left open — `<div>x</div><section>`, or a mismatched `<div>Text</section>` — still starts an HTML block, so definitions written inside raw HTML are still left alone. A tag name that only appears inside a quoted attribute value (`<table title="<div>">Content</table>`) no longer counts as real markup; it used to leave a phantom block open and switch off the following footnotes in exactly the same way, and so did a stray or deliberately-escaped quote inside such a value (`<div title="a"b">`). A slash inside an unquoted attribute value (`<div data-url=https://example.com/>`) is read as part of the address rather than as a self-closing tag, so definitions written under such a tag are still left alone. Checking a line is also faster than it was: where a line used to be searched up to eleven times over, once per recognised tag name, it is now walked once — several times quicker on a line that contains a `<` but opens no block — and balancing a line's tags costs time in proportion to the number of tags rather than its square. Two ways footnote pre-processing can still disagree with the real parser about where a raw-HTML block ends are tracked separately: a blank line after an unclosed tag, and a block tag name written inside an HTML comment (`<!-- comment with <div> -->`), which still switches off the footnotes that follow it. - Changing the theme while viewing raw source no longer brings back the previous version of the document (T-1759). If the file changed on disk — or a URL document was refreshed — at the same moment the colours changed, the recolour worked from a snapshot taken before the reload and, finishing last, put the old text back on screen, where it stayed until the next reload or raw/rendered toggle. Recolouring now only applies while the lines it started from are still the ones being shown.-- 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 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 were left on the standard palette at the time; they follow the setting as of the footnote-popover fix above (T-1978). - Jumping to a specific place in a document you have read before now lands where you asked instead of at your saved reading position (T-1775). This covers every way you can ask: following a link to a heading, picking a table-of-contents entry, opening a note, and stepping to a search match. The requested target was queued while the document's HTML was being prepared in the background, and the saved position — restored a moment later — replaced it, so the page settled where you last stopped reading. An explicit target now takes precedence for that load. Reading position is otherwise untouched: an ordinary reopen, returning from raw source, and a reload after the file changes on disk or a URL refresh all still restore where you left off, even with a search active. - Adding a note to text selected after a footnote badge now quotes the text you actually selected (T-1876). In a paragraph like `before[^1] after`, selecting `after` quoted words from near the start of the paragraph instead, and saving stored a wrong source range, which the note then carried into relocation and inline-note export. The rendered document's text-to-source map now keeps its offsets in the block's own coordinates across any number of footnote badges. Footnotes inside list items and table cells are tracked separately. - 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.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 3343b94..d41122d 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -65,7 +65,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 4. Badge substitution happens inside `InlineHTMLRenderer.render`, NOT by splitting the source in the emitter (T-1716/T-1945). `FootnoteReferenceScanner.scan` walks the block's inline SOURCE once and marks every occurrence that is spelled verbatim (no backslash escape anywhere in the token) and resolvable, rewriting only its identifier characters into a reserved Private Use Area window. The marker is length-preserving (runs come out in block coordinates — no rebasing, `rebased` is gone) and parse-shape-preserving (`[`, `^`, `]` untouched, so cmark builds the same tree). The whole marked source is then parsed ONCE 5. `InlineHTMLRenderer.Walker` turns a marker into a badge only where a badge is legal: TEXT position **and** not nested inside an element that cannot contain an anchor. Text position alone is not sufficient — the badge is itself an `<a>`, so a `Link` ancestor disqualifies it (`<a>` inside `<a>` is invalid HTML), tracked by the walker's `linkDepth` counter. Every other landing place — a code span, a link destination, an image `src`/`alt`, raw inline HTML, or text inside a link — is restored to the literal `[^id]` the author wrote. Classification is never inferred from parsed text, which is the failure class the redesign removes. `BlockHTMLEmitter.footnoteBadge` builds the styled pill-badge anchor (`prism://footnote/{id}`, display number from `FootnoteData`) carrying `data-prism-chrome`: inert, unselectable, covered by no source-map run 6. Badge taps produce `prism://footnote/{id}` links → `linkActivated` over the bridge → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` popover state (`PrismLinkRoute.footnotePrefix`)-7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform+7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform. The popover page is bridge-less, so everything the document surface receives over the bridge must instead be BAKED into the emitted HTML via `RenderSettings`. Baked today: the palette — theme key **and** Increase Contrast state, carried as one `WebPaletteFeed` so they can never be applied out of step (T-1542/T-1829), emitted as `data-prism-theme`/`data-prism-contrast` by `BlockHTMLEmitter.paletteAttributes`; and the typography custom properties (T-1978, emitted as a validated `<html style>` by `BlockHTMLEmitter.rootStyleAttribute`). Not baked: the mermaid theme config and the note/search/section state, none of which a footnote fragment can contain. `FootnotePopoverView.renderSettings(settings:colorScheme:contrast:dynamicTypeSize:)` is the single pure builder of that render input — it reuses `WebDocumentControllerFactory.typographyVariables` rather than resolving fonts again (font-settings Decision 18), and its equatable result is what a future re-present key (T-1979) observes 8. `FootnoteStripping` removes `[^id]` references from heading text used in ToC entries, anchor IDs, and window titles 9. Search integration: `searchableText(with:)` appends footnote content to host blocks; badge highlighting indicates matches
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex bfc829d..4dce529 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -14,7 +14,7 @@ Each round = `make install` → user-reported issues → fix → re-test. Every item is confirmed working on the user's iPhone. ### Round 2 (`7643d79`, `cbe9237`, `8d573d8`, `da7a087`)-1. **Footnote popover theme** (`7643d79`): `RenderSettings.theme` baked onto `<html data-prism-theme>` (the popover page has no live bridge to push applyTheme).+1. **Footnote popover theme** (`7643d79`): the baked theme key emitted onto `<html data-prism-theme>` (the popover page has no live bridge to push applyTheme). Since T-1978 that field is `RenderSettings.palette` (a `WebPaletteFeed`), which also bakes `data-prism-contrast`, and typography is baked beside it as a validated `<html style>`. 2. **Code-block header** (`7643d79`): `.prism-code-chrome` styled — language label leading, copy button trailing (flex), `<pre>` flush beneath. 3. **DEBUG probes stripped** (`7643d79`). 4. **Notes affordance gutter** (`cbe9237`): block-level "+" / indicator dot absolutely positioned in `<main>`'s left gutter.
Every assertion here is on emitted HTML. Worth one manual pass: open a footnote with a non-system Body Font at an accessibility Dynamic Type size, on a dark theme, with Increase Contrast on, and confirm the popover matches the document behind it. That covers the CSP/specificity link the automated tests do not.
reset() emits with a default RenderSettings(), so the transient post-dismissal page has no baked palette. Pre-existing and unchanged by this PR, but if a light flash is visible during the sheet's dismissal animation on a dark theme, it belongs with T-1979.
showHTMLComments and the catalog strings are also baked and equally frozen at present time; T-1979's re-present key should cover the whole RenderSettings value, which is now conveniently one Equatable thing.